Add session distribution to the AI Governance metrics API

What does this MR do and why?

  • Adds sessionDistribution to the existing aiGovernanceMetrics GraphQL type (on both group and project), next to sessions and agents. 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_type without its version suffix (chat/v1 becomes chat). External sessions (Claude Code via the Compliance API or glab) are grouped by agent_type. A missing or blank name is reported as unknown.
  • It follows the resolver's existing timeframe (bounds on session_started_at, from inclusive, to exclusive) and agentClass: ALL returns both, INTERNAL_DAP only GitLab Duo sessions (source = gitlab_duo), EXTERNAL only 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_sessions and dedups Siphon row versions with argMax over _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)), not duo_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_sessions flag is on, and that flag is still off. Until then this field returns an empty list.
  • Agent class here follows source, while the existing sessions and agents KPIs split on Workflow.agent_type IS NULL. The sync writer on master stores every workflow as gitlab_duo, so a workflow-backed external session counts as EXTERNAL in those KPIs but INTERNAL_DAP here, until #630095+ moves the KPIs to source.

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 ms

ai_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).

Edited by Dheeraj Joshi

Merge request reports

Loading
Loading