feat(analytics): emit pageInfo on aggregated connections

What does this MR do and why?

Expose pagination info in analytics sources.

This also fixes a live bug in analytics list/table displays: the Load more button already rendered (its hasNextPage check in resolver.vue only needs data.count, which aggregated already returned), but loadMore reads its cursor from this.data.pageInfo?.endCursor. With no pageInfo selected, it sent no cursor and appended page 1 again as duplicate rows.

Example Usage

Build the Ruby extension once:

cd glql_rb
bundle install
bundle exec rake compile

Duo Workflow sessions per flow type, paged three rows at a time

mode: analytics
query: type = DuoWorkflow and group = "gitlab-org" and created > -30d
dimensions: flowType
metrics: totalCount
sort: totalCount desc
Test via ruby extension

Analytics data needs a token: export GITLAB_TOKEN before running.

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 > -30d'
context = { mode: "analytics", dimensions: "flowType", metrics: "totalCount", sort: "totalCount desc" }

# 1. Compile GLQL -> GraphQL (also returns the typed `fields` the transform needs).
compiled = Glql.compile(query, context)
puts "== Generated GraphQL =="
puts compiled["output"]

# 2. Execute against GitLab. `limit: 3` leaves more pages behind; set `after:`
#    to the previous run's `pageInfo.endCursor` to fetch the next page.
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)

# 3. Transform the response back into flat rows. Pass response["data"] (the
#    GraphQL `data` object, not the whole envelope) and reuse the compiled
#    `fields`, which carry the resolved parameters/aliases.
transformed = Glql.transform(response["data"], { mode: context[:mode], fields: compiled["fields"] })
puts "\n== Transformed rows =="
puts JSON.pretty_generate(transformed["data"])
RUBY

Generated GraphQL (trimmed to the aggregated selection):

...
      duoWorkflows(createdAtFrom: "2026-08-17 23:59") {
        aggregated(before: $before, after: $after, first: $limit, orderBy: [{direction: DESC, identifier: "totalCount"}]) {
          count
          pageInfo {
            startCursor
            endCursor
            hasNextPage
            hasPreviousPage
          }
          nodes {
            dimensions {
              flowType: workflowDefinition
            }
            totalCount
          }
        }
      }
...

Transformed rows, run 1 with variables: { limit: 3 } (real output from gitlab.com):

{
  "count": 31,
  "pageInfo": {
    "startCursor": "MA",
    "endCursor": "Mg",
    "hasNextPage": true,
    "hasPreviousPage": false
  },
  "nodes": [
    { "totalCount": 56497, "flowType": "developer/v1" },
    { "totalCount": 24701, "flowType": "chat" },
    { "totalCount": 19735, "flowType": "ai_catalog_agent" }
  ]
}

Run 2 with variables: { limit: 3, after: "Mg" } - the next page comes back:

{
  "count": 31,
  "pageInfo": {
    "startCursor": "Mw",
    "endCursor": "NQ",
    "hasNextPage": true,
    "hasPreviousPage": true
  },
  "nodes": [
    { "totalCount": 16039, "flowType": "code_review/v1" },
    { "totalCount": 821, "flowType": "fix_pipeline/v1" },
    { "totalCount": 761, "flowType": "software_development" }
  ]
}

How to set up and validate locally

  1. cargo test - all green. The new codegen test alone: cargo test --test code_suggestion_tests test_aggregated_selects_page_info (the transform-side pageInfo passthrough assertion now lives in test_transform_basic_analytics_response).
  2. sh scripts/fetch_gitlab_graphql_schema.sh && npm ci && DUMP_GRAPHQL=1 cargo test -- --test-threads=1 && npm run test:graphql - every aggregated document in the dump validates with pageInfo against the master schema. --test-threads=1 sidesteps a pre-existing, unrelated race in the subquery variable counter under threaded cargo test; CI uses cargo nextest and is not affected.
  3. Run the Example Usage snippet with limit: 3: pageInfo.hasNextPage is true and endCursor is set. Re-run with after: set to that cursor: the next three rows come back with hasPreviousPage: true.
Edited by Duo Developer

Merge request reports

Loading
Loading