Add per-Duo-Workflow-session GitLab Credits ingestion and creditsUsed field
What
Adds creditsUsed to Duo Agent Platform session artifacts, so the AI Agent
Artifacts dashboard has a per-session "credits used" column, plus the background
ingestion that populates it.
Credits are computed in CustomersDot from ClickHouse billing events. GitLab
cannot derive them, so this fetches them across the service boundary on a cron
and stores them in the pre-existing duo_workflow_session_enrichments ClickHouse
table. The GraphQL field then reads that table locally rather than putting a
cross-service HTTP call in a dashboard page load.
This is the GitLab half (MR B) of issue 592156 (milestone 19.3, epic 22151). Zero migrations, Postgres and ClickHouse both.
Depends on CustomersDot
The flag must stay off until
customers-gitlab-com!16692
is merged and deployed to CustomersDot production, which is roughly 3 hours
after merge. Until then the sessionCreditsUsed field this calls does not exist
in production. That cross-repo lead time is the reason for the feature flag: this
MR can merge inert.
Changes
Bottom up:
Ai::DuoWorkflows::Workflow::BILLABLE_STATUSESand awith_billable_statusscope. Deliberately not derived fromTERMINAL_STATUSES, which includesfailed(never bills) and omits the three pause states (which do bill).SubscriptionUsageClient#get_session_credits(workflow_ids:), mirroringget_subscription_usage. Slices into pages of 100 ids (the CustomersDot cap) and merges the results.SessionCredits::IngestService— resolves the date window, calls CustomersDot, writes to ClickHouse viainsert_csv.FetchNamespaceSessionCreditsWorker— one namespace batch per job. Transient CustomersDot failures raise aRetryErrorsubclass so Sidekiq retries the batch; a batch that exhausts retries is logged at error level with its ids.FetchSessionCreditsCronWorker— cursor scan every 15 minutes, groups by root namespace, fans out.creditsUsedonDuoWorkflowSessionArtifact, batch-loaded per page.
Feature flag duo_workflow_session_credits_ingestion, default off, gating the
ingestion only. Rollout issue:
https://gitlab.com/gitlab-org/gitlab/-/work_items/607369
The GraphQL field is deliberately unflagged. It is nullable and returns null when no rows exist, which is the same thing it returns on any instance with ClickHouse analytics disabled.
How to test
The GraphQL field on its own
The field reads ClickHouse directly and has no dependency on the ingestion path, so it can be exercised without CustomersDot or the flag. Insert a row for a workflow you can see in the dashboard:
-- against the GDK ClickHouse, gitlab_clickhouse_development
INSERT INTO duo_workflow_session_enrichments (workflow_id, credits_used, updated_at)
VALUES (<WORKFLOW_ID>, 12.5, now64(6));Then query it. Requires read_agent_artifacts on the group, and
use_clickhouse_for_analytics enabled in application settings:
query {
group(fullPath: "<GROUP_PATH>") {
duoWorkflowSessionArtifacts(first: 10) {
nodes {
id
creditsUsed
}
}
}
}| Expect | Why it matters |
|---|---|
creditsUsed: 12.5 on that session |
the read path works |
creditsUsed: null on a session with no row |
absent must read null, never 0 |
insert a second row for the same id with a newer updated_at and a different value, then re-query |
must return the newer value. The table is a ReplacingMergeTree, so unmerged duplicates are possible and the query uses argMax rather than a plain read |
| one ClickHouse query for a whole page of sessions | no N+1. Check the query log with several sessions in the page |
The ingestion path end to end
Needs a reachable CustomersDot that has sessionCreditsUsed, so either a local
CustomersDot with !16692 checked out, or staging.
# rails console
Feature.enable(:duo_workflow_session_credits_ingestion)
# A session only qualifies if its status is billable AND its last transition is
# older than the 2 hour settle horizon.
w = Ai::DuoWorkflows::Workflow.last
w.update!(status: 3, updated_at: 3.hours.ago)
Ai::DuoWorkflows::FetchSessionCreditsCronWorker.new.performThen confirm a row landed in duo_workflow_session_enrichments and that
creditsUsed reflects it.
Worth checking the negative cases too, since they are where the behaviour is deliberate rather than incidental:
- Flag off, or
use_clickhouse_for_analyticsoff: the cron is a complete no-op and the cursor does not move. - A session whose last transition is inside the 2 hour horizon: not picked up yet.
- A
failedsession: never picked up, and reads null forever. Failed sessions never bill. - A project-scoped session (
namespace_idNULL,project_idset): still dispatched, under its root namespace. See the first gap below.
Notes for reviewers
Four things that are load-bearing and not obvious from the diff.
The billable status set is not the terminal status set. AI Gateway emits a
billing event at every billable transition including the three pause states, so a
session's total keeps growing over its life and must be re-fetched on later
transitions. TERMINAL_STATUSES is a different set and reusing it would both
over-fetch (failed) and under-fetch (the pause states).
Sessions are scoped to either a namespace or a project, never both
(CHECK (num_nonnulls(namespace_id, project_id) = 1)), and a non-NULL
namespace_id may be a subgroup while CustomersDot subscriptions key on the
root. The cron therefore selects project_id as well and resolves roots in
bulk through namespaces.traversal_ids, two queries per run rather than
root_ancestor per row. Resolving only namespace_id would have silently dropped
every project-scoped session.
The cursor bound is > cursor_time, not >= cursor_time + 1.second.
ClickHouse::SyncCursor stores a UInt64, so the high-water mark is a whole
second. If MAX_ROWS_PER_RUN truncates mid-second, rows later in that same second
were never dispatched, and skipping forward past them loses them permanently.
Rounding toward re-processing is safe because the target table is a
ReplacingMergeTree keyed on workflow_id, so a re-fetch is an upsert. There is a
regression spec that stubs the row cap and asserts the truncated row is rescanned.
urgency :throttled on the child worker is intentional, not a downgrade.
concurrency_limit -> { 50 } deliberately buffers the fan-out, and buffered time
is added to the measured queueing duration
(Gitlab::InstrumentationHelper.queue_duration_for_job defaults to
with_buffering_duration: true). Under :low that self-inflicted delay would be
scored against a 60 second queueing target and breach it by design. :throttled
has no queueing target and the same 300 second execution target. It also declares
worker_has_external_dependencies!, which raises the error alerting threshold
since a failure here is most likely CustomersDot rather than us.
Both workers route to the default queue and land on the catchall shard. Note
tags :clickhouse is informational and does not affect routing.
Expected load
Per 15-minute cycle:
- One Postgres scan, keyset-bounded, capped at 10k rows, on the
(status, updated_at, id)index, plus two bulk plucks for root namespace resolution. - Job count: under 100 in the best case (sessions cluster into a few root
namespaces), up to 10k in the pathological case (every session a different root
namespace). Fan-out drains at
concurrency_limit50, so at most 50 jobs run at once. - Exactly one CustomersDot GraphQL call per namespace job (batches are capped at 100 ids by construction).
No production baseline yet for how many sessions transition into billable statuses per 15 minutes on GitLab.com; the feature flag lets us roll out on GitLab.com first, watch the CustomersDot-side ClickHouse metrics, and tune from there.
Transient failures retry through Sidekiq's normal ladder (25 retries with exponential backoff); every failed attempt is counted and logged by the ingest service, and a batch that exhausts retries is logged at error level with its workflow ids so it can be replayed.
Test coverage
625 examples, 0 failures across the seven affected spec files, verified locally at
this commit. bin/rubocop clean. Generated artifacts regenerated rather than
hand-edited: all_queues.yml, config/sidekiq_queues.yml,
doc/api/graphql/reference/_index.md, public/-/graphql/introspection_result.json.
Round 3 (worker retries + client pagination): 275 examples, 0 failures across the four affected spec files at the current head.
The specs that matter most are the negative ones: absent session reads null rather than zero, unmerged duplicates resolve to the newest row, the cursor does not advance when nothing was dispatched, and a truncated mid-second row is rescanned.
Follow-ups, not in this MR
- Reduce the child job count. The fan-out is one job per namespace, so 10,000
billable sessions across 10,000 distinct root namespaces is 10,000 jobs. Packing
several namespaces per job would cut that by orders of magnitude with no change
to CustomersDot pressure, since
concurrency_limitbounds concurrent calls either way. Worth measuring the real distinct-namespace rate first. - Pass the date window down from the cron.
IngestServicere-derivesmin(created_at)per job, which the cron could compute once from rows it has already scanned. - A cross-subscription batch endpoint on CustomersDot is the only thing that reduces actual HTTP volume, rather than just job count.
- Sessions older than 90 days have their window clamped and may miss their earliest events. Accepted.
- Self-managed instances without ClickHouse analytics have no credits source and read null.
Database review
All writes in this MR go to ClickHouse. The only Postgres touches are reads. Both workers declare defer_on_database_health_signal :gitlab_main_org, [:duo_workflows_workflows].
1. Cron scan, steady state
FetchSessionCreditsCronWorker#scan (with_billable_status scope, updated_at window, ORDER BY updated_at, id, LIMIT 10000). Rides idx_workflows_status_updated_at_id (status, updated_at, id): 12ms, 27 buffers on a 15-minute window.
Plan: https://console.postgres.ai/gitlab/projects/gitlab-production-main/sessions/54490/commands/157267
2. Same scan, cold start / worst case
90-day window, the floor at MAX_WINDOW_DAYS so this is the widest window the scan can ever hit. Parallel index scan over ~611k rows per worker (~1.8M total) with a top-N heapsort, ~35s. The 5-value status IN-list rules out a single ordered index walk, which is why this isn't as cheap as query 1.
Bounded by: LIMIT 10000 per tick, cursor advances each tick so the window shrinks as backlog drains, cron runs at urgency :low with retries disabled, and this shape only occurs once per cold start.
Plan: https://console.postgres.ai/gitlab/projects/gitlab-production-main/sessions/54490/commands/157268
3. root_namespace_ids_for pluck 1
SELECT id, namespace_id FROM projects WHERE id IN (up to 10k literal ids), a canonical PK IN-list. The linked plan's cost is dominated by a CTE that samples ids for testing, the app never runs it. App-relevant node is the inner per-id index scan, ~4ms per row cold-cache.
Plan: https://console.postgres.ai/gitlab/projects/gitlab-production-main/sessions/54490/commands/157270
4. root_namespace_ids_for pluck 2
SELECT id, traversal_ids[1] FROM namespaces WHERE id IN (...). traversal_ids[1] is computed per row after a namespaces_pkey lookup, never used as a predicate, so no index on traversal_ids is involved. Same CTE-sampling caveat as query 3; app-relevant node is the pkey index scan, ~3.4ms per row cold-cache.
Plan: https://console.postgres.ai/gitlab/projects/gitlab-production-main/sessions/54490/commands/157271
5. IngestService#start_date
SELECT MIN(created_at) FROM duo_workflows_workflows WHERE id IN (batch of at most 100 ids). Same CTE-sampling caveat; app-relevant node is 100 pkey lookups plus the aggregate.
Plan: https://console.postgres.ai/gitlab/projects/gitlab-production-main/sessions/54490/commands/157272
Note on introspection
The experiment: { milestone: '19.3' } marker on creditsUsed intentionally shows up as isDeprecated: true with a Status: Experiment reason in the introspection dump. That is GitLab's standard encoding for experiment fields, not an accident.