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 into tier_0..tier_N from 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 bare userTier fails with "userTier requires the thresholds parameter: use userTier(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.
  • flowTypesUsed and activeDays range filters. Distinct flow types the user ran, and distinct days the user created flows, in the selected period. Same shape as featuresCount on AiUsageEvents (inclusive From/To pair, strict operators shifted by one, clamped to a 32-bit Int), with two differences: they mount on the duoWorkflows engine field rather than aggregated, 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 the SourceAnalyzer::equality_expands_to_range hook; the bound shifting lives in a shared inclusive_int_range_bound helper that featuresCount uses 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 asc works. The sort field is named bare or by its alias: the sort: 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 userTier values as plain text (tier_0) and the column header through labelWithParameter, which stringifies the thresholds array without spaces: "User tier (4,25,100)". Flattening the parameter values there (.flat() before .map(String) in app/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 compile

Sessions 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 asc
Test 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"])
RUBY
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]}}, {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 asc
Test 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, totalCount
Test 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 desc
Test 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" }
  ]
}
Edited by Daniele Rossetti

Merge request reports

Loading
Loading