Add sessions(updatedAfter:): per-session GitLab Credits changes with cursor pagination
Summary
Add a tenant-scoped GraphQL field sessions(updatedAfter:) on
gitlabCreditsUsage. It returns every Duo Workflow session whose billed GitLab
Credits changed since a given instant, with the session's lifetime credit total,
per-model breakdown and a monotonic updatedAt that the caller persists as its
next updatedAfter. Standard cursor pagination.
This is the CustomersDot API half of the v2 session-credits sync (gitlab-org/gitlab#617271): GitLab keeps a per-subscription cursor and pulls only what changed.
Depends on #19201, which builds usage_billing_sessions: one row per
session with its total, per-model breakdown and an UpdatedAt written by
Billing::Usage::SyncSessionsCronJob every 10 minutes. This issue only reads
that table.
Context
v1, its gaps, and who needs v2
v1.
subscription(namespaceId: | licenseKey:).gitlabCreditsUsage(instanceId:, startDate:, endDate:).sessionCreditsUsed(workflowIds: [ID!]!)
(!16692, cache !16891, kill switch and query timeout !17077). A 15-minute GitLab
cron (gitlab!247620 (merged), safeguards !254266) scans
duo_workflows_workflows for billable-status rows older than a 2-hour settle
horizon, sends up to 100 ids per root namespace, and writes the totals to
ClickHouse duo_workflow_session_enrichments.
Gaps, from the !247620 review:
- Change detection is a proxy: GitLab infers "billed" from its own session
status. A session that bills while paused and then fails is never fetched
(#617273), and
BILLABLE_STATUSEScan drift from AI Gateway. - The 2-hour settle horizon is a guess, not a guarantee.
- The cursor cannot cross a crowded second (#617272).
- Each call is an unindexed scan of
usage_billing_enrichedwith a JSON predicate onMetadata. Enabling it globally saturated CustomersDot (INC-13873; production-engineering#29725, #29726). credit_finders_cachenever hits, because every tick sends a different id batch.
v2. CustomersDot is the source of truth for "what changed": no status
mirroring, no settle horizon, no id batches. Authorization stays as today:
namespaceId (admin token) on GitLab.com, licenseKey + instanceId on
self-managed. Choosing which namespaces to poll, per-tenant cursors, concurrency
and back-off stay on the GitLab side. This replaces the server-side change log
and admin-only "changed subscriptions" listing sketched in #617271.
Consumers.
- DAP Impact dashboard (&23225): private beta 2026-10-06 on v1; v2 makes a wider rollout safe.
- AI Agent Artifacts
creditsUsedcolumn (#629047), blocked on the v1 rollout (#607369). - Closes production-engineering#29725 structurally.
Functional requirements
-
Request.
query { subscription(namespaceId: $namespaceId, licenseKey: $licenseKey) { gitlabCreditsUsage(instanceId: $instanceId, flowTypes: $flowTypes) { sessionsSyncedAt sessions(updatedAfter: $updatedAfter, first: $first, after: $after) { nodes { workflowId creditsUsed modelsUsed updatedAt creditsByModel { model creditsUsed } } pageInfo { hasNextPage endCursor } } } } } -
updatedAt.usage_billing_sessions.UpdatedAt: the time the #19201 job last wrote a changed result for the session. It moves forward only whencreditsUsedorcreditsByModelchanges, so an unchanged session is not returned again. Any change counts, including a drop: a total that falls to zero (incident recovery only) is returned once with0, so the client's upsert corrects it. Serialized with microsecond precision through a new scalar,ISO8601DateTimeMicro(a subclass ofISO8601DateTimewithtime_precision = 6). In graphql-ruby the precision is one class-level setting shared by everyISO8601DateTimefield, so it stays at 0 and existing fields keep their format. GitLab declares$updatedAfter: ISO8601DateTimeMicro!. -
updatedAfter. Returns sessions withupdatedAt >= updatedAfter. Inclusive: all rows of one job run share oneUpdatedAt, and a client resuming from it re-reads that run instead of skipping rows that became visible later. Its upsert makes the repeat harmless. AnupdatedAfterin the future is a GraphQL error. -
No lost changes. A session whose
updatedAtbecomes T is returned by every call withupdatedAfter <= Tmade after T + δ, until its next change.- δ = 1 minute. The server never returns a session with
updatedAt > now() - δ. The #19201 job is a single sequential writer (runs never overlap), so visibility followsUpdatedAtorder; δ is only a guard against part-visibility lag between replicas. - Freshness. Credits reach the table 2–3 hours after usage (hourly
consumption plus the 10-minute job). That lag is the consumption
schedule, not a settle horizon: nothing is skipped because it came late.
While the job is disabled, or skips runs because enrichment or
consumption is behind,
updatedAtstops advancing; the next run catches up from its stored watermark.sessionsSyncedAt(FR 11) exposes that watermark.
- δ = 1 minute. The server never returns a session with
-
creditsUsed.usage_billing_sessions.CreditsUsed: billed, consumed, non-SkipConsumptioncredits, each event counted once, at its latest enriched version when its consumed marker was processed. Lifetime total: no month window and no lookback, every event of the session counts (#19201).- Differs from v1 on purpose.
sessionCreditsUsedplain-sums enriched rows over the current month and can double-count re-enriched events.
- Differs from v1 on purpose.
-
Event model. As computed in #19201: each event's credits are attributed whole to
Metadata.llm_operations[0].model_id. The event's other models are listed with0, and an event withoutllm_operationsis counted under model"". -
creditsByModelandmodelsUsed.creditsByModelis theCreditsByModelmap as entries; they sum exactly tocreditsUsed, and every model the session used is listed, with0when it received no credits.modelsUsedlists the same models, ordered bycreditsUseddescending, then by model.- No effect on nodes.
creditsByModelis optional and doesn't change what a node is. A changed session returns its full breakdown, and clients replace it rather than merge.
-
Ordering and paging. Deterministic order by (
updatedAt,workflowId, unique instance id). Default and maximum page size 500. Forward-only:first/afterare supported, whilelast/beforeare a GraphQL error. Client loop:- page with
after = endCursorwhilehasNextPage; - after each page, persist the highest
updatedAtas the nextupdatedAfter.
- page with
-
Lookback.
updatedAfterolder than 99 days is a GraphQL error. Sessions last changed more than 99 days ago are unreachable through this field. -
v1 unchanged.
sessionCreditsUsed(workflowIds:)keeps working until GitLab migrates; deprecation is a follow-up. -
sessionsSyncedAt. A nullableISO8601DateTimeMicrofield ongitlabCreditsUsage, next tosessions. It returns the #19201 job's watermark (usage_billing_sync_states.watermark_atforsessions): every consumed marker inserted before it is reflected insessions.- Microsecond precision, as
updatedAt. Not covered by thesessionskill switch: it reads no ClickHouse data. usage_billing_sync_statesis a Postgres table. The field is null until itssessionsrow exists.
- Microsecond precision, as
-
flowTypes. The existinggitlabCreditsUsage(flowTypes:)argument applies tosessionsasFlowType IN (...), using theFlowTypecolumn ofusage_billing_sessions. Without the argument, every flow type is returned. A client that uses a filter keeps a separate cursor per filter.
Read path
SELECT
WorkflowId, UniqueInstanceId, RootNamespaceId,
argMax(CreditsUsed, UpdatedAt) AS CreditsUsed,
argMax(CreditsByModel, UpdatedAt) AS CreditsByModel,
max(UpdatedAt) AS UpdatedAt
FROM usage_billing_sessions
WHERE ZuoraSubscriptionName = {subscription}
[AND UniqueInstanceId IN ({resolve_unique_instance_ids(instance_id)})] -- licenseKey + instanceId
[AND RootNamespaceId = {namespaceId}] -- namespaceId
[AND FlowType IN ({flowTypes})] -- when `flowTypes` is given
AND UpdatedAt >= {updatedAfter}
GROUP BY WorkflowId, UniqueInstanceId, RootNamespaceId
HAVING UpdatedAt <= now64(6) - INTERVAL 1 MINUTE
AND (UpdatedAt, WorkflowId, UniqueInstanceId) > ({cursor}) -- when `after` is given
ORDER BY UpdatedAt, WorkflowId, UniqueInstanceId
LIMIT {first + 1}
SETTINGS max_execution_time = …, timeout_overflow_mode = 'throw'- Cost.
ZuoraSubscriptionNameis the sort-key prefix, so a call reads only the subscription's session rows (one or a few versions per session, narrow columns), never its events. argMax, notFINAL, as elsewhere in usage billing (WalletMismatchCheckerService).UpdatedAt >= updatedAfterbelongs inWHERE. A session's newest version always has its highestUpdatedAt, so it passes whenever any older version does, andargMaxstill picks it.- δ and the cursor belong in
HAVING. InWHERE, δ would drop a version written in the last minute andargMaxwould return the previous one: a stale total. InHAVING, the session waits out δ and then returns its latest version. The cursor compares against the aggregatedUpdatedAt. - Scaling option. If a subscription's session count makes the range read
slow, add
PROJECTION ORDER BY (ZuoraSubscriptionName, UpdatedAt)in a follow-up. Not needed for the first version. - Sort key. #19201 changed the table to
ORDER BY (ZuoraSubscriptionName, UniqueInstanceId, RootNamespaceId, WorkflowId)and addedFlowTypeas a plain column, so the read groups byRootNamespaceIdtoo. - Scoping.
namespaceIdresolves to the subscription as in v1 and addsRootNamespaceId = {namespaceId}, as v1 does.licenseKey+instanceIdadds theUniqueInstanceIdfilter viaresolve_unique_instance_ids, as in v1.- A non-numeric namespace id is an error. v1's
apply_root_namespace_id_filterlogs it and drops the filter. Here that would return every session of the subscription, across namespaces, sosessionsraises instead.
- A non-numeric namespace id is an error. v1's
FlowTypeinWHEREis safe. A session has one flow type, so filtering versions beforeargMaxcan't change which version wins.- Cursor. Opaque, encodes (
UpdatedAtwith microseconds,WorkflowId,UniqueInstanceId). - Connection. The existing
Gitlab::Graphql::Pagination::ClickHouseConnectioncan't be reused: it adds the cursor inWHEREand needs Arel orders, but this read needs it inHAVING, over the aggregatedUpdatedAt.sessionsgets a small forward-only connection of its own. - Complexity. graphql-ruby scales a connection's complexity by its page
size, so 500 nodes would exceed the schema limit of 250. The field sets a
fixed
complexity. The introspection limit (INTROSPECTION_MAX_COMPLEXITY) may also need a bump for the new types.
Non-functional requirements
- Per-call cost is independent of the subscription's event volume. It is
proportional to the subscription's session count within the
ZuoraSubscriptionNamerange. - Query timeout. Same mechanism as v1:
max_execution_time = SESSION_CREDITS_QUERY_TIMEOUT_SECONDSwithtimeout_overflow_mode = 'throw'. - Kill switch.
usage_billing_disable_sessions_apidisablessessionsonly. v1'ssessionCreditsUsedkeeps its own switch,usage_billing_disable_session_credits_api, so either API can be turned off without the other. Both follow theusage_billing_disable_<component>convention: off by default, and an absent flag means enabled. The generalusage_billing_disable_apidisables both. Neither stops the #19201 job, which has its own switch (usage_billing_disable_session_sync).sessionsreturns the errorsessions is temporarily disabledwithextensions: { code: "FEATURE_DISABLED", field: "GitlabCreditsUsage.sessions" }. The v2 client matches the code, not the text.- Implemented as a generic field option,
kill_switch_flag: :sessions_api(Extensions::KillSwitchExtension), which v1 now uses too (kill_switch_flag: :session_credits_api); v1's message is unchanged.
- Reads only
usage_billing_sessions. No request readsusage_billing_enrichedorusage_billing_consumed. - Failure mode. A timeout or disabled API returns a GraphQL error. The client keeps its cursor and retries on the next tick, so nothing is lost. CustomersDot adds no rate limit; GitLab bounds concurrency.
Known limitations
- Re-enrichment and re-attribution of already-consumed events aren't reflected until the session gets a new consumed marker (#19201, out of scope). Both happen only through incident recovery.
- One instance, several
UniqueInstanceIds. If an instance identifier resolves to several unique ids and one session spans them, the session comes back as one node per unique id with the sameworkflowId. An upsert byworkflowIdkeeps one of them. Rare: it needs a session running across an instance re-registration.
Acceptance criteria
-
sessionsworks fornamespaceIdand forlicenseKey+instanceId; unauthenticated requests are rejected as today. - Self-managed requests only see sessions of the resolved
UniqueInstanceIds. - Sessions with
updatedAtnewer thannow() - δare never returned, and a session with an unmerged older version returns only its latest one. -
creditsByModelsums exactly tocreditsUsed;modelsUsedlists exactly the models increditsByModel, ordered by credits descending, then by model. - Pagination is deterministic by (
updatedAt,workflowId, unique instance id);endCursorresumes without gaps or repeats; page size defaults to and is capped at 500;last/beforeare rejected. -
updatedAtis serialized with microsecond precision. - Sessions that never had billed credits are absent; a session whose
total drops to zero is returned once with
creditsUsed: 0. -
updatedAfterbeyond 99 days or in the future returns a GraphQL error. - SaaS requests only see sessions of the requested
RootNamespaceId; a non-numeric namespace id is an error. -
flowTypesfilterssessions; without it, every flow type is returned. -
usage_billing_disable_sessions_apiand the query timeout coversessions; v1's kill switch doesn't. -
sessionsSyncedAtreturns thesessionssync watermark, and null before the sync state exists. - Per-call cost measured on staging for the largest subscription by session count; read path reviewed by @suraj_tripathy.
- Request, type and model specs added (rows seeded directly into
usage_billing_sessions); agent docs anddoc/process/session_credits_rollout.mdupdated.
References
- Storage prerequisite: #19201 (
usage_billing_sessionsandSyncSessionsCronJob) - Review of the storage design: #19049 (comment 3923694375)
- v1 review and v2 draft: gitlab!247620 (comment 3701109386)
- Rollout and flag: https://gitlab.com/gitlab-org/gitlab/-/work_items/607369
- Epic: https://gitlab.com/groups/gitlab-org/-/work_items/23528
- Incident: INC-13873, production-engineering#29725, #29726
- CustomersDot v1: !16692, !16891, !17077; runbook
doc/process/session_credits_rollout.md - Frontend consumer: https://gitlab.com/gitlab-org/gitlab/-/work_items/629047