feat(duo_workflows): status, MR count measurements and retention metrics

What does this MR do and why?

Extends the DuoWorkflows analytics source with the fields the DAP Impact v1 Work and Adoption tabs need, one commit per work item:

  1. status filter, dimension and sort field (#207 (closed)) - the nine flow lifecycle states as a closed enum (created, running, paused, finished, failed, stopped, input_required, plan_approval_required, tool_call_approval_required), rendering to the engine's status: [String!] argument with lowercase names. It's an enum rather than a free string because the engine silently drops a status name it doesn't know, so a typo would compile to a narrower filter instead of an error.
  2. createdMrCount, mergedMrCount, closedMrCount measurements (#206 (closed)) - declared through the shared Measurement helper next to creditsUsed, so each stat is a flat metric and sort field (createdMrCountSum, mergedMrCountMean, closedMrCountQuantile, ...). Fifteen new Field variants, no per-source render or sort mappings.
  3. required_dimension hook (refactor, #214 (closed) follow-ups from !485 (merged)) - the metric-requires-dimension rule moves out of AiUsageEvents' validate_query into a SourceAnalyzer::required_dimension(&Field) -> Option<Field> hook, checked once in ContextAnalyzer before sort/scope validation (same precedence as before). Every metric missing the same dimension is reported in one MetricRequiresDimension error instead of only the first, and the schema conformance helper that injects the required dimension is now keyed by source too, so DuoWorkflows attempts don't get timestamp injected. The error kind shipped in !485 (merged) after v0.37.0 was cut, so its shape (fields: Vec<Field>) can still change freely.
  4. returningUsersCount, joinedUsersCount, churnedUsersCount, previousPeriodUsersCount (#214 (closed)) - the period-over-period user metrics, requiring the created dimension via the hook above. A created filter doesn't satisfy it; created, created(monthly) and created as "Month" do.
  5. flowTypesCount metric (no issue) - the engine's distinct count of flow types per bucket, same shape as projectsCount. Spotted while confirming the engine contract; wired here since the metric lists were already open.

src/schema/schema.json is regenerated in each feature commit.

Closes #207 (closed) Closes #206 (closed) Closes #214 (closed)

Example Usage

Every example below was compiled with the gem from this branch and run against gitlab.com (group = "gitlab-org"), and the rows are what the transform returned, with keys reordered and the raw createdAt key (returned beside created, pre-existing behaviour) trimmed for readability. The engine changes for #206 (closed) and #214 (closed) (gitlab!254737 (merged), gitlab!254420 (merged)) are deployed there already.

cd glql_rb
bundle install
bundle exec rake compile

Sessions by status (Work tab)

mode: analytics
query: type = DuoWorkflow and group = "gitlab-org" and created > -30d
dimensions: status
metrics: totalCount, usersCount
sort: totalCount desc
Test via ruby extension
cd glql_rb # after the build prerequisite above

bundle exec ruby -Ilib - <<'RUBY'
require "gitlab_query_language"
require "json"
require "open3"

query   = 'type = DuoWorkflow and group = "gitlab-org" and created > -30d'
context = { mode: "analytics", dimensions: "status", metrics: "totalCount, usersCount", sort: "totalCount desc" }

compiled = Glql.compile(query, context)
puts "== Generated GraphQL =="
puts compiled["output"]

out, _ = Open3.capture2("glab", "api", "graphql", "--hostname", "gitlab.com", "-f", "query=#{compiled["output"]}", "-F", "limit=6")
response = JSON.parse(out)

transformed = Glql.transform(response["data"], { mode: "analytics", 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-17 23:59") {
        aggregated(before: $before, after: $after, first: $limit, orderBy: [{direction: DESC, identifier: "totalCount"}]) {
          count
          nodes {
            dimensions {
              status
            }
            totalCount
            usersCount
          }
        }
      }
    }
  }
}
{
  "count": 7,
  "nodes": [
    { "status": "input_required", "totalCount": 39118, "usersCount": 955 },
    { "status": "failed", "totalCount": 38795, "usersCount": 514 },
    { "status": "finished", "totalCount": 37775, "usersCount": 701 },
    { "status": "created", "totalCount": 3242, "usersCount": 28 },
    { "status": "tool_call_approval_required", "totalCount": 821, "usersCount": 202 },
    { "status": "stopped", "totalCount": 807, "usersCount": 277 }
  ]
}

Completed and aborted sessions per flow type (status filter)

mode: analytics
query: type = DuoWorkflow and group = "gitlab-org" and created > -30d and status in ("finished", "stopped")
dimensions: flowType, status
metrics: totalCount
sort: totalCount desc
Test via ruby extension Same snippet as above with the query and context swapped in.
      duoWorkflows(createdAtFrom: "2026-08-17 23:59", status: ["finished", "stopped"]) {
        aggregated(before: $before, after: $after, first: $limit, orderBy: [{direction: DESC, identifier: "totalCount"}]) {
          count
          nodes {
            dimensions {
              flowType: workflowDefinition
              status
            }
            totalCount
          }
        }
      }
{
  "count": 30,
  "nodes": [
    { "flowType": "ai_catalog_agent", "status": "finished", "totalCount": 19270 },
    { "flowType": "code_review/v1", "status": "finished", "totalCount": 13769 },
    { "flowType": "developer/v1", "status": "finished", "totalCount": 2389 },
    { "flowType": "fix_pipeline/v1", "status": "finished", "totalCount": 765 },
    { "flowType": "software_development", "status": "finished", "totalCount": 738 },
    { "flowType": "chat", "status": "stopped", "totalCount": 358 }
  ]
}

Duo MRs by status, last 6 months (Work tab)

mode: analytics
query: type = DuoWorkflow and group = "gitlab-org" and created > -6m
dimensions: created(monthly)
metrics: totalCount, createdMrCountSum, mergedMrCountSum, closedMrCountSum, createdMrCountMax
sort: created asc
Test via ruby extension Same snippet as above with the query and context swapped in.
      duoWorkflows(createdAtFrom: "2026-03-16 23:59") {
        aggregated(before: $before, after: $after, first: $limit, orderBy: [{direction: ASC, identifier: "createdAt", parameters: {granularity: "monthly"}}]) {
          count
          nodes {
            dimensions {
              createdAt(granularity: "monthly")
            }
            totalCount
            createdMrCount {
              createdMrCountSum: sum
            }
            mergedMrCount {
              mergedMrCountSum: sum
            }
            closedMrCount {
              closedMrCountSum: sum
            }
            createdMrCount {
              createdMrCountMax: max
            }
          }
        }
      }
{
  "count": 7,
  "nodes": [
    { "created": "2026-03-01", "totalCount": 40595, "createdMrCountSum": 0.0, "mergedMrCountSum": 0.0, "closedMrCountSum": 0.0, "createdMrCountMax": 0 },
    { "created": "2026-04-01", "totalCount": 68727, "createdMrCountSum": 0.0, "mergedMrCountSum": 0.0, "closedMrCountSum": 0.0, "createdMrCountMax": 0 },
    { "created": "2026-05-01", "totalCount": 105957, "createdMrCountSum": 0.0, "mergedMrCountSum": 0.0, "closedMrCountSum": 0.0, "createdMrCountMax": 0 },
    { "created": "2026-06-01", "totalCount": 128852, "createdMrCountSum": 36.0, "mergedMrCountSum": 36.0, "closedMrCountSum": 0.0, "createdMrCountMax": 34 },
    { "created": "2026-07-01", "totalCount": 129078, "createdMrCountSum": 1345.0, "mergedMrCountSum": 584.0, "closedMrCountSum": 658.0, "createdMrCountMax": 12 },
    { "created": "2026-08-01", "totalCount": 127907, "createdMrCountSum": 1072.0, "mergedMrCountSum": 504.0, "closedMrCountSum": 421.0, "createdMrCountMax": 7 }
  ]
}

sum is a Float on the engine (hence 36.0); GLQL passes it through untouched. On gitlab.com the buckets before June 2026 come back as zero - I haven't checked when the engine's MR id columns started being populated, so treat the early rows as "no data" rather than "no MRs".

New vs. returning users (Adoption tab)

mode: analytics
query: type = DuoWorkflow and group = "gitlab-org" and created > -3m
dimensions: created(monthly)
metrics: usersCount, returningUsersCount, joinedUsersCount, churnedUsersCount, previousPeriodUsersCount
sort: created asc

Without created among the dimensions this fails at compile time:

Error: `returningUsersCount`, `joinedUsersCount`, `churnedUsersCount`, `previousPeriodUsersCount` cannot be selected as metrics for Duo workflows analytics queries unless `created` is also selected as a dimension.
Test via ruby extension Same snippet as above with the query and context swapped in.
      duoWorkflows(createdAtFrom: "2026-06-16 23:59") {
        aggregated(before: $before, after: $after, first: $limit, orderBy: [{direction: ASC, identifier: "createdAt", parameters: {granularity: "monthly"}}]) {
          count
          nodes {
            dimensions {
              createdAt(granularity: "monthly")
            }
            usersCount
            returningUsersCount
            joinedUsersCount
            churnedUsersCount
            previousPeriodUsersCount
          }
        }
      }
{
  "count": 4,
  "nodes": [
    { "created": "2026-06-01", "usersCount": 1065, "returningUsersCount": 0, "joinedUsersCount": 1065, "churnedUsersCount": 0, "previousPeriodUsersCount": 0 },
    { "created": "2026-07-01", "usersCount": 1227, "returningUsersCount": 851, "joinedUsersCount": 376, "churnedUsersCount": 214, "previousPeriodUsersCount": 1065 },
    { "created": "2026-08-01", "usersCount": 1144, "returningUsersCount": 910, "joinedUsersCount": 234, "churnedUsersCount": 317, "previousPeriodUsersCount": 1227 },
    { "created": "2026-09-01", "usersCount": 985, "returningUsersCount": 807, "joinedUsersCount": 178, "churnedUsersCount": 337, "previousPeriodUsersCount": 1144 }
  ]
}

returningUsersCount + joinedUsersCount = usersCount holds on every row, and the first bucket has no previous period.

How to set up and validate locally

  1. cargo test - tests/duo_workflows_tests.rs covers the status filter/dimension/sort and its typo rejection, the three measurement contracts, the retention metrics with and without created, and the transform row shapes. tests/ai_usage_events_tests.rs still passes with the rule moved into the core (one message changed to list both offending metrics).
  2. DUMP_GRAPHQL=1 cargo test && npm run test:graphql against a fresh scripts/fetch_gitlab_graphql_schema.sh dump validates every compiled query against master's schema. It reports two errors on this branch and on main alike - duplicate $epicId1 / $parentId1 variables in work-item subqueries - neither touching duoWorkflows.
  3. Run any example above through the gem snippet.
Edited by Daniele Rossetti

Merge request reports

Loading
Loading