Skip to content

refactor(metrics): move each metric beside the code that increments it - #1205

Merged
peteski22 merged 8 commits into
mainfrom
refactor/metrics-beside-owners
Sep 16, 2026
Merged

peteski22 merged 8 commits into
mainfrom
refactor/metrics-beside-owners

Conversation

@peteski22

@peteski22 peteski22 commented Sep 15, 2026 •

Copy link
Copy Markdown
Contributor

Description

Nothing changes for someone using Otari. The Prometheus scrape at /metrics exposes the same 14 gateway metric families, with the same names, types and labels, before and after.

What changes is where each metric is declared. Until now every counter lived in one shared module, so every feature that added or touched a metric edited that file. Each metric is now declared in the module that increments it: the rate limiter, the budget service, the usage log writer, the authentication dependencies, the hybrid attempt runner and the request pipeline. The shared module keeps the registry, the process collector and the HTTP request instrumentation only.

The first commit pins the set of exposed families in a test, before any move, so every move was checked against the same set. That test is the scrape diff the issue asks for.

How to test it locally

make lint && make typecheck && make test-unit
uv run pytest tests/integration/test_metrics.py

The pinned family test is tests/unit/test_gateway_metrics.py::test_scrape_exposes_the_pinned_families. To eyeball the scrape itself, start the gateway with enable_metrics: true, hit /metrics, and compare the # HELP and # TYPE lines against main; the lines are the same, only their order differs, because a family now registers when its owner module imports.

Measured before and after (90-day churn on main at ea1a2471 versus this branch):

before after
lines in src/gateway/metrics.py 255 130
modules importing from gateway.metrics (src/) 11 7
of which import anything but REGISTRY 11 1 (main.py, the middleware and endpoint)
commits touching metrics.py, last 90 days 5 n/a

Run locally: make lint, make typecheck, make test-unit, and tests/integration/test_metrics.py against a Testcontainers PostgreSQL. The rest of the integration suite was left to CI.

Not run here: the OSS edition smoke gate fails on this machine on main too (model discovery against the mock provider never answers, so readiness times out), and 14 master key and dashboard session unit tests fail on main too. Neither touches metrics; CI should confirm both.

PR Type

  • New Feature
  • Bug Fix
  • Refactor
  • Documentation
  • Infrastructure / CI

Relevant issues

Fixes #1177. Part of #1171.

Checklist

  • I understand the code I am submitting.
  • I have added or updated tests that cover my change (tests/unit, tests/integration).
  • I ran the Definition of Done checks locally (make lint, make typecheck, make test). Lint, typecheck and the unit suite were run; see above for what of make test was not.
  • Documentation was updated where necessary.
  • If the API contract changed, I regenerated the OpenAPI spec (uv run python scripts/generate_openapi.py).

AI Usage

  • No AI was used.
  • AI was used for drafting/refactoring.
  • This is fully AI-generated.

AI Model/Tool used: Claude Code (Claude Fable 5.1)

Any additional AI details you'd like to share: The agent wrote the moves and the pinned test, ran lint, typecheck, the unit suite and the metrics integration tests, and confirmed the unrelated failures reproduce on main.

  • I am an AI Agent filling out this form (check box if true)

Summary

  • Moved Prometheus metric declarations into the modules that use them.
  • Kept the shared registry and request instrumentation in gateway.metrics.
  • Preserved the existing /metrics metric names, types, and labels.
  • Added a pinned metric-family contract test and focused tests for relocated metrics.
  • Re-exported Prometheus metric types from gateway.metrics to keep one import boundary.

This improves ownership and keeps future metric changes closer to the related code.

@peteski22
peteski22 deployed to integration-tests September 15, 2026 19:41 — with GitHub Actions Active
@coderabbitai

coderabbitai Bot commented Sep 15, 2026 •

Copy link
Copy Markdown

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Advanced

Run ID: 941583b5-c235-47e9-8a57-15939bd296af

📥 Commits

Reviewing files that changed from the base of the PR and between 3c00bcb and fe90cb6.

📒 Files selected for processing (15)
  • src/gateway/api/deps.py
  • src/gateway/api/routes/_attempts.py
  • src/gateway/api/routes/_pipeline.py
  • src/gateway/api/routes/_platform.py
  • src/gateway/api/routes/auth_oauth.py
  • src/gateway/api/routes/auth_session.py
  • src/gateway/api/routes/auth_webauthn.py
  • src/gateway/metrics.py
  • src/gateway/rate_limit.py
  • src/gateway/services/budget_service.py
  • src/gateway/services/log_writer.py
  • tests/unit/test_gateway_metrics.py
  • tests/unit/test_pipeline_metrics.py
  • tests/unit/test_rate_limiter_core.py
  • tests/unit/test_run_platform_attempts.py

Included review availability: Your plan provides up to 2 included reviews per hour; 1 remains after this review.


Walkthrough

The change moves feature-specific Prometheus metrics from gateway.metrics into the modules that record them. The shared registry and HTTP instrumentation remain centralized. Tests pin exposed metric families and validate recording behavior.

Changes

Metric ownership migration

Layer / File(s) Summary
Route-owned metrics
src/gateway/api/deps.py, src/gateway/api/routes/_platform.py, src/gateway/api/routes/_pipeline.py, src/gateway/api/routes/_attempts.py, src/gateway/api/routes/auth_*.py
Authentication, pipeline, and abandoned-attempt metrics are defined locally. Route imports use the local helpers.
Service-owned metrics
src/gateway/rate_limit.py, src/gateway/services/budget_service.py, src/gateway/services/log_writer.py
The rate limiter, budget service, and log writer define and update their own counters, gauges, and histograms on REGISTRY.
Central metrics boundary
src/gateway/metrics.py
gateway.metrics retains the shared registry, metric types, HTTP middleware, and metrics endpoint. Feature-specific instruments and recorder helpers are removed.
Metric contract validation
tests/unit/test_gateway_metrics.py, tests/unit/test_pipeline_metrics.py, tests/unit/test_rate_limiter_core.py, tests/unit/test_run_platform_attempts.py
Tests pin exposed gateway_* families and validate pipeline, rate-limit, and abandoned-attempt metric recording.

Priority: ➖ Normal

Estimated code review effort: 3 (Moderate) | ~25 minutes

Change: Refactor

Merge Risk: ⚪ Minimal · up to fe90c

Metric collection remains compatible with the existing scrape contract, with no concrete current-head issue requiring changes before merge.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 48.48% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 33 functions across 15 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title uses the required refactor prefix, clearly describes the metric relocation, uses imperative wording, and is approximately 70 characters.
Description check ✅ Passed The description includes all required sections, explains the user impact and implementation, lists local test commands and known limitations, identifies the refactor type and issues, and documents che…
Linked Issues check ✅ Passed Issue #1177 requires owner-local Prometheus metrics on the existing shared REGISTRY and a compatible /metrics contract. The PR moves rate-limit, budget, usage-log, authentication, abandoned-attemp…
Out of Scope Changes check ✅ Passed The changes stay within issue #1177. The gateway.metrics type re-exports preserve one import boundary for prometheus_client and support the refactor. The updated and added tests verify moved metri…
  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch refactor/metrics-beside-owners
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch refactor/metrics-beside-owners

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@peteski22
peteski22 requested review from a team, daavoo, khaledosman and tbille and removed request for a team September 15, 2026 19:41
@peteski22
peteski22 force-pushed the refactor/metrics-beside-owners branch from f5cfb86 to 71d6a9a Compare September 16, 2026 11:10
@peteski22
peteski22 deployed to integration-tests September 16, 2026 11:10 — with GitHub Actions Active
@daavoo

daavoo commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Not a blocker, and not an objection to the move: a packaging ask that this PR can satisfy for almost nothing, and that gets materially more expensive once it lands.

Putting each declaration beside its owner is right, and the 14 families come through unchanged. The side effect is that prometheus_client becomes a top-level import in six modules (api/deps.py, api/routes/_pipeline.py, api/routes/_platform.py, rate_limit.py, services/budget_service.py, services/log_writer.py) where today it is one. Before this PR, gateway/metrics.py is the single place where prometheus could be made optional; after it, that change reopens all six.

And we do want it optional. prometheus-client is a core dependency, imported on every startup regardless of enable_metrics, which gates only the middleware and the /metrics route (main.py:803, main.py:837). A deployment that never scrapes still pays the import (most of it prometheus_client.exposition pulling in http.server and wsgiref.simple_server, which nothing else here needs), the ProcessCollector registration, and 14 counters incrementing that nothing can read.

The ask: in those six modules, import Counter, Gauge and Histogram from gateway.metrics rather than from prometheus_client. Same objects, since metrics.py would just re-export them, so nothing about this PR's behavior or its pinned-family test changes. It keeps one seam where the try: from prometheus_client import ... / except ImportError: fallback can live, so the follow-up is confined to metrics.py and pyproject.toml.

Two small bonuses if you do: it retires the PrometheusCounter alias in _pipeline.py, since gateway.metrics.Counter collides with nothing, and metrics.py keeps a reason to exist beyond the registry, which reads better than a module that is only a variable.

#1235 is a reference draft of the follow-up against main, so you can see what the seam buys: the extra, the no-op fallback, a startup refusal when enable_metrics is set without it, and the OSS smoke gate (which installs no extras) becoming the thing that keeps the fallback honest. It should rebase onto this once this lands. If the import source changes here, its diff to metrics.py shrinks to the fallback block alone.

@peteski22
peteski22 force-pushed the refactor/metrics-beside-owners branch from 71d6a9a to 1a63dd6 Compare September 16, 2026 14:01
@peteski22
peteski22 deployed to integration-tests September 16, 2026 14:01 — with GitHub Actions Active
@peteski22
peteski22 enabled auto-merge (squash) September 16, 2026 14:01
@peteski22
peteski22 deployed to integration-tests September 16, 2026 14:04 — with GitHub Actions Active
Moving each counter beside the code that increments it (#1177) must not
rename a metric or change its labels, because dashboards and alerts outside
this repository key on them. Pin the families before any move so each move
is checked against the same set.
The rate limiter is the only code that increments gateway_rate_limit_hits,
so the counter now lives in rate_limit.py, registered on the shared
registry. The wrapper function went with it: a one-line helper with a
single caller in the same module said nothing the counter does not.

Part of #1177.
budget_service is the only code that increments gateway_budget_exceeded, so
the counter now lives there, registered on the shared registry, and the
three call sites increment it directly. The wrapper function and its unit
test go: the counter's real path is covered by the integration test that
drives a zero-limit budget to a 403.

Part of #1177.
The two writers are the only code that touches the four usage log metrics,
so they now live in log_writer.py, registered on the shared registry, under
names that read in that module (ROWS, QUEUE_DEPTH, BATCH_SIZE,
FLUSH_DURATION). The lowercase aliases metrics.py exported for the writer
are gone with the move.

Part of #1177.
api.deps is where nearly every authentication failure is counted, so
gateway_auth_failures and its record helper now live there, registered on
the shared registry. The three sign-in routes already import their
dependencies from api.deps and now take the helper from the same place. The
helper's unit test goes: the bearer-token and key-verification tests
already assert the counter through the real callers.

Part of #1177.
Three modules count abandoned attempts: the platform runner, the local
attempt walker, and the pipeline. The runner module already owns the
upstream error classification the other two import, so
gateway_abandoned_attempts and its record helper now live beside it, on the
shared registry, and the walker and the pipeline take the helper from
there. The helper's tests move next to the runner's own abandonment tests.

Part of #1177.
…he pipeline

The shared request pipeline is the only code that records token counts,
request cost and inline cost settlement outcomes, so those three metrics
and their record helpers now live in _pipeline.py, registered on the shared
registry. The Prometheus Counter is imported under an alias there because
the module already uses collections.Counter. metrics.py now holds the
registry, the process collector and the HTTP request instrumentation only.

Part of #1177.
…re-exports

The six modules that own a metric imported Counter, Gauge and Histogram
from prometheus_client directly. Importing them from gateway.metrics
instead keeps one module as the gateway's dependency on prometheus_client,
so making that dependency optional later changes that module alone.

gateway.metrics lists the types in __all__, because mypy strict does not
treat an imported name as exported. The PrometheusCounter alias in
_pipeline.py stays: that module also imports collections.Counter.
@peteski22
peteski22 force-pushed the refactor/metrics-beside-owners branch from 3a41113 to fe90cb6 Compare September 16, 2026 14:11
@peteski22
peteski22 deployed to integration-tests September 16, 2026 14:11 — with GitHub Actions Active
@peteski22
peteski22 disabled auto-merge September 16, 2026 14:20
@peteski22
peteski22 enabled auto-merge (squash) September 16, 2026 14:20
@peteski22
peteski22 disabled auto-merge September 16, 2026 14:21
@peteski22
peteski22 merged commit 173008c into main Sep 16, 2026
12 checks passed
@peteski22
peteski22 deleted the refactor/metrics-beside-owners branch September 16, 2026 14:21
@peteski22

Copy link
Copy Markdown
Contributor Author

Done in 3a41113. Two notes. mypy strict does not treat an imported name as exported, so metrics.py now lists its public names in __all__. That also covers both import blocks in #1235. The PrometheusCounter alias stays, because _pipeline.py also imports collections.Counter.

daavoo added a commit that referenced this pull request Sep 16, 2026
…drop the readiness check

The readiness probe judged the pool on one instantaneous reading, so a pool
that filled for a moment took the pod out of rotation and a traffic spike
across replicas became an outage. Drop the check, along with POOL_EXHAUSTED,
request_pool_stats and PoolStats.is_saturated. #1247 tracks a check that
judges the pool over time.

The gauges move out of metrics.py, which #1205 had just emptied, and become a
collector in core/database.py that reads the pools on each scrape. That also
removes the metrics -> core.database import, which would have stopped
core.database ever declaring a metric of its own.

Capacity now uses the max_overflow the gateway configured rather than
QueuePool's private _max_overflow, so it cannot silently understate itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
peteski22 pushed a commit that referenced this pull request Sep 16, 2026
…drop the readiness check

The readiness probe judged the pool on one instantaneous reading, so a pool
that filled for a moment took the pod out of rotation and a traffic spike
across replicas became an outage. Drop the check, along with POOL_EXHAUSTED,
request_pool_stats and PoolStats.is_saturated. #1247 tracks a check that
judges the pool over time.

The gauges move out of metrics.py, which #1205 had just emptied, and become a
collector in core/database.py that reads the pools on each scrape. That also
removes the metrics -> core.database import, which would have stopped
core.database ever declaring a metric of its own.

Capacity now uses the max_overflow the gateway configured rather than
QueuePool's private _max_overflow, so it cannot silently understate itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
peteski22 pushed a commit that referenced this pull request Sep 16, 2026
#1242)

* feat(observability): export database pool stats and fail readiness fast when the pool is full

Nothing reported how much of the SQLAlchemy connection pool was in use, so a
connection leak stayed invisible until every request answered 503, and the
readiness probe then queued for the full pool timeout before reporting a
generic database outage.

Adds a pool-stats helper over the active engines and uses it twice: four
Prometheus gauges filled at scrape time (checked out, idle, overflow,
capacity), labeled by pool so the metering pool is covered too; and a
side-effect free saturation check ahead of the readiness query, which answers
503 immediately and names pool exhaustion as its own state.

Fixes #1240

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(deployment): document the database pool metrics and the pool_exhausted readiness state

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* style(db): drop a stray blank line

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(web): regenerate the API client for the readiness docstring

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(observability): publish the pool stats from a collector and drop the readiness check

The readiness probe judged the pool on one instantaneous reading, so a pool
that filled for a moment took the pod out of rotation and a traffic spike
across replicas became an outage. Drop the check, along with POOL_EXHAUSTED,
request_pool_stats and PoolStats.is_saturated. #1247 tracks a check that
judges the pool over time.

The gauges move out of metrics.py, which #1205 had just emptied, and become a
collector in core/database.py that reads the pools on each scrape. That also
removes the metrics -> core.database import, which would have stopped
core.database ever declaring a metric of its own.

Capacity now uses the max_overflow the gateway configured rather than
QueuePool's private _max_overflow, so it cannot silently understate itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: daavoo <davidd@mozilla.ai>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

This branch was successfully deployed

1 active deployment
integration-tests — fe90cb6f Deployed Sep 16, 2026 by peteski22 via test-integration #2050
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Metrics beside their owners

3 participants