Add session distribution to the AI Governance metrics API
What does this MR do and why?
- Adds
sessionDistributionto the existingaiGovernanceMetricsGraphQL type (on bothgroupandproject), next tosessionsandagents. It returns a list of{ name: String!, count: Int! }and is the backend for the session distribution pie card on the AI Governance dashboard (#629071+). - GitLab Duo sessions are grouped by
flow_typewithout its version suffix (chat/v1becomeschat). External sessions (Claude Code via the Compliance API or glab) are grouped byagent_type. A missing or blank name is reported asunknown. - It follows the resolver's existing
timeframe(bounds onsession_started_at, from inclusive, to exclusive) andagentClass:ALLreturns both,INTERNAL_DAPonly GitLab Duo sessions (source = gitlab_duo),EXTERNALonly external sources. This is the same source-based agent class mapping as the sessions list API in #630095+. - Scoped to the group and its descendants, including project namespaces, or to one project. Top 10 by count, ties broken by name. The dashboard donut folds the rest into "Other".
- Implemented for PostgreSQL and ClickHouse. The backend is picked the same way as the other metrics (ClickHouse when enabled for analytics). The ClickHouse query reads
siphon_ai_governance_sessionsand dedups Siphon row versions withargMaxover_siphon_replicated_at, like the existing metrics service. - Field is marked experiment, 19.5.
Data source
- Reads
ai_governance_sessions(added in Add ai_governance_sessions table (!256344 - merged), replicated to ClickHouse in Replicate ai_governance_sessions to ClickHouse ... (!256732 - merged)), notduo_workflows_workflows. External sessions are moving off workflows (#630013+) and the dashboard is migrating to this table (#630706+), so building on workflows would miss Claude Code sessions and need a rewrite. - The table fills once #630013+ (external sessions) and the Duo session sync land. Duo rows are written by the sync worker only when the
sync_ai_governance_sessionsflag is on, and that flag is still off. Until then this field returns an empty list. - Agent class here follows
source, while the existingsessionsandagentsKPIs split onWorkflow.agent_type IS NULL. The sync writer on master stores every workflow asgitlab_duo, so a workflow-backed external session counts asEXTERNALin those KPIs butINTERNAL_DAPhere, until #630095+ moves the KPIs tosource.
Feature flag
New wip flag ai_governance_dashboard_charts, default off. It's meant to gate this and later chart cards. When it's off, the field returns null and no query runs. The flag isn't pushed to the frontend yet, the card MR will do that.
How to try it
Enable the flag:
Feature.enable(:ai_governance_dashboard_charts)Then run this query in GraphiQL:
query {
group(fullPath: "my-group") {
aiGovernanceMetrics(timeframe: LAST_30_DAYS, agentClass: ALL) {
sessionDistribution {
name
count
}
}
}
}project(fullPath:) works the same way, and agentClass: INTERNAL_DAP or EXTERNAL narrows the result.
Database
One new aggregate query per backend. No new index needed: the existing i_ai_governance_sessions_on_namespace_started_at_id (namespace_id, session_started_at, id) covers the namespace and time filters, then the query reads source, flow_type and agent_type from the heap for the matching rows.
PostgreSQL SQL:
SELECT COUNT(*) AS "count_all",
CASE WHEN source = 0 THEN COALESCE(NULLIF(split_part(flow_type, '/', 1), ''), 'unknown')
ELSE COALESCE(NULLIF(agent_type, ''), 'unknown') END AS name
FROM "ai_governance_sessions"
WHERE "ai_governance_sessions"."namespace_id" IN (
SELECT "namespaces"."id" FROM "namespaces" WHERE (traversal_ids @> ('{22}'))
)
AND "ai_governance_sessions"."session_started_at" >= '2026-08-29 00:00:00'
AND "ai_governance_sessions"."session_started_at" < '2026-09-28 17:02:39.908015'
GROUP BY name
ORDER BY COUNT(*) DESC, name
LIMIT 10(With INTERNAL_DAP it adds AND source = 0, with EXTERNAL it adds AND source != 0.)
Local plan from a tiny local test database (0 rows), for shape only:
Limit (cost=3.44..3.44 rows=1 width=40) (actual time=0.015..0.015 rows=0 loops=1)
-> Sort
Sort Key: (count(*)) DESC, (CASE ... END)
-> GroupAggregate
-> Sort
-> Nested Loop
-> Seq Scan on namespaces
Filter: (traversal_ids @> '{22}'::bigint[])
-> Index Scan using i_ai_governance_sessions_on_namespace_started_at_id on ai_governance_sessions
Index Cond: ((namespace_id = namespaces.id) AND (session_started_at >= '2026-08-29 00:00:00+00') AND (session_started_at < '2026-09-28 17:02:39+00'))
Planning Time: 2.117 ms
Execution Time: 0.041 msai_governance_sessions has no rows in production yet because the write paths aren't live, so a postgres.ai plan wouldn't be representative until it fills.
ClickHouse SQL:
SELECT
if(source = 0,
coalesce(nullIf(splitByChar('/', ifNull(flow_type, ''))[1], ''), 'unknown'),
coalesce(nullIf(ifNull(agent_type, ''), ''), 'unknown')) AS name,
count() AS count
FROM (
SELECT
id,
session_started_at,
argMax(source, _siphon_replicated_at) AS source,
argMax(flow_type, _siphon_replicated_at) AS flow_type,
argMax(agent_type, _siphon_replicated_at) AS agent_type,
argMax(_siphon_deleted, _siphon_replicated_at) AS deleted
FROM siphon_ai_governance_sessions
WHERE startsWith(traversal_path, {traversal_path:String})
AND session_started_at >= {from:DateTime64(6, 'UTC')}
AND session_started_at < {to:DateTime64(6, 'UTC')}
GROUP BY traversal_path, session_started_at, id
)
WHERE deleted = false
GROUP BY name
ORDER BY count DESC, name ASC
LIMIT {limit:UInt8}The inner filters match the table's primary key (traversal_path, session_started_at, id), so ClickHouse prunes granules before dedup.
Later
The dashboard card that uses this field is Add 'Which AI agents developers are using' card... (!258337) (session distribution card).