Add group dimension to AiUsageEvents engine
What does this MR do and why?
This MR adopts the traversal_path dimension (added to the framework in !254110 (merged)) in the Analytics::AggregationEngines::AiUsageEvents engine.
- Adds a
group_iddimension to the engine, keyed bydepth, reading thetraversal_pathcolumn:traversal_path :group_id, :integer, -> { sql('traversal_path') }, association: { finder: ->(ids) { Group.id_in(ids).with_route.index_by(&:id) }, preloader: Preloaders::GroupPolicyPreloader }, description: 'Group at the requested depth of the hierarchy. ' \ 'NULL for events tracked above that depth. Events tracked in a project at that depth ' \ 'bucket by project namespace ID, which also resolves to NULL.' - The
duoUsageEventsfield onGroup,Project, andOrganizationanalytics gainsdimensions { group(depth: Int) { id fullPath ... } }. depthdefaults to1. The dimension resolves toGrouprecords through the association mechanism, so GraphQL returns aGroupobject.- The
finderloads groups with their routes, and thepreloaderrunsPreloaders::GroupPolicyPreloader, so resolvingfullPathand theread_groupauthorization check does not issue one query per group. The request spec guards this withActiveRecord::QueryRecorder.
Why
The "Duo adoption by group" dashboard chart needs distinct AI users per group (usersCount grouped by group), and per-group trends when combined with the existing timestamp date bucket. A first attempt, !250645 (closed), grouped by the last segment of the path and was closed after review, since that segment is the project namespace id for events tracked in a project, not a group id. This MR wires the depth-based traversal_path dimension, added to the framework in MR 254110, into the AiUsageEvents engine instead.
Notes for reviewers
- NULL semantics:
ai_usage_events.traversal_pathidentifies namespaces only, with no marker for project rows. When the requested depth equals the path length of an event tracked in a project, the extracted id is the project namespace id; theGroupfinder does not find it, and GraphQL returnsgroup: nullfor that row. Events tracked in a group shallower than the requested depth, and rows with the0/placeholder path, also returngroup: null. Severalgroup: nullnodes can appear in one response, since each unresolved id is its own ClickHouse bucket. Consumers charting adoption by group should drop null-group rows. This is covered by specs. depthis absolute: it is counted from the top-level group, not from the group the query is scoped to.prepare_base_aggregation_scopefilters ontraversal_pathsince !253365 (merged) merged on 2026-09-10, so the base scope and this dimension read the same column, and the scope condition is a primary key range on the rebuilt sort key.- The framework MR !254110 (merged) merged on 2026-09-15, so this MR now targets
masterand carries only the engine change, rebased onto current master as a single commit. - The sibling MR !254120 (merged) adds a
groupIdfilter to the same engine and touches the same generated GraphQL files, so whichever of the two merges second needs a rebase.
Dependencies and merge order
- The
traversal_pathcolumn onai_usage_eventswas added by !253282 (merged) (merged 2026-09-08), with a'0/'default and dual writes from Ruby. - !253365 (merged) ("Rebuild AI Usage Events", part of #608216) merged on 2026-09-10 and rebuilt the table with
traversal_pathas the leading sort key, resolving historic rows throughnamespace_traversal_paths_dict; rows it could not resolve keep'0/'and returngroup: null. - !254110 (merged) (the framework
traversal_pathdimension) merged on 2026-09-15. - Nothing else blocks this MR.
Query plan
Since !253365 (merged), ai_usage_events is ordered by (traversal_path, event, timestamp, user_id) and the base scope is startsWith(traversal_path, ...), a primary key range that prunes parts and granules. The dimension expression (splitByChar plus arrayElement over the same column) is evaluated only on rows that survive pruning, in the inner query, like the existing computed feature dimension.
Representative query generated by the engine for the dimension group(depth: 2) plus monthly timestamp, metric usersCount, scoped to the group gitlab-org (traversal path 1/24/, where 1 is the organization id). Depth 2 reads the third path segment because the first one is the organization id.
SELECT `ch_aggregation_inner_query`.`aeq_group_id_2` AS aeq_group_id_2,
toStartOfInterval(`ch_aggregation_inner_query`.`aeq_timestamp_granularity_monthly`, INTERVAL 1 month) AS aeq_timestamp_granularity_monthly,
COUNT(DISTINCT `ch_aggregation_inner_query`.`aeq_users_count`) AS aeq_users_count
FROM (
SELECT nullIf(toUInt64OrNull(arrayElement(splitByChar('/', traversal_path), 3)), 0) AS aeq_group_id_2,
timestamp AS aeq_timestamp_granularity_monthly,
user_id AS aeq_users_count,
`ai_usage_events`.`traversal_path`, `ai_usage_events`.`event`, `ai_usage_events`.`timestamp`, `ai_usage_events`.`user_id`
FROM `ai_usage_events`
WHERE startsWith(`ai_usage_events`.`traversal_path`, '1/24/')
GROUP BY ALL
) `ch_aggregation_inner_query`
GROUP BY ALLEXPLAIN indexes = 1 on a local development ClickHouse with 175,953 rows in ai_usage_events, on the rebuilt schema:
Expression ((Project names + Projection))
Aggregating
Expression ((Before GROUP BY + (Change column names to column identifiers + (Project names + Projection))))
Aggregating
Expression (Before GROUP BY)
Expression ((WHERE + Change column names to column identifiers))
ReadFromMergeTree (gitlab_clickhouse_development.ai_usage_events)
Indexes:
MinMax
Condition: true
Parts: 14/14
Granules: 31/31
Partition
Condition: true
Parts: 14/14
Granules: 31/31
PrimaryKey
Keys:
traversal_path
Condition: (traversal_path in ['1/24/', '1/240'))
Parts: 5/14
Granules: 5/31
Search Algorithm: binary search
Ranges: 5The PrimaryKey step shows the scope condition used as a key range on traversal_path (5 of 14 parts and 5 of 31 granules read) before the dimension expression runs on the surviving rows.
References
- Parent issue: https://gitlab.com/gitlab-org/gitlab/-/work_items/605518
- Framework MR: !254110 (merged)
traversal_pathcolumn and dual write: !253282 (merged)- Table rebuild: !253365 (merged), #608216
- Closed first attempt: !250645 (closed)
Screenshots or screen recordings
Backend only, no screenshots.
How to set up and validate locally
Enable ClickHouse for analytics in GDK, with AI usage events flowing into ai_usage_events. Run the ClickHouse migrations so the table has the traversal_path column and the rebuilt sort key from !253365 (merged). Rows whose namespace the traversal path dictionary could not resolve show group: null.
Open http://gdk.test:3000/-/graphql-explorer and run this query, adjusting fullPath and depth:
query {
group(fullPath: "gitlab-org") {
analytics {
duoUsageEvents {
aggregated {
nodes {
dimensions {
group(depth: 2) { id fullPath }
timestampMonthly: timestamp(granularity: "monthly")
}
usersCount
totalCount
}
}
}
}
}
}Expect one node per subgroup and month. Events tracked directly in the top-level group, or in its direct projects, appear with group: null.
42 engine spec examples and 31 request spec examples pass locally.
MR acceptance checklist
Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.