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:
statusfilter, 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'sstatus: [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.createdMrCount,mergedMrCount,closedMrCountmeasurements (#206 (closed)) - declared through the sharedMeasurementhelper next tocreditsUsed, so each stat is a flat metric and sort field (createdMrCountSum,mergedMrCountMean,closedMrCountQuantile, ...). Fifteen newFieldvariants, no per-source render or sort mappings.required_dimensionhook (refactor, #214 (closed) follow-ups from !485 (merged)) - the metric-requires-dimension rule moves out of AiUsageEvents'validate_queryinto aSourceAnalyzer::required_dimension(&Field) -> Option<Field>hook, checked once inContextAnalyzerbefore sort/scope validation (same precedence as before). Every metric missing the same dimension is reported in oneMetricRequiresDimensionerror 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 gettimestampinjected. The error kind shipped in !485 (merged) after v0.37.0 was cut, so its shape (fields: Vec<Field>) can still change freely.returningUsersCount,joinedUsersCount,churnedUsersCount,previousPeriodUsersCount(#214 (closed)) - the period-over-period user metrics, requiring thecreateddimension via the hook above. Acreatedfilter doesn't satisfy it;created,created(monthly)andcreated as "Month"do.flowTypesCountmetric (no issue) - the engine's distinct count of flow types per bucket, same shape asprojectsCount. 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 compileSessions by status (Work tab)
mode: analytics
query: type = DuoWorkflow and group = "gitlab-org" and created > -30d
dimensions: status
metrics: totalCount, usersCount
sort: totalCount descTest 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"])
RUBYquery 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 descTest 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 ascTest 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 ascWithout 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
cargo test-tests/duo_workflows_tests.rscovers the status filter/dimension/sort and its typo rejection, the three measurement contracts, the retention metrics with and withoutcreated, and the transform row shapes.tests/ai_usage_events_tests.rsstill passes with the rule moved into the core (one message changed to list both offending metrics).DUMP_GRAPHQL=1 cargo test && npm run test:graphqlagainst a freshscripts/fetch_gitlab_graphql_schema.shdump validates every compiled query against master's schema. It reports two errors on this branch and onmainalike - duplicate$epicId1/$parentId1variables in work-item subqueries - neither touchingduoWorkflows.- Run any example above through the gem snippet.