feat(duo_workflows): add userTier dimension and activity range filters
What does this MR do and why?
Wires the DuoWorkflows engine's per-user activity parts (gitlab-org/gitlab#627697, shipped in 19.4) into the GLQL source, for the five DAP Impact v1 Adoption tab panels that classify users by activity before counting:
userTier(thresholds=[...])dimension and sort field. The engine buckets each user by their number of flows over the whole selected period intotier_0..tier_Nfrom N client-supplied thresholds. The parameter is a required list of at most nine strictly ascending integers of at least 1 (TierDimension), and GLQL enforces all of that at compile time, where the engine only rejects at request time. There is no default, on purpose: the dashboard scales the thresholds to the selected period (so many flows per week), so any fixed value would be wrong for most periods. A bareuserTierfails with "userTierrequires thethresholdsparameter: useuserTier(thresholds=...)". A single threshold may be written without brackets (userTier(8)). Sorting carries the thresholds (parameters: {thresholds: [4, 25, 100]}), which the engine coerces item by item.flowTypesUsedandactiveDaysrange filters. Distinct flow types the user ran, and distinct days the user created flows, in the selected period. Same shape asfeaturesCounton AiUsageEvents (inclusiveFrom/Topair, strict operators shifted by one, clamped to a 32-bitInt), with two differences: they mount on theduoWorkflowsengine field rather thanaggregated, and they accept=, which compiles to both bounds (activeDays = 1→activeDaysFrom: 1, activeDaysTo: 1) because the abandonment panel needs it. The equality expansion reuses the date-equality path through theSourceAnalyzer::equality_expands_to_rangehook; the bound shifting lives in a sharedinclusive_int_range_boundhelper thatfeaturesCountuses too.
Stacked on the list-parameter train !520 (merged) → !521 (merged) → !522 (merged), whose last step this MR targets and needs for thresholds. src/schema/schema.json is regenerated: the new filters, dimension and sort field, and the thresholds parameter published as {"kind": "List", "items": [{"kind": "Number", "min": 1, "max": 2147483647}], "min_length": 1, "max_length": 9, "strictly_ascending": true, "required": true}.
Behaviour change for every source
An equality on a field whose = expands to a closed range already pins both bounds, so combining it with another comparison on the same field is a compile error: "created = ... selects an exact value and cannot be combined with another comparison on created. Use either the equality or the range." This applies to activeDays and flowTypesUsed (activeDays = 5 and activeDays > 1) and to every date filter of every source, standard mode included: type = Issue and created = 2024-01-01 and created > 2023-01-01 is rejected, where only one of the two bounds can reach the query. The monolith bump that ships this needs the same callout, since GLQL blocks written that way render the error once the bump ships. A registry-wide test (test_equality_filters_resolve_to_engine_keys) checks that every =-admitting filter field of every source resolves to engine keys after expansion.
Notes
- The tier is computed over the whole selected period:
created(weekly), userTier(...)does not give a per-week tier. Per-period normalisation is the client's, by scaling the thresholds. - Tier labels sort lexicographically as they do numerically (single-digit tiers), so
sort: userTier ascworks. The sort field is named bare or by its alias: thesort:value is split on commas before parsing, so a parameter list cannot be spelled there; the thresholds come from the selected dimension. The transform keeps the generated response key next to a user alias, as for every aliased parameterised dimension. - Docs table for the source in gitlab-org/gitlab follows after release + bump.
- The monolith renders
userTiervalues as plain text (tier_0) and the column header throughlabelWithParameter, which stringifies thethresholdsarray without spaces: "User tier (4,25,100)". Flattening the parameter values there (.flat()before.map(String)inapp/assets/javascripts/glql/utils/chart_data.js) is a monolith follow-up alongside the bump.
Closes #209 (closed)
Example Usage
Every example was compiled with the gem from this branch, run on gitlab.com (group = "gitlab-org", last 28 days) and transformed; the rows are what came back.
cd glql_rb
bundle install
bundle exec rake compileSessions by user tier and flow type
mode: analytics
query: type = DuoWorkflow and group = "gitlab-org" and created > -28d
dimensions: userTier(thresholds=[4, 25, 100]), flowType
metrics: totalCount, usersCount
sort: userTier asc, flowType ascTest 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 and group = "gitlab-org" and created > -28d'
context = { mode: "analytics", dimensions: "userTier(thresholds=[4, 25, 100]), flowType",
metrics: "totalCount, usersCount", sort: "userTier asc, flowType asc" }
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: 6 } }.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"])
RUBYquery GLQL($before: String, $after: String, $limit: Int) {
group(fullPath: "gitlab-org") {
analytics {
duoWorkflows(createdAtFrom: "2026-08-19 23:59") {
aggregated(before: $before, after: $after, first: $limit, orderBy: [{direction: ASC, identifier: "userTier", parameters: {thresholds: [4, 25, 100]}}, {direction: ASC, identifier: "workflowDefinition"}]) {
count
nodes {
dimensions {
userTier(thresholds: [4, 25, 100])
flowType: workflowDefinition
}
totalCount
usersCount
}
}
}
}
}
}{
"count": 79,
"nodes": [
{ "usersCount": 4, "totalCount": 10, "userTier": "tier_0", "flowType": "ai_catalog_agent" },
{ "usersCount": 288, "totalCount": 400, "userTier": "tier_0", "flowType": "chat" },
{ "usersCount": 1, "totalCount": 1, "userTier": "tier_0", "flowType": "ci_expert_agent/v1" },
{ "usersCount": 59, "totalCount": 89, "userTier": "tier_0", "flowType": "code_review/v1" },
{ "usersCount": 10, "totalCount": 14, "userTier": "tier_0", "flowType": "developer/v1" },
{ "usersCount": 14, "totalCount": 15, "userTier": "tier_0", "flowType": "duo_planner/v1" }
]
}Share of users per tier, aliased and sorted by the alias
mode: analytics
query: type = DuoWorkflow and group = "gitlab-org" and created > -28d
dimensions: userTier(thresholds=[4, 25, 100]) as "Tier"
metrics: usersCount, totalCount
sort: Tier ascTest via ruby extension
Same snippet as above with this query/context.
query GLQL($before: String, $after: String, $limit: Int) {
group(fullPath: "gitlab-org") {
analytics {
duoWorkflows(createdAtFrom: "2026-08-19 23:59") {
aggregated(before: $before, after: $after, first: $limit, orderBy: [{direction: ASC, identifier: "userTier", parameters: {thresholds: [4, 25, 100]}}]) {
count
nodes {
dimensions {
userTier_thresholds_4_025_0100: userTier(thresholds: [4, 25, 100])
}
usersCount
totalCount
}
}
}
}
}
}{
"count": 4,
"nodes": [
{ "totalCount": 579, "usersCount": 375, "userTier_thresholds_4_025_0100": "tier_0", "Tier": "tier_0" },
{ "totalCount": 4536, "usersCount": 391, "userTier_thresholds_4_025_0100": "tier_1", "Tier": "tier_1" },
{ "totalCount": 14295, "usersCount": 288, "userTier_thresholds_4_025_0100": "tier_2", "Tier": "tier_2" },
{ "totalCount": 89846, "usersCount": 86, "userTier_thresholds_4_025_0100": "tier_3", "Tier": "tier_3" }
]
}Abandonment: users active on exactly one day
mode: analytics
query: type = DuoWorkflow and group = "gitlab-org" and created > -28d and activeDays = 1
metrics: usersCount, totalCountTest via ruby extension
Same snippet as above with this query/context.
query GLQL($before: String, $after: String, $limit: Int) {
group(fullPath: "gitlab-org") {
analytics {
duoWorkflows(createdAtFrom: "2026-08-19 23:59", activeDaysFrom: 1, activeDaysTo: 1) {
aggregated(before: $before, after: $after, first: $limit) {
count
nodes {
usersCount
totalCount
}
}
}
}
}
}{ "count": 1, "nodes": [{ "usersCount": 283, "totalCount": 383 }] }Multi-activity users: two or more flow types
mode: analytics
query: type = DuoWorkflow and group = "gitlab-org" and created > -28d and flowTypesUsed >= 2
dimensions: flowType
metrics: usersCount
sort: usersCount descTest via ruby extension
Same snippet as above with this query/context.
query GLQL($before: String, $after: String, $limit: Int) {
group(fullPath: "gitlab-org") {
analytics {
duoWorkflows(createdAtFrom: "2026-08-19 23:59", flowTypesUsedFrom: 2) {
aggregated(before: $before, after: $after, first: $limit, orderBy: [{direction: DESC, identifier: "usersCount"}]) {
count
nodes {
dimensions {
flowType: workflowDefinition
}
usersCount
}
}
}
}
}
}{
"count": 30,
"nodes": [
{ "usersCount": 552, "flowType": "code_review/v1" },
{ "usersCount": 550, "flowType": "chat" },
{ "usersCount": 305, "flowType": "developer/v1" },
{ "usersCount": 157, "flowType": "fix_pipeline/v1" },
{ "usersCount": 90, "flowType": "orbit_agent/v1" },
{ "usersCount": 82, "flowType": "ai_catalog_agent" }
]
}