🌐 US-Proxy
class="logged-out env-production page-responsive" style="word-wrap: break-word;" >
Skip to content

feat: add Prometheus metrics for AI Governance cost control - #27490

Merged
ssncferreira merged 4 commits into
mainfrom
ssncf/aigov-cost-control-metrics
Jul 28, 2026
Merged

feat: add Prometheus metrics for AI Governance cost control#27490
ssncferreira merged 4 commits into
mainfrom
ssncf/aigov-cost-control-metrics

Conversation

@ssncferreira

@ssncferreira ssncferreira commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Description

Adds Prometheus metrics for AI budget cost control, emitted by the aibridged server under the cost_control subsystem (full names are prefixed coder_ai_gateway_).

  • blocked_requests_total (counter) — labels: group_id
  • blocked_users (gauge) — labels: group_id
  • unpriced_requests_total (counter) — labels: provider, model
  • enforcement_duration_seconds (histogram) — labels: outcome

Changes

  • Add GetOverBudgetUsersPerGroup query (plus dbauthz/dbmetrics/dbmock wiring) to count over-budget users per effective group.
  • Add a background collector that refreshes the blocked_users gauge on an interval, started only when Prometheus is enabled.
  • Wire Metrics through the aibridged server, coderd API, cli/server.go, and the enterprise AI gateway handler; recording is nil-safe when metrics are unset.

Closes https://linear.app/codercom/issue/AIGOV-296/add-prometheus-metrics-for-cost-control

Note

Initially generated by Claude Opus 4.7, modified and reviewed by @ssncferreira

@github-actions

github-actions Bot commented Jul 24, 2026

Copy link
Copy Markdown

Docs preview

Check off each page once it's been reviewed. If a page changes in a later push, its checkbox clears automatically so it gets a fresh look. Pages not yet wired into the docs navigation aren't listed here.

Copy link
Copy Markdown
Contributor Author

This stack of pull requests is managed by Graphite. Learn more about stacking.

@ssncferreira
ssncferreira force-pushed the ssncf/aigov-cost-control-metrics branch 5 times, most recently from f9a901e to 1c7af26 Compare July 27, 2026 10:15
@ssncferreira

Copy link
Copy Markdown
Contributor Author

/coder-agents-review

@coder-agents-review

coder-agents-review Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Chat: Review posted | View chat
Requested: 2026-07-27 15:32 UTC by @ssncferreira
Spend: $34.12 / $100.00

Review history
  • R1 (2026-07-27), 1 P2, 1 P3, COMMENT. Review
  • R2 (2026-07-27): 17 reviewers, 4 Nit, 3 Note, 2 P2, 6 P3, COMMENT. Review

deep-review v0.9.0 | Round 2 | 60c20be..350c639

Last posted: Round 2, 15 findings (2 P2, 6 P3, 4 Nit, 3 Note), COMMENT. Review

Finding inventory

Findings

# Sev Status Location Summary Round Reviewer Posted
CRF-1 P2 Author fixed (350c639) scripts/metricsdocgen/scanner/scanner.go:44 New cost_control metrics skipped by scanner but never added to static metrics file, so absent from generated Prometheus docs R1 Netero Yes
CRF-2 P3 Deferred (AIGOV-527) coderd/database/queries/aicostcontrol.sql:260 GetOverBudgetUsersPerGroup re-derives effective-group resolution inline, a 4th copy hardcoding the highest policy with no test pinning it to ResolveUserAIBudget R1 Netero Yes
CRF-3 P3 Open coderd/aibridgedserver/metrics.go:96 blocked_users collector waits for the first tick before its initial update, so the gauge is blank for up to one interval (5m default) after every restart R2 Netero Yes
CRF-4 P3 Open coderd/aibridgedserver/aibridgedserver_test.go:618 blocked_requests_total test asserts only series count, never the group_id label the PR adds GroupID plumbing to populate R2 Bisky, Chopper, Kite Yes
CRF-5 P3 Open coderd/aibridgedserver/metrics.go:129 blocked_users Reset()+Set() loop is not atomic vs a concurrent scrape; a scrape in the window reads zero/partial series (spurious drop-to-zero on a governance gauge) R2 Takumi, Komugi, Ryosuke Yes
CRF-6 P2 Open coderd/aibridgedserver/metrics.go:56 unpriced_requests_total name/help say "requests" but it counts token-usage records; RecordTokenUsage fires per MessageStart/MessageDelta, so streaming requests inflate the count by a variable multiplier (metric name is immutable API) R2 Leorio Yes
CRF-7 Note Open coderd/aibridgedserver/metrics.go:53 unpriced_requests_total model label is the client-influenced unpriced tail, never evicted, accrues even when Prometheus disabled; "3 providers, 5 models = up to 15" comment understates it. Inherited aibridge convention (subset of InterceptionCount), so not a new class; consider dropping the model label on this counter R2 Killua P1 (downgraded), Mafu-san P2, Chopper P3, Kurapika/Hisoka/Pariston/Mafuuu Note Yes
CRF-8 P3 Open coderd/aibridgedserver/aibridgedserver.go:781 IsBudgetExceeded threads the outcome label manually across four returns while named return retErr is unused; a future early return that forgets outcome silently records outcome=allowed R2 Meruem Yes
CRF-9 Nit Open coderd/aibridgedserver/metrics.go:68 "// Native histogram config." restates the NativeHistogram* field names; siblings document each field's rationale R2 Gon Yes
CRF-10 Nit Open coderd/aibridgedserver/metrics.go:82 RecordBlockedUsers breaks the package Record* (synchronous one-shot) convention; it starts a background collector and returns a stop closer R2 Gon Yes
CRF-11 P3 Open coderd/database/querier_test.go:13911 TestGetOverBudgetUsersPerGroup seeds all spend on periodStart, so the day >= period_start window predicate is never exercised (deleting it passes all 8 cases) R2 Bisky Yes
CRF-12 Nit Open coderd/aibridgedserver/metrics.go:86 RecordBlockedUsers doc never states interval<=0 means the 5m default, while cli passes 0 R2 Leorio Yes
CRF-13 Nit Open coderd/aibridgedserver/metrics_internal_test.go:93 New internal test uses context.Background() where the package uses t.Context() (auto-cancels the collector goroutine); two instances R2 Ging-go Yes
CRF-14 Note Open coderd/aibridgedserver/metrics.go:50 blocked_users is a deployment-wide gauge emitted per replica; a naive sum() across instances multiplies the real count by the replica count. Worth a help/docs hint R2 Pariston Yes
CRF-15 Note Open coderd/database/queries/aicostcontrol.sql:258 Refresh scans group_members_expanded twice per cycle, deployment-wide, every 5m; grows with total org+group memberships R2 Knuckle Yes

Contested and acknowledged

(none yet)

Round log

Round 2

Churn guard PROCEED: CRF-1 author fixed (350c639 added the four cost_control series to scripts/metricsdocgen/metrics and docs/admin/integrations/prometheus.md), CRF-2 deferred to AIGOV-527 (no code change, thread resolved). Author also filed AIGOV-561 for the broader ai-gateway metricsdocgen gap. Netero round-2 found new P3 CRF-3 (collector cold-start blind window). P3-only, mechanical floor clean, panel runs (first panel round). Reviewed against 60c20be..350c639.

Panel: 17 reviewers (5 always-on, Gon+Leorio once-per-PR, 8 specialists, Meruem+Kite wildcards). New findings CRF-4..CRF-15. Cross-check resolutions verified against code:

  • Killua P1 (unpriced model-label OOM DoS) downgraded to Note (CRF-7): increment requires a provider usage record via RecordTokenUsage (garbage models rejected upstream, not mintable at will); the model label is inherited aibridge convention (baseLabels on 8 sibling metrics; InterceptionCount labels even failed interceptions by model), so this counter is a strict subset and introduces no new cardinality class. Confirmed counters accrue even when Prometheus.Enable is false (only the collector is gated), so the memory point is real but bounded.
  • Provider-label help text dispute resolved: intc.Provider = p.Type() in {anthropic, openai, copilot} (aibridge bridge.go:228, config constants). Leorio correct; Mafu-san/Mafuuu "operator-chosen provider" premise wrong. Provider-help Notes dropped as disproved.
  • Leorio CRF-6 confirmed: aibridge streaming.go:210/237 calls RecordTokenUsage per MessageStart and MessageDelta; kept at P2 (metric name is immutable API, streaming inflates the count).
  • Reset/Set non-atomic (CRF-5): Takumi/Komugi/Ryosuke P3 over Hisoka/Knuckle/Kite Note; higher wins. Ryosuke/Meruem structural fix (custom Collector) would also resolve CRF-3.

Round 1

Netero-only first pass. 1 P2, 1 P3. Reviewed against 6f2011a..1c7af26. Netero decision gate: P2 present, panel deferred until mechanical findings addressed. Orchestrator verified both findings against head: metrics.go is in scanner skipPaths and the four cost_control series appear in neither scripts/metricsdocgen/metrics (102 existing coder_ai_gateway lines, 0 cost_control) nor docs/admin/integrations/prometheus.md; aicostcontrol.sql:252-300 confirmed the inline user_highest_group+effective CTE duplication under TODO(AIGOV-527).

About deep-review

CRF = Coder Review Finding (P0-P4, Nit, Note)

Reviewer Focus
Bisky tests
Chopper ops/errors
Churn-guard change verification
Ging language modernization
Gon naming
Hisoka edge cases
Killua perf
Kite change integrity
Knov contracts
Knuckle SQL
Komugi flake/determinism
Kurapika security
Law decomposition
Leorio docs
Luffy product
Mafu-san process
Mafuuu contracts
Melody dispatch/pairing
Meruem structural
Nami frontend
Netero mechanical checks
Pariston premise testing
Pen-botter product gaps
Razor verification
Robin duplication
Ryosuke Go arch
Takumi concurrency
Zoro shape

🤖 Managed by Coder Agents.

@coder-agents-review coder-agents-review Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First-pass review only. These are mechanical findings from Netero (conventions, coverage, dead code, duplication, build). The full review panel has not yet reviewed this PR; it will review after these findings are addressed. Netero traced callers of every new symbol and ran the new unit tests.

What the change does well: metric recording is nil-safe at every call site, cardinality is reasoned about per collector, and test density is high (60.7%) with real assertions against a live Prometheus registry and real SQL against a real DB rather than tautological checks. The dbauthz AsSystemRestricted use is justified and scoped, and query naming follows the PerX aggregation convention.

Severity count: 1 P2, 1 P3.

The P2 is a documentation gap that the code's own skip-list comment predicts: a governance/cost-control feature should not ship metrics operators cannot discover in the generated Prometheus docs. The P3 is a drift trap already tracked by TODO(AIGOV-527); it is not blocking but no test pins the query to the Go resolver, so a second budget policy would silently diverge the gauge from enforcement.

In Netero's words: "Their metrics must be maintained in the static metrics file instead" is the skip-list's own instruction, and this PR added the skip without the maintenance.

🤖 This review was automatically generated with Coder Agents.

// eliminate the need for this skip list.
var skipPaths = []string{
"coderd/aibridged/metrics.go",
"coderd/aibridgedserver/metrics.go",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 [CRF-1] Adding coderd/aibridgedserver/metrics.go to skipPaths excludes its four new metrics from scanning, but they were never added to the static metrics file, so they will be absent from the generated Prometheus docs. (Netero)

The skipPaths comment states: "Their metrics must be maintained in the static metrics file instead." The static file scripts/metricsdocgen/metrics holds every other coder_ai_gateway_ series (102 existing lines; 0 cost_control), and docs/admin/integrations/prometheus.md also has 0. The PR touches no file under docs/ or scripts/metricsdocgen/metrics. The four new series (coder_ai_gateway_cost_control_blocked_requests_total, _blocked_users, _unpriced_requests_total, _enforcement_duration_seconds) end up undocumented for a feature whose whole point is operator-facing observability. Fix: add the four series to scripts/metricsdocgen/metrics and regenerate prometheus.md.

🤖

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 350c639

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is actually a problem with the current ai-gateway metrics not being properly documented. Created this follow-up issue: https://linear.app/codercom/issue/AIGOV-561/add-ai-gateway-metrics-to-metricsdocgen

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified fixed in 350c639. All four coder_ai_gateway_cost_control_* series are present in scripts/metricsdocgen/metrics and docs/admin/integrations/prometheus.md, and coderd/aibridgedserver/metrics.go is in the scanner skipPaths, matching the hand-maintenance requirement. Thanks for filing AIGOV-561 for the broader ai-gateway doc gap.

🤖

Comment thread coderd/database/queries/aicostcontrol.sql
@linear-code

linear-code Bot commented Jul 27, 2026

Copy link
Copy Markdown

AIGOV-296

@ssncferreira
ssncferreira force-pushed the ssncf/aigov-cost-control-metrics branch from 1c7af26 to 350c639 Compare July 27, 2026 15:21
@ssncferreira

Copy link
Copy Markdown
Contributor Author

/coder-agents-review

@coder-agents-review coder-agents-review Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First panel review (round 2). Round 1's mechanical findings are resolved: CRF-1 (metrics missing from generated docs) is verified fixed in 350c639 (all four cost_control series now in scripts/metricsdocgen/metrics and docs/admin/integrations/prometheus.md, with the follow-up AIGOV-561 filed for the broader ai-gateway doc gap), and CRF-2 (effective-group resolution duplication) is deferred to AIGOV-527. Nice.

What this does well: the gauge query is a faithful twin of the enforcement path (same >= threshold, override-wins precedence, deleted-org filter, and period-window normalization), so blocked_users and blocked_requests_total count the same population enforcement blocks; recording is nil-safe at every site; and the tests assert real collected values against a live registry, including Reset clearing stale series across rounds. Knuckle put it well: "This mirroring is the hard, boring kind of correct."

Severity count: 1 P2, 5 P3, 4 Nit, 3 Note. No P0/P1 after verification, so this is a COMMENT, not a request for changes.

One transparency note on the panel: Killua raised a P1 that the client-controlled model label on unpriced_requests_total could OOM the control plane. I investigated and downgraded it to a Note (CRF-7). The increment only fires after RecordTokenUsage, which the aibridge library calls only once it has parsed a usage block from an accepted provider response, so arbitrary/garbage model strings are rejected upstream and never mint series; and the model label is the established aibridge convention (baseLabels on 8 sibling metrics, InterceptionCount already labels even failed interceptions by model), so this counter is a strict subset that adds no new cardinality class. The real, in-scope points survive as CRF-7: the counter accrues series even when Prometheus is disabled (only the collector is gated), the "up to 15" comment understates the model domain, and provider alone would answer this counter's question.

The P2 (CRF-6) is worth deciding now because a metric name is immutable API: unpriced_requests_total counts token-usage records, not requests, and streaming inflates it by a variable multiplier. The P3s cluster on two real seams: the blocked_requests_total test never checks the group_id label this PR exists to add (CRF-4), and the blocked_users gauge is refreshed with a non-atomic Reset()+Set() loop that a scrape can photograph mid-rebuild (CRF-5). Ryosuke and Meruem both note a single refactor (a pull-based custom Collector) would resolve CRF-5 and the cold-start blind window (CRF-3) together.

For the record, two reviewer premises did not survive verification and were dropped: the "operator-chosen provider" cardinality angle (intc.Provider is p.Type(), bounded to anthropic/openai/copilot, so the help text is accurate).

🤖 This review was automatically generated with Coder Agents.

Comment thread coderd/aibridgedserver/metrics.go
Comment thread coderd/aibridgedserver/aibridgedserver_test.go

// Reset clears groups that dropped to zero since the last cycle so their
// stale series do not linger.
m.BlockedUsers.Reset()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 [CRF-5] updateBlockedUsers calls BlockedUsers.Reset() then repopulates in a loop, which is not atomic against a concurrent Prometheus scrape. (Takumi P3, Komugi P3, Ryosuke P3; Hisoka/Knuckle/Kite Note)

MetricVec.Reset() and each WithLabelValues().Set() take the vec lock independently, while Collect takes only its own RLock. A scrape landing after Reset() but before the loop finishes snapshots zero series or a partial subset, so blocked_users reads absent/short for groups that are still over budget. The window is microseconds against a 5m refresh and ~15s scrapes, and it self-heals next scrape, which is why this is P3 and not higher, but it produces spurious drop-to-zero samples on a governance gauge and can flap instantaneous alerts. Ryosuke's fix, which Meruem seconds: turn BlockedUsers into a custom prometheus.Collector backed by a snapshot updated under a mutex; Collect emits it in one pass, so no scrape can observe a partial state. That single restructuring also removes the cold-start window (CRF-3) and the manual Reset() bookkeeping.

🤖

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The gap between the Reset() and repopulating the gauge is only microseconds against a 5m refresh, and the next scrape corrects it, so I'd rather keep the simpler code for now. If we find a scrape landing in that window is producing wrong blocked_users values that trip operator alerts, we can switch to a custom Collector that builds the values under a lock and emits them in one pass.

Comment thread coderd/aibridgedserver/metrics.go Outdated
Name: "blocked_users",
Help: "The number of users currently over their AI budget.",
}, []string{"group_id"}),
// Pessimistic cardinality: 3 providers, 5 models = up to 15.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note [CRF-7] The model label on unpriced_requests_total ranges over the client-influenced unpriced tail, and the "Pessimistic cardinality: 3 providers, 5 models = up to 15" comment understates it. (Killua P1 downgraded, Mafu-san P2, Chopper P3; Kurapika/Hisoka/Pariston/Mafuuu Note)

The label fires precisely on the sql.ErrNoRows branch, i.e. models not in the price table: dated snapshots, preview models, and fine-tune IDs. Each distinct value mints a permanent series (counter, no Reset, no eviction), and the counter is created whenever BridgeConfig.Enabled, so series accrue even when Prometheus.Enable is false (only the collector is gated). Why this is a Note and not the P1 Killua first rated it: the increment requires a RecordTokenUsage call, which the aibridge library issues only after parsing a usage block from an accepted provider response, so garbage model strings are rejected upstream and cannot be minted at will; and the model label is the established aibridge convention (baseLabels on 8 sibling metrics, and InterceptionCount already labels even failed interceptions by model), so this counter is a strict subset that adds no new worst-case cardinality. Two actionable options if you want a hard bound here specifically: drop the model label (provider alone answers "are we failing to price a provider's traffic") or bucket unknowns into a single value. At minimum, fix the comment so it does not tell the next maintainer the label is bounded to 5.

🤖

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

model ranges over unpriced models (unknown to the price table), so cardinality is unbounded in principle but gated by accepted provider responses. This is consistent with the model label on other aigateway metrics.

Comment thread coderd/aibridgedserver/metrics.go Outdated
Comment thread coderd/aibridgedserver/metrics.go
Comment thread coderd/aibridgedserver/metrics_internal_test.go Outdated
Comment thread coderd/aibridgedserver/metrics.go
Comment thread coderd/database/queries/aicostcontrol.sql
@ssncferreira
ssncferreira force-pushed the ssncf/aigov-cost-control-metrics branch from 350c639 to e93e806 Compare July 27, 2026 21:32
Help: "The number of AI requests blocked because the initiator's budget was exceeded.",
}, []string{"group_id"}),
// Pessimistic cardinality: one series per group with an over-budget user.
BlockedUsers: promauto.With(reg).NewGaugeVec(prometheus.GaugeOpts{

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This metric is included in the RFC (link), but populating it requires a potentially heavy SQL query since this is deployment-wide. Its cost scales with the number of users who have a budget (so with the member size of budgeted groups), and with longer budget periods since we scan more daily-spend rows. Since it's a gauge, we have to refresh it on a schedule (currently every 5 minutes in a goroutine).
So I think this comes down to: is this metric useful enough to operators to justify that recurring cost?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If it's just called every five minutes then I think it's probably fine.

@ssncferreira
ssncferreira marked this pull request as ready for review July 27, 2026 21:39
@ssncferreira
ssncferreira requested review from a team, ibetitsmike and johnstcn as code owners July 27, 2026 21:39
@ssncferreira
ssncferreira force-pushed the ssncf/aigov-cost-control-metrics branch from e93e806 to 35e2dd2 Compare July 28, 2026 07:39
@ssncferreira
ssncferreira merged commit c351280 into main Jul 28, 2026
33 checks passed
@ssncferreira
ssncferreira deleted the ssncf/aigov-cost-control-metrics branch July 28, 2026 08:23
@github-actions github-actions Bot locked and limited conversation to collaborators Jul 28, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants