feat: add group dimension to AiUsageEvents and DuoWorkflows analytics
What does this MR do and why?
Adds group as a dimension and sort field to both analytics sources that expose it, so a query can bucket results per group:
dimensions: group
metrics: usersCount, creditsUsedSum
sort: usersCount descThis is the DAP Impact v1 "Group comparison" panel, which today needs one request per group and is capped at 20 groups. It becomes a single query covering any number of groups.
Two compiler gaps blocked it, and both are fixed here:
-
No scalar integer parameter kind.
ParameterConstrainthadEnum,Range(f64) andList; #213 (closed) addedListItem::Intfor integers inside a list, but a scalarIntargument had nowhere to live. ARange-backed default rendersdepth: 1.0, which the GraphQLIntargument rejects, so plaindimensions: groupwould have been broken whilegroup(depth=2)worked. AddsParameterConstraint::Int { min, max }andParameterDefault::Int, mirroringListItem::Intand reusing itsparse_graphql_intgrammar andvalidate_int_itembounds check rather than restating them. -
A dimension could be parameterised or an object, never both.
render_parameterized_bodyemittedname(args)and never consultedrender_query_field, the only path that produced a selection set, sogroup(depth: 1) { … }was inexpressible. Adds afield_selection_sethook onSourceAnalyzer, defaulting toNone, consulted on every path a field can take — parameterised, the empty-parameters fallback, and the plainStaticarm. A source declares an object field's shape once.
depth mirrors the engine: absolute, counted from the hierarchy root rather than the query scope, default 1, bounds 1..99. Sorting needed no new code, because sort resolution already inherits the selected dimension's parameters — which matters, since the engine keys the row on the depth and an orderBy without parameters: { depth: N } fails with the specified identifier is not available: 'group'.
Why group is not a filter
The engines expose a groupId argument, but GLQL does not compile a filter to it. descendantsScope (group in (...), shipped in 19.3) already narrows to any set of groups and projects with the same subtree semantics, so a group filter would be a second spelling for the same thing. group = "path" also already means scope, so adding a filter under the same keyword would leave quoting to decide the mechanism.
group therefore keeps the meanings it has, plus one:
| GLQL | Compiles to | Meaning |
|---|---|---|
group = "gitlab-org" |
group(fullPath: "gitlab-org") |
query scope |
group in ("a", "b") |
descendantsScope: {groupFullPaths: [...]} |
multi-group scope |
dimensions: group |
group(depth: N) { … } |
breakdown (new) |
Engine side
Both engines declare the dimension with the same framework class, so the GraphQL surface is identical across the two sources:
- AiUsageEvents: gitlab!254731 (merged) — merged
- DuoWorkflows: gitlab!255369 (merged) — merged 2026-09-18
Example Usage
Build prerequisite, once:
cd glql_rb
bundle install
bundle exec rake compileGroup comparison panel (DuoWorkflows)
mode: analytics
query: type = duoworkflow
dimensions: group
metrics: usersCount, creditsUsedSum
sort: usersCount descTest via ruby extension
cd glql_rb # after the build prerequisite above
bundle exec ruby -Ilib - <<'RUBY'
require "gitlab_query_language"
require "json"
require "net/http"
query = "type = duoworkflow"
context = { mode: "analytics", group: "gitlab-org", dimensions: "group",
metrics: "usersCount, creditsUsedSum", sort: "usersCount desc" }
compiled = Glql.compile(query, context)
puts "== Generated GraphQL =="
puts compiled["output"]
uri = URI("https://gitlab.com/api/graphql")
http = Net::HTTP.new(uri.host, uri.port); http.use_ssl = true
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json")
req["PRIVATE-TOKEN"] = ENV.fetch("GITLAB_TOKEN")
req.body = { query: compiled["output"], variables: { limit: 3 } }.to_json
response = JSON.parse(http.request(req).body)
transformed = Glql.transform(response["data"], { mode: context[:mode], fields: compiled["fields"] })
puts "\n== Transformed rows =="
puts JSON.pretty_generate(transformed["data"])
RUBYGenerated GraphQL:
query GLQL($before: String, $after: String, $limit: Int) {
group(fullPath: "gitlab-org") {
analytics {
duoWorkflows {
aggregated(before: $before, after: $after, first: $limit, orderBy: [{direction: DESC, identifier: "usersCount"}]) {
count
pageInfo { startCursor endCursor hasNextPage hasPreviousPage }
nodes {
dimensions {
group(depth: 1) { id fullPath webUrl fullName }
}
usersCount
creditsUsed { creditsUsedSum: sum }
}
}
}
}
}
}Rows are not included: duoWorkflows is behind the dap_impact_v1 feature flag and needs a token with access to Duo analytics, so the response is not reproducible from a public request. The compile and transform stages are covered by tests, including a case asserting that a group: null row (a flow tracked directly in a project) survives flattening rather than being dropped.
Subgroup breakdown (AiUsageEvents)
mode: analytics
query: type = aiusageevent
dimensions: group(depth=2)
metrics: usersCount
sort: usersCount descTest via ruby extension
Same snippet as above, with:
query = "type = aiusageevent"
context = { mode: "analytics", group: "gitlab-org", dimensions: "group(depth=2)",
metrics: "usersCount", sort: "usersCount desc" }Generated GraphQL:
query GLQL($before: String, $after: String, $limit: Int) {
group(fullPath: "gitlab-org") {
analytics {
duoUsageEvents {
aggregated(before: $before, after: $after, first: $limit, orderBy: [{direction: DESC, identifier: "usersCount"}]) {
count
pageInfo { startCursor endCursor hasNextPage hasPreviousPage }
nodes {
dimensions {
group(depth: 2) { id fullPath webUrl fullName }
}
usersCount
}
}
}
}
}
}depth: 2 is absolute, so under a top-level group it yields one row per direct subgroup. Events tracked directly in a project at that depth resolve to group: null, which is engine behaviour and documented on the dimension; charts should drop those rows.
How to set up and validate locally
cargo test— 1714 examples pass.cargo clippy --all-targets -- -D warningsandcargo fmt --check— clean.cargo run --bin generate-schema && npm run lint:prettier:fix—src/schema/schema.jsonregenerates with no drift.DUMP_GRAPHQL=1 cargo test && npm run test:graphql— everygroupdocument validates against the current schema dump.
Verification beyond the test suite
- No regression in existing sources. A corpus of 16 queries covering all 7 analytics sources, every parameter kind (
granularityenum,quantilefloat,thresholdslist) and standard mode was compiled onmainand on this branch. The generated GraphQL is byte-identical, so neither the newIntvariant nor routing theStaticarm through the hook perturbs existing rendering. - Engine conformance. Every dimension and metric GLQL publishes, for all 7 sources, was compiled and validated against the live GitLab schema. All valid.
- Frontend, tables: the dimension resolves to a
Group, whichpresentersByObjectTypealready renders withLinkPresenter(it needswebUrlandfullName, both in the selection), and the selection carriesidso rows pair across periods for the trend column. - Frontend, charts: not covered.
labelByObjectTypeinchart_data.jshandles onlyUserCoreandProject, so aGrouprenders an empty label — and because that string is the series identity downstream, distinct groups collapse into one entry. A follow-up ingitlab-org/gitlabmust register aGroupformatter before this dimension is charted. Raised by @jiaan below. - Transformer untouched.
src/transformer/context.rsis byte-identical tomain.
Follow-ups
Neither blocks this MR, both are in gitlab-org/gitlab:
- Chart formatter. Register
GroupinlabelByObjectType, e.g.(value) => value.fullPath ?? value.fullName. Required before the dimension is used in a chart, on either source. - User docs.
doc/user/glql/data_sources/duo_workflows.mdandai_usage_events.mdlist every dimension and neither has agrouprow or thedepthparameter.duo_workflows.mdis also missinguserTier, so one follow-up could cover both gaps.
Issue references
Closes #169 (closed), which covers both AiUsageEvents and DuoWorkflows.
Follow-up #228 (closed) moves the other object fields (project, user, pipeline, …) onto the same hook, after this and the #187 (closed) train land.