Add AI governance session and agent metrics services
What does this MR do and why?
Adds the aggregation service layer for the AI Governance Dashboard's AI Sessions and AI Agents KPI tiles (#603776, beta aggregation items 1 and 2). Given a group or project and a timeframe (:last_24_hours / :last_7_days / :last_30_days), Ai::Governance::MetricsService returns session and distinct-agent counts for the current window, the preceding window (for the delta badge), and a zero-filled bucketed trend series (for the sparkline).
- ClickHouse backend reads
siphon_duo_workflows_workflows(ReplacingMergeTreeargMaxdedup,startsWith(traversal_path, …)hierarchy scoping) when the instance has ClickHouse analytics enabled. - Postgres backend produces a byte-identical payload via new
Ai::DuoWorkflows::Workflowscopes (self_and_descendantshierarchy scoping) for instances without ClickHouse — the default for Self-Managed. - Owner-private chat sessions (
workflow_definition = 'chat') are excluded from every aggregate, matching the visibility rules of the existing governance surfaces. - No caller yet — this MR is inert. The GraphQL exposure (
aiGovernanceMetricsonGroupandProject, behind theai_governance_dashboardfeature flag introduced with the dashboard tab in !243109 (merged)) follows in a stacked MR.
Database review
Two aggregate queries against duo_workflows_workflows (down from six): one single-pass totals query (COUNT(*) FILTER / COUNT(DISTINCT ...) FILTER for the current and previous windows) and one grouped trend query. Each resolves the namespace hierarchy once. The group-container form (UNION ALL of the project and namespace arms) is the heavier path; the project-container form replaces the union with a single project_id = ?.
Adds index_duo_workflows_workflows_on_namespace_id_created_at (namespace_id, created_at DESC) WHERE workflow_definition <> 'chat', the namespace-arm mirror of the existing project-arm index. With it the namespace arm's created_at becomes an index condition instead of a post-scan filter, and buffers touched by that arm drop from ~26.3k to ~4.6k (~82%) on group 9970.
Migration executed on GitLab.com data via db:gitlabcom-database-testing (results in the comment below).
Query plans (postgres.ai, with the index present)
base = Ai::DuoWorkflows::Workflow.in_namespace_hierarchy(group).without_workflow_definition(%w[chat])
base.created_between(previous_from, to).count_current_and_previous(from) (totals)
https://console.postgres.ai/gitlab/projects/gitlab-production-main/sessions/53867/commands/156194
base.created_between(from, to).counts_by_created_at_bucket (trend)
https://console.postgres.ai/gitlab/projects/gitlab-production-main/sessions/53867/commands/156195
ClickHouse queries (dedup subquery + totals + trend)
Source table: siphon_duo_workflows_workflows (ReplacingMergeTree, version col
_siphon_replicated_at, ORDER BY (traversal_path, created_at, id)). All variable
values are ClickHouse {name:Type} placeholders (server-side substitution); the
%{dedup_subquery} / %{unit} tokens are Ruby format splices resolved before send.
Placeholder values (for last_7_days, frozen at 2026-07-03 15:30 UTC):
traversal_path=<org>/<group>/<subgroup>/<project_ns>/viatraversal_path(with_organization: true)excluded_definitions=['chat']from=2026-06-26 00:00:00.000000previous_from=2026-06-19 00:00:00.000000to=2026-07-03 15:30:00.000000
Deduplication subquery (shared, spliced into both queries below)
SELECT
id,
created_at,
argMax(workflow_definition, _siphon_replicated_at) AS workflow_definition,
argMax(_siphon_deleted, _siphon_replicated_at) AS deleted
FROM siphon_duo_workflows_workflows
WHERE startsWith(traversal_path, {traversal_path:String})
AND created_at >= {previous_from:DateTime64(6, 'UTC')}
AND created_at < {to:DateTime64(6, 'UTC')}
GROUP BY traversal_path, created_at, id1. Totals (current + previous windows, sessions + agents, single scan)
SELECT
countIf(created_at >= {from:DateTime64(6, 'UTC')}) AS sessions_count,
countIf(created_at < {from:DateTime64(6, 'UTC')}) AS sessions_previous_count,
uniqExactIf(workflow_definition, created_at >= {from:DateTime64(6, 'UTC')}) AS agents_count,
uniqExactIf(workflow_definition, created_at < {from:DateTime64(6, 'UTC')}) AS agents_previous_count
FROM (<dedup subquery above>)
WHERE deleted = false
AND workflow_definition NOT IN {excluded_definitions:Array(String)}2. Trend buckets (current window; %{unit} = 'day' or 'hour')
SELECT
toStartOfInterval(created_at, INTERVAL 1 day) AS bucket_start,
count() AS sessions_count,
uniqExact(workflow_definition) AS agents_count
FROM (<dedup subquery above>)
WHERE deleted = false
AND created_at >= {from:DateTime64(6, 'UTC')}
AND workflow_definition NOT IN {excluded_definitions:Array(String)}
GROUP BY bucket_start
ORDER BY bucket_start ASCNotes for the plan review
- Chat exclusion moved to the outer query (post-dedup) rather than inside the dedup
subquery as originally sketched. Inside the subquery it collided with the
argMax(...) AS workflow_definitionalias (ClickHouseILLEGAL_AGGREGATION: the rawworkflow_definitionin WHERE resolved to the aggregate). The excluded set is unchanged (['chat']);workflow_definitionis immutable so pre/post-dedup filtering is equivalent. This also mirrorsAi::DuoWorkflows::SessionArtifacts::ClickHouseFinder, which filtersworkflow_definitionin the outer query for the same reason. - Dedup GROUP BY uses the full primary key
(traversal_path, created_at, id)rather thanidalone, socreated_atstays a plain grouping column. Grouping byid+min(created_at)also hitILLEGAL_AGGREGATIONunder predicate pushdown of thecreated_atrange. Immutable PK columns make this dedup identical toid-only. with_organization: trueon the traversal path matches the CH column format used by the siblingClickHouseFinder; the plaintraversal_path(no org) would not match siphon-populated rows in production.- Placeholder type
DateTime64(6, 'UTC')matches the column type and forces UTC interpretation of the string values (mirrorsContributionAnalytics::ClickHouseDataCollector).
How to validate locally
Master:
Ai::Governance::MetricsServiceNameError: uninitialized constant Ai::GovernanceThis branch:
Ai::Governance::MetricsService.new(group, current_user: user, timeframe: :last_7_days).execute.payload{:sessions=>
{:count=>1,
:previous_count=>2,
:trend=>
[{:bucket_start=>2026-06-29 00:00:00 UTC, :count=>0},
{:bucket_start=>2026-06-30 00:00:00 UTC, :count=>0},
{:bucket_start=>2026-07-01 00:00:00 UTC, :count=>0},
{:bucket_start=>2026-07-02 00:00:00 UTC, :count=>1},
{:bucket_start=>2026-07-03 00:00:00 UTC, :count=>0},
{:bucket_start=>2026-07-04 00:00:00 UTC, :count=>0},
{:bucket_start=>2026-07-05 00:00:00 UTC, :count=>0},
{:bucket_start=>2026-07-06 00:00:00 UTC, :count=>0}]},
:agents=>
{:count=>1,
:previous_count=>1,
:trend=>
[{:bucket_start=>2026-06-29 00:00:00 UTC, :count=>0},
{:bucket_start=>2026-06-30 00:00:00 UTC, :count=>0},
{:bucket_start=>2026-07-01 00:00:00 UTC, :count=>0},
{:bucket_start=>2026-07-02 00:00:00 UTC, :count=>1},
{:bucket_start=>2026-07-03 00:00:00 UTC, :count=>0},
{:bucket_start=>2026-07-04 00:00:00 UTC, :count=>0},
{:bucket_start=>2026-07-05 00:00:00 UTC, :count=>0},
{:bucket_start=>2026-07-06 00:00:00 UTC, :count=>0}]}}Cross-check against raw rows in the same hierarchy:
scope = Ai::DuoWorkflows::Workflow.in_namespace_hierarchy(group)
scope.where(created_at: 14.days.ago..).pluck(:id, :workflow_definition, :environment, :created_at)[[7654, "resolve_sast_vulnerability/v1", "web", 2026-06-25 16:49:46 UTC],
[7656, "chat", "web", 2026-06-25 17:42:50 UTC],
[7655, "chat", "web", 2026-06-25 16:49:53 UTC],
[7644, "chat", "web", 2026-06-23 19:36:39 UTC],
[7645, "resolve_sast_vulnerability/v1", "web", 2026-06-23 19:39:00 UTC],
[7659, "resolve_sast_vulnerability/v1", "web", 2026-07-02 17:21:07 UTC]]no_chat = scope.where.not(workflow_definition: 'chat')
no_chat.where(created_at: Time.utc(2026, 6, 29)...Time.current).count
no_chat.where(created_at: Time.utc(2026, 6, 22)...Time.utc(2026, 6, 29)).count1
2Matches the payload: 1 non-chat session in the current window (2026-07-02, lands in the 07-02 trend bucket), 2 in the previous window, the 3 chat sessions excluded, and both windows contain a single distinct definition so agents is 1/1.
Toggling ApplicationSetting.current.update!(use_clickhouse_for_analytics: true/false) switches the backend; the payload shape and semantics are identical (pinned by specs for both backends).
References
- Planning issue: #603776 (AI Governance Dashboard aggregation layer)
- FE consumers: !243109 (merged) (dashboard tab scaffold), !243779 (merged) (sparkline KPI tiles)