Skip to content

feat: /metrics with full app instrumentation, complete API docs, benchmark output paths, and six bug fixes - #157

Merged
aditeyabaral merged 17 commits into
pesu-dev:devfrom
aditeyabaral:feat/metrics-and-benchmark-outputs
Sep 13, 2026
Merged

aditeyabaral merged 17 commits into
pesu-dev:devfrom
aditeyabaral:feat/metrics-and-benchmark-outputs

Conversation

@aditeyabaral

@aditeyabaral aditeyabaral commented Sep 13, 2026

Copy link
Copy Markdown
Member

📌 Description

Closes #123, #129, and supersedes #143 and #132, whose review changes were never applied.

This builds directly on work by @niffy-artist2876 (#143) and @snigenigmatic (#132). Their diffs established what to build; the review comments on those PRs are the specification for how. Both PRs are a year old with changes requested and no path to a rebase, so this implements the reviewed requirements fresh against current dev.

# Change Commit
0 Every benchmark script has been broken on dev since #156 585c07e
1 Metrics collector, with typed families and labels instead of dynamic metric names c30344b
2 Prometheus text exposition, hand-rolled — no new dependencies 5e36a01
3 A proper response model for the JSON view 0c8aff5
4 Middleware instrumentation, and the middleware/handler split that makes it correct dc94f43
5 /metrics, serving either format by query parameter 3f23c1d, 0ae0a0a
6 Benchmark output paths, naming, --tag, and three adjacent bugs f9b7088
7 Every path in the app instrumented — 9 metric families to 23 92db11e
8 A startup bug the new metrics found within minutes 688eb00
9 Three defects found reviewing the above 25a7a9a
10 Swagger documented a 422 this API can never return 25a7a9a
11 ResponseModel could not parse the responses it describes 7a37ec9
12 One fact recorded in two metric families 37357a9
13 The version bumped twice in one PR, and the guidance that caused it 776bad4

Important

scripts/benchmark/util.py still imported httpx, which #156 removed. All three benchmark scripts have been dying at import on dev and main since that merge:

ModuleNotFoundError: No module named 'httpx'

Nothing caught it — ruff does not resolve third-party imports, no test imports these scripts, and the coverage gate is --cov=app. The check run after that migration was a grep over app/, tests/, pyproject.toml, Dockerfile and README.md, which simply omitted scripts/. Production was never affected, since the Dockerfile copies only app/. Fixed first (585c07e) so it can be cherry-picked ahead of the rest.

🔭 Metrics (#129)

The middleware/handler split

The review asked for a middleware layer rather than per-route instrumentation, which is right — but the pseudo-code assumes the middleware's except sees handled errors. It does not, and the real layering cuts both ways. starlette/applications.py builds:

ServerErrorMiddleware     <- our @app.exception_handler(Exception) lives HERE (ABOVE us)
  metrics middleware      <- us
    ExceptionMiddleware   <- RequestValidationError + PESUAcademyError handlers (BELOW us)
      router
  • A handled PESUAcademyError becomes a response below us, so call_next returns an ordinary 401 and the except branch never fires.
  • An unhandled exception passes through us but its 500 is rendered above us, so we never see that response either — the except branch has to record status=500 itself, or requests_total silently stops matching sum(responses_total).

So the middleware owns status, route and latency; the exception handlers own the error type, one line each. Different families, so one failed request yields exactly one status sample and exactly one error sample — nothing doubled, and the information a status code loses is recovered: CSRFTokenError and ProfileFetchError are both 502, and only errors_total tells them apart.

Two invariants you can check on a running instance, and there is a test for each:

sum(responses_total) == requests_success + requests_failed
sum(errors_total)     < requests_failed          # a router 404 runs no handler of ours

Other corrections to the pseudo-code

Problem Fix
200 <= status < 300 as success /readme returns 308, so every readme hit counted as a failure. < 400.
f"requests_failed_status_{code}" Dynamic metric names cannot be rendered as Prometheus — each becomes its own family, and sum by (status) is impossible. Labels instead.
request_latency_sum with no count A sum alone gives no average and no rate denominator. Sum and count, as a summary.
time.time() Not monotonic; one NTP step backwards poisons a cumulative sum permanently. perf_counter().
scope["route"] Only populated after call_next, and reached via getattr — plain Starlette routes (Swagger UI, /openapi.json) never set it.

Latency is also named for what it measures: call_next returns at http.response.start and the body streams afterwards, so this is time-to-response-start, not request duration.

Why no dependency

Answering "Are these dependencies necessary to be added?" from the review of #132: none are. prometheus_client installs a process-global default registry at import time — precisely the "global collector outside the entry point" the other review comment rejected — and it would become a second source of truth beside the snapshot needed anyway for the pydantic model. What it buys over ~50 lines is histogram buckets, which are not exposed.

Cardinality

Both attacker-controlled label values are clamped. The route is the matched template, never the raw path — otherwise a scanner walking /wp-login.php, /.env and friends mints a series per probe — with unmatched paths collapsed to <unmatched>, and the method clamped to the seven known verbs.

One path, two formats

GET /metrics?fmt=prometheus (the default) or ?fmt=json.

The default has to be Prometheus: a scraper pointed at this path with no query string must get the exposition format. fmt is a StrEnum, so FastAPI validates it, Swagger renders a dropdown, and an unrecognised value goes through the existing validation handler — an ordinary 400 that is itself counted like any other failed request rather than being a special case.

Content negotiation on Accept was the alternative and is worse: real scrapers, curl and browsers all hit the */* wildcard, so any resolution of it surprises half the callers and needs a hand-rolled q-value parser larger than the renderer itself.

response_model is None, since the response type depends on the parameter and cannot be declared once; both shapes are documented under responses=, which is what Swagger renders from anyway.

The model resolves the dynamic-key tension by noting dynamic keys do not need dynamic fields: dict[str, int] and dict[str, RouteMetricsModel] validate every value and generate correct additionalProperties. Note strict=True rejects a float for an int field and the collector stores floats, so every value is cast explicitly — that is the most likely source of a 500 here and has its own test.

The one deliberate exception to "middleware, not routes"

The profile split is recorded in the /authenticate handler. Reading the body in middleware would consume the downstream receive channel and pull a plaintext-password payload into another layer. How many auth requests arrive is already free from route_requests_total; only the split needs the body.

🔬 Every path instrumented

A second pass audited every request, response, error and background task in the app against what was being recorded. 9 metric families became 23.

Gap Now measured
The upstream was entirely uninstrumented Every call to PESU Academy — csrf_fetch, login, profile_fetch — with count, outcome, latency and the upstream status code
No client-vs-server attribution failures_total{fault} splits 4xx from 5xx, so an alert can fire on only our own faults
No login success rate with a matching denominator authentication_results_total{result}: success or failure. The reason stays in errors_total{type} — see the de-duplication note below
Validation said that something failed, not which validation_errors_total{field}, bounded against a caller-controlled body
Profile parse failures opaque profile_parse_errors_total{reason} — six reasons, including unknown_campus_code, which previously only logged a warning and raised nothing at all
Prefetch effectiveness invisible csrf_cache_total{hit,miss} — the thing #156 was about
Background refresh untracked csrf_refreshes_total{outcome} (present in #132, and I had dropped it)
Prefetch tasks untracked prefetch_tasks_total{success,failure,cancelled}
No leak indicator http_clients_total{created,closed,close_failed} — created minus closed is what is still open
The accounting gap unexplained requests_in_flight
Restarts invisible beyond a timestamp lifespan_events_total

The upstream one matters most: PESU Academy is the only dependency this service has and the only thing that can be slow or down, and its latency is now recorded separately from the API's own — so a slow request can be attributed rather than guessed at. It also separates "the call failed" from "the call succeeded and we could not parse what came back": a missing CSRF tag counts as a successful 200, because it is our parsing that failed, not theirs.

Every label value that could come from a caller is bounded — route templates, methods, request fields — so none of this opens a cardinality hole.

The metrics found a bug within minutes of existing

An idle process, seconds after boot, reported:

upstream.csrf_fetch.success  2
httpClients                  {created: 2, closed: 1}

lifespan prefetches a client and caches it, then starts the refresh loop — which refreshed immediately on its first iteration, fetching a second token and discarding the one just prefetched. Every startup paid a wasted upstream round trip, and on Render, which restarts often, that is every restart.

Exactly the class of waste fixed in #156, and invisible for exactly the same reason: nothing counted the upstream calls. Fixed in 688eb00 by having the loop sleep before its first refresh, which is all it was ever meant to do — lifespan primes the cache, the loop keeps it fresh afterwards. Verified after: csrf_fetch: 1, created: 1, closed: 0. Kept as a separate commit so it can be dropped independently of the instrumentation that found it.

🔍 Self-review pass

Reviewing the above turned up three defects, each with a test that fails against the code as it was.

requests_in_flight leaked on every client disconnect. The decrement sat in the two branches of the middleware rather than in a finally — and except Exception does not catch CancelledError, so an abandoned request incremented the gauge and never decremented it. On a real server it would have climbed forever, and since it is the number documented as explaining the gap between total and success + failed, it would have actively misled. The cancellation test asserted total, success and failed but not the gauge, which is exactly why it passed:

E       AssertionError: assert 1.0 == 0.0
E        +  where 1.0 = value('pesu_auth_requests_in_flight')

A label could shadow a positional parameter. increment(family, value=...) and observe(family, seconds=...) took their amount as an ordinary parameter, so a family declaring a label named value or seconds would have had it silently captured as the amount instead. Both are positional-only now — which immediately caught a test that had been relying on that exact ambiguity.

A cancelled upstream call was counted as an upstream error. A disconnect or a shutdown is not PESU Academy failing; counting it as one would spike the upstream error rate on every deploy, which is precisely when someone is looking at the dashboard. It has its own outcome now.

Also closed the last uninstrumented branch: profile_field_filtering_total records whether a caller's field list actually narrowed the response, measured at the branch rather than from the request body — so a caller who passes exactly the default list counts as no filtering, which is what happened.

Verified on a live server afterwards, with the accounting identity holding:

total=5  success=2  failed=2  inFlight=1        ->  identity holds
sum(responsesByStatus) == success + failed      ->  true
httpClients {created: 3, closed: 2}             ->  1 open (the cached client)
upstream    csrf_fetch(2,0,0) login(2,0,0) profile_fetch(1,0,0)

📖 Swagger and README

Swagger documented a response that cannot happen. FastAPI adds a 422 carrying its own HTTPValidationError body to every route whose parameters can fail validation — but this API converts every RequestValidationError into a 400 with the same {status, message, timestamp} body as every other error. /metrics therefore advertised a status it never returns, in a shape it never emits. The schema is now built through an override that drops those, matched on their schema so that /authenticate's real 422 (a profile parse failure, using this API's own response model) is kept. HTTPValidationError and ValidationError leave the components with them.

Every response on every route now carries both an example and a schema — the /readme 308 and the /metrics text/plain body previously had neither. The /metrics examples were hand-written and had drifted from the model; both are generated from a real snapshot now, so the JSON example is complete and round-trips through MetricsModel.

Codes Every response has example + schema
POST /authenticate 200, 400, 401, 422, 500, 502 ✅ (plus 3 request examples)
GET /health 200, 500
GET /metrics 200, 400, 500 ✅ (plus the fmt parameter)
GET /readme 308, 500

tests/unit/test_openapi_docs.py makes the documentation self-checking, and is what found both defects above: every route documents a success and a 500, every response has an example and a schema, every JSON example validates against the model it claims, the request examples cover all three username forms plus profile and field filtering, and the documented 400, 401 and 200 bodies are compared against real responses.

One subtlety worth flagging for review: those examples are validated in JSON mode, not Python mode. The models are strict, and a strict Python-mode validation rejects the ISO string these responses actually carry in timestamp. JSON mode is the mode a caller parsing the body is in, so it is the honest check.

The two test-only exception routes are now include_in_schema=False — they were appearing in the published schema whenever their module was imported.

README gains a full metrics reference: how collection works and which of the three layers records what, the two accounting identities that hold at all times, the definitions that are easy to assume wrongly (latency is time to response start; success is below 400, not 300; summaries expose sum and count, not quantiles; scrapes count themselves), a table explaining every metric, and complete generated examples of both formats.

📊 Benchmarks (#123)

All five review comments on #143 are implemented, plus "please incorporate similar changes in unauthenticated_csrf_token_expiry.py". resolve_output_path lives in the existing util.py rather than a new module, since both runners already import it.

Output is now benchmark/results/{script}_{date}_{time}[_{tag}].{ext}, anchored to the repository root so results land in one place regardless of cwd, with --output-dir, --tag and --output all honoured and parent directories created.

Three adjacent bugs fixed in the files being rewritten:

  • make_request crashed on non-JSON routes. It ended in an unconditional response.json(), so --route readme (a 308 to GitHub returning HTML) killed the sequential runner and was silently swallowed as a failed request by the parallel one — skewing the very numbers being measured. This is what Feat metric logging #132's author was patching when a reviewer asked "why is this being added?" The answer is that it was a real bug.
  • analyze_benchmark.py --files was not required, so omitting it raised a bare TypeError from a list comprehension instead of an argparse error.
  • unauthenticated_csrf_token_expiry.py lost everything on Ctrl-C. It wrote its CSV only after the loop ended, and that loop sleeps for hours between requests. Rows are now written as they are measured.

🧹 Two things the review changed its mind about

ResponseModel could not parse the responses it describes. timestamp is a datetime under a model-wide strict=True, but the API serializes an ISO string onto the wire — so a client holding a decoded dict (what every HTTP library returns) got a ValidationError from the schema this API publishes for exactly that purpose.

It also made the documentation tests weaker than they looked: they validated in JSON mode, where pydantic accepts a string for a datetime because JSON has no datetime type. That passed, but it was working around the problem instead of finding it. strict=False on that one field now accepts both forms; every other field stays strict, pinned by a test. The docs tests assert both modes rather than whichever passes.

One fact was being recorded in two families. authentication_results_total carried a value per failure class, duplicating what errors_total{type} already said about the same event — and three of the four were not a different vocabulary, just the class name in snake_case:

CSRFTokenError    -> csrf_token_error
ProfileFetchError -> profile_fetch_error
ProfileParseError -> profile_parse_error

Worse than redundant, it was a drift risk: a hand-written dict read through .get(type(exc), "other"), so a fifth exception class with a forgotten entry would have left two counters disagreeing, with the less informative one failing silently.

It is success or failure now, and the reason lives in exactly one place. The family survives because it answers the one thing nothing else can — the login success rate, with success and failure sharing a denominator (sum(authenticationResults) == authentication.total, pinned by a test). Deriving that from errorsByType would mean subtracting several error classes from a different family, which is the fragile cross-family arithmetic this avoids.

🔁 Final review pass

The version had bumped twice in one pull request — 4.1.0 → 4.2.0 → 4.3.0, because the second batch of work read as another feature. One merge to dev is one bump, so this is 4.2.0; 4.3.0 would have skipped a version that never reaches dev.

The guidance in check_version_bump.py is what led there: it described picking a level by change type ("minor: new functionality", "patch: a bug fix"), which invites exactly that reading when one PR contains several kinds of change. It now states the rule in force — raise the minor by one, once per pull request, with major reserved for a backwards-incompatible change.

Three more from re-reading the diff:

  • The OpenAPI override restated FastAPI's argument list, calling get_openapi() with five arguments where FastAPI passes fourteen. Every missing one is a default today, so nothing was visibly wrong — but setting servers= or license_info= on the app later would have silently vanished from the schema. It captures and delegates to FastAPI's own builder now.
  • _upstream_call recorded in three places, once per branch. A finally with a pessimistic default makes "every call is counted and timed exactly once" structural instead of three copies that have to stay in step.
  • A test still used a label value the app can no longer emit (result="invalid_credentials"). It passed because the collector validates label names, not values — so it was quietly asserting a behaviour that no longer exists.

FAMILIES is now guarded against the drift that made two of those possible: a family defined but left out of the registry would be collected into and never exposed, silently. Tests assert the registry matches the module, names are unique and valid Prometheus identifiers, no counter name collides with a summary's _sum/_count, and every HELP line fits the exposition example's line limit.

🧱 Type of Change

  • 🐛 Bug fix – Non-breaking fix for a functional/logic error
  • ✨ New feature – Adds functionality without breaking existing APIs
  • 📝 Documentation update – README, docstrings, OpenAPI tags, etc.
  • 🧪 Test suite change – Adds/updates unit, functional, or integration tests
  • 🕵️ Debug/logging enhancement – Adds or improves logging/debug support
  • 🔧 Developer tooling – Scripts, benchmarks, local testing improvements

🧪 How Has This Been Tested?

  • Unit Tests (tests/unit/)
  • Integration Tests (tests/integration/)
  • Manual Testing

⚙️ Test Configuration:

  • OS: Linux
  • Python: 3.14.4 via uv
  • Docker build tested

240 tests (was 101), 100.00% coverage maintained, 139 of them new across seven modules. The most valuable is test_an_unhandled_exception_records_a_500: that path runs through ServerErrorMiddleware, which sits above the metrics middleware, so whether a 500 is recorded at all cannot be established by reading the code — only by driving a real exception through the whole stack.

Verified against a running instance rather than only in tests:

requests      : {'total': 13, 'success': 9, 'failed': 3}
byStatus      : {'200': 7, '308': 2, '400': 1, '401': 1, '404': 1}
byType        : {'AuthenticationError': 1, 'RequestValidationError': 1}
byRoute       : {'GET /health': 5, 'GET /metrics': 2, 'GET /readme': 2,
                 'GET <unmatched>': 1, 'POST /authenticate': 2}

sum(byStatus) == success+failed : True
sum(byType)   <  failed         : True

The 308 counted as a success is the < 400 correction proven live; GET <unmatched> is the cardinality bucket; and the two error types being distinguishable while both appearing as status codes is the middleware/handler split working.

content-type: text/plain; version=0.0.4; charset=utf-8 confirmed over real HTTP — Starlette appends a charset only when one is absent, so it is not duplicated.

Benchmark scripts were run against a live server: default naming, --tag, --output-dir and explicit --output all land where intended, and --route readme and --route metrics both succeed where they previously could not.

✅ Checklist

  • My code follows the CONTRIBUTING.md guidelines
  • I've performed a self-review of my changes
  • I've added/updated necessary comments and docstrings
  • I've updated relevant docs (README or endpoint docs)
  • No new warnings introduced
  • I've added tests to cover my changes
  • All tests pass locally (scripts/run_tests.py)
  • I've run linting and formatting (pre-commit run --all-files)
  • Docker image builds and runs correctly
  • Changes are backwards compatible

🛠️ Affected API Behaviour

  • app/app.py – Metrics middleware, three one-line handler increments, the profile split, two new routes

New endpoint: /metrics — Prometheus text by default, JSON with ?fmt=json

🧩 Models

  • app/models/metrics.py – New: MetricsModel and four nested models

🐳 DevOps & Config

  • pyproject.toml / uv.lock – Version 4.1.0 → 4.2.0. No dependency changes.
  • .github/scripts/check_version_bump.py – guidance now states the rule in force: one bump per pull request
  • README.md – full metrics reference: how collection works, what every metric means, generated examples of both formats

requirements.txt is deliberately untouched: it is a stale uv pip compile artefact (still pinning fastapi==0.136.1 against an installed 0.141.1) and the Dockerfile builds from uv.lock --frozen. The Dockerfile copies app wholesale, so app/metrics/ ships with no build change.

📊 Benchmarks & Analysis

  • scripts/benchmark/util.pyresolve_output_path, plus the non-JSON fix and the httpx2 import
  • scripts/benchmark/benchmark_requests.py, analyze_benchmark.py, unauthenticated_csrf_token_expiry.py

🧠 Additional Notes

#130 (Grafana) is unblocked by this/metrics speaks Prometheus, so no exporter is needed later.

Commits are ordered so each builds and tests green alone. Reverting the middleware commit leaves the collector, renderer and model as dead but harmless code; reverting the endpoints commit keeps collecting but stops exposing.

Two things noted and deliberately not done:

  • @app.middleware("http") means BaseHTTPMiddleware, which creates an anyio task group and a memory object stream per request. A pure ASGI middleware class would be cheaper and could read the status straight off http.response.start. With one middleware on a small API the difference is not worth the readability, and the decorator is what the review asked for.
  • A client disconnect surfaces as cancellation and skips recording, so total exceeds success + failed while requests are in flight. Swallowing cancellation in a metrics layer would be worse than a small, self-documenting undercount.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VU7YUhP71KSsotWQ1H7CRH

aditeyabaral and others added 9 commits September 12, 2026 20:21
The httpx2 migration in pesu-dev#156 removed `httpx` from the project, but
`scripts/benchmark/util.py` still imported it. Every benchmark script imports
`util`, so all three have been dead on `dev` and `main` since that merge:

    ModuleNotFoundError: No module named 'httpx'

Nothing caught it. Ruff does not resolve third-party imports, no test imports
these scripts, and the coverage gate is `--cov=app`. The check I ran after the
migration was a grep over `app/`, `tests/`, `pyproject.toml`, `Dockerfile` and
`README.md` -- which simply omitted `scripts/`. A rename needs a repo-wide
grep, not a directory-by-directory one.

Production was never affected: the Dockerfile copies only `app/`.

The API is identical, so this is the import and the two call sites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU7YUhP71KSsotWQ1H7CRH
Implements pesu-dev#129's collector, with two changes from the sketch in the issue.

Families are typed registry objects rather than free strings. A typo in a
metric name would otherwise create an orphan series that silently never gets
reported; now it raises at the call site, and `FAMILIES` doubles as the single
source of HELP and TYPE text shared by every view.

Dimensions are labels, not name suffixes. The review on pesu-dev#132 asked for keys
like `requests_failed_status_{code}`, but those cannot be rendered as
Prometheus -- each string becomes its own family needing its own HELP and TYPE,
and `sum by (status)` becomes impossible. `responses_total{status="401"}`
aggregates and stays one family however many status codes appear.

No lock. Every mutation is a dict read and write with no await between them, so
the event loop cannot interleave two increments. The asyncio.Lock in
app/pesu.py exists because that code swaps several fields *around* an await,
which is a different problem. The docstring records where this stops holding.

Unlabelled series are seeded at zero so a freshly started process exposes them
before its first request -- otherwise a series springs into existence mid-window
and `rate()` reads it as a spike. `process_start_time_seconds` is exposed for
the same class of reason: Render restarts wipe these counters, and without it a
dashboard cannot tell a restart from a drop in traffic.

Nothing imports this yet. 20 tests, 100% coverage of the new module.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU7YUhP71KSsotWQ1H7CRH
This is what unblocks pesu-dev#130: Grafana scrapes Prometheus, not arbitrary JSON, so
a JSON-only /metrics would have needed an exporter written later.

Hand-rolled rather than pulling in prometheus_client, which is the direct
answer to the dependency question raised on pesu-dev#132: **no dependencies are added.**
prometheus_client installs a process-global default registry at import time --
precisely the "global collector outside the entry point" the review rejected --
and it would become a second source of truth beside the snapshot we need anyway
for the pydantic view. What it buys over these 50 lines is histogram buckets,
which we do not expose. The format for counters, gauges and a quantile-less
summary is a small, stable grammar that the tests pin exactly.

Details that are easy to get wrong, so they are tested:
- the media type must carry `version=0.0.4`, or a scraper guesses the format
- label values are double-quoted, so `"`, `\` and newline need escaping; HELP
  text is unquoted, so only `\` and newline do
- labels render sorted, which keeps the payload deterministic and diffable
- whole numbers render without a decimal point, matching every other exporter

16 tests, 100% coverage of the renderer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU7YUhP71KSsotWQ1H7CRH
Answers "create a model for this -- the response model will also need an
update" from the review of pesu-dev#132.

The awkward part is that metric keys are dynamic: the status codes and route
templates that appear depend on traffic, so a fixed-field model cannot express
them. Resolved by noting that dynamic *keys* do not require dynamic *fields* --
`responses_by_status: dict[str, int]` and `requests_by_route:
dict[str, RouteMetricsModel]` validate every value, generate correct OpenAPI
`additionalProperties`, and keep `strict=True` meaningful.

`from_snapshot` casts every value explicitly. The collector stores floats, and
strict mode rejects a float for an int field, so an un-cast value would be a
500 in production rather than a payload. That is the single most likely bug
here, which is why the fresh-collector case is its own test.

No timestamp field, deliberately: `IST` lives in app/app.py and importing it
here would make app.models depend on app.app. `startTimeSeconds` and
`uptimeSeconds` carry the same information, need no timezone, and line up with
the Prometheus gauge.

`averageSeconds` is null rather than absent on a fresh process, so consumers
see one stable shape instead of a key that materialises after the first
request.

11 tests, 100% coverage of the new module.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU7YUhP71KSsotWQ1H7CRH
Implements the middleware layer asked for in the review of pesu-dev#132, rather than
instrumenting each route by hand. Eight things in the review's pseudo-code do
not work as written; the interesting one is the fourth.

**The layering.** starlette/applications.py builds the stack as
ServerErrorMiddleware -> user middleware -> ExceptionMiddleware -> router. Our
`@app.exception_handler(Exception)` becomes ServerErrorMiddleware's handler and
so runs *above* this middleware; the RequestValidationError and
PESUAcademyError handlers live in ExceptionMiddleware, *below* it. That cuts
both ways:

- a handled PESUAcademyError is already a response by the time call_next
  returns, so the `except` branch never sees it -- the pseudo-code assumes it
  does;
- an unhandled exception passes through us but its 500 is rendered above us, so
  we never see that response either, and the `except` branch has to record the
  status itself or requests_total silently stops matching sum(responses_total).

So: the middleware owns status, route and latency; the exception handlers own
the error type, one line each. Different families, so one failed request yields
exactly one status sample and one error sample. That is also what recovers the
information a status code loses -- CSRFTokenError and ProfileFetchError are both
502, and only errors_total tells them apart.

The other corrections: `status < 400` for success, not `< 300`, or /readme's 308
counts every readme hit as a failure; perf_counter rather than time(), since a
wall clock can step backwards and poison a cumulative sum; scope["route"] read
only after call_next, via getattr, because plain Starlette routes never set it;
and latency named for what it measures, which is time to response *start* --
call_next returns at http.response.start and the body streams afterwards.

Cardinality is bounded at both attacker-controlled labels: the route is the
matched template with unmatched paths collapsed to one bucket, and the method is
clamped to the seven known verbs. Keying on the raw path would let a scanner
walking /wp-login.php mint a series per probe.

The profile split is the one deliberate exception to "middleware, not routes".
Reading the body in middleware would consume the downstream receive channel and
pull a plaintext-password payload into another layer; how many auth requests
arrive is already free from route_requests_total, and only the split needs the
body.

Cancellation from a client disconnect is left unrecorded, so total exceeds
success + failed while requests are in flight. Swallowing it to record would be
worse than the undercount.

163 tests, 100% coverage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU7YUhP71KSsotWQ1H7CRH
Two paths rather than content negotiation on one. Real scrapers send
`Accept: application/openmetrics-text;...,text/plain;version=0.0.4;q=0.5,*/*;q=0.1`,
curl sends `*/*`, and a browser sends `text/html,...,*/*;q=0.8` -- none of which
unambiguously mean JSON, so any resolution of the wildcard surprises half the
callers, and getting there needs a hand-rolled q-value parser larger than the
renderer. A single path also cannot carry a response_model, so Swagger would
show either a misleading schema or none.

Split in two, each is documented properly: /metrics declares
response_class=PlainTextResponse with a text/plain example, mirroring how
app/docs/readme.py documents its text/html body, and /metrics.json declares
response_model=MetricsModel -- the second response_model in the codebase after
/authenticate, which is what "the response model will also need an update"
asked for. JSON deliberately does not live on /metrics: Prometheus has
effectively reserved that path.

/metrics.json returns the model rather than a JSONResponse, so FastAPI
serializes it with by_alias=True and the camelCase keys come for free, with no
hand-patched dict of the kind /authenticate needs for its datetime.

Both reuse the existing "Monitoring" OpenAPI tag.

The most valuable of the 13 new tests is the unhandled-exception one. That path
runs through ServerErrorMiddleware, which sits *above* our middleware, so
whether a 500 is recorded at all cannot be established by reading the code --
only by driving a real exception through the whole stack. There is also a test
asserting the accounting invariants hold end to end:
sum(responsesByStatus) == success + failed, and sum(errorsByType) < failed
whenever a 404 is in the mix, since the router's 404 runs no handler of ours.

176 tests, 100% coverage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU7YUhP71KSsotWQ1H7CRH
Closes pesu-dev#123. Implements the five changes requested on pesu-dev#143, plus the review's
"please incorporate similar changes in unauthenticated_csrf_token_expiry.py".

Every output path was a bare relative filename, so results landed wherever the
script happened to be run from -- in practice cluttering scripts/benchmark/ --
and `analyze_benchmark.py` overwrote distribution.png and timeline.png on every
run. `resolve_output_path` in util.py now handles all of it: explicit --output
wins, otherwise `{script}_{date}_{time}[_{tag}].{ext}`, with parent directories
created either way. The default directory is anchored to the repository root
rather than the cwd, so output lands in one place regardless of where the
script is invoked.

util.py is reused rather than adding a module, since both runners already
import it; analyze_benchmark.py now imports it too.

Also in scope, because they are in the files being rewritten:

- `make_request` ended in an unconditional `response.json()`, so `--route
  readme` (a 308 to GitHub returning HTML) crashed the sequential runner and
  was silently swallowed as a *failed request* by the parallel one, skewing the
  very numbers being measured. This is what pesu-dev#132's author was patching when a
  reviewer asked "why is this being added?" -- it is a real bug. Non-JSON
  responses now fall back to the status and the raw text.
- `analyze_benchmark.py --files` was not required, so omitting it raised a bare
  TypeError from a list comprehension instead of an argparse error.
- `unauthenticated_csrf_token_expiry.py` wrote its CSV only after the loop
  ended, and that loop sleeps for hours between requests -- so a Ctrl-C threw
  away every measurement taken. Rows are now written as they are measured.
- Both runners carried a no-op string expression where a docstring cannot go,
  inside `if __name__ == "__main__":`.
- `--route` gained `metrics` and `metrics.json`, now that those exist.

Verified against a live server: default naming, --tag, --output-dir and an
explicit --output all land where intended, and `--route readme` and
`--route metrics` both succeed where they previously could not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU7YUhP71KSsotWQ1H7CRH
README gains rows for /metrics and /metrics.json in the endpoint table and a
section for each, following the shape of the /health section. The notes worth
having in writing rather than only in code comments: counters reset on restart
(which is what process_start_time_seconds is for), status codes and exception
classes are recorded separately so CSRFTokenError and ProfileFetchError stay
distinguishable despite both being 502, scrapes of /metrics count themselves on
purpose, and requests.total can briefly exceed success + failed because arrival
and outcome are recorded at different moments.

CONTRIBUTING gains a note on where the benchmark scripts now write, since the
answer changed in this PR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU7YUhP71KSsotWQ1H7CRH
Minor: new functionality that keeps existing APIs working, per the guidance in
.github/scripts/check_version_bump.py. Two new endpoints, no change to
/authenticate, /health or /readme, and no new dependencies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU7YUhP71KSsotWQ1H7CRH
@aditeyabaral
aditeyabaral requested a review from a team as a code owner September 13, 2026 01:36
aditeyabaral and others added 4 commits September 12, 2026 20:39
Replaces /metrics + /metrics.json with a single path selecting the
representation by query parameter: `?fmt=prometheus` (the default) or
`?fmt=json`.

`fmt` is a StrEnum, so FastAPI validates it, Swagger renders a dropdown, and an
unrecognised value goes through the existing validation handler -- a 400 with
the usual body, which is itself counted like any other failed request rather
than being a special case.

The default stays Prometheus: a scraper pointed at this path with no query
string must get the exposition format.

`response_model` is None because the response type depends on the parameter and
cannot be declared once. Both shapes are documented under `responses=` instead,
which is what Swagger renders from anyway, so the endpoint documents itself as
well as the two-path version did.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU7YUhP71KSsotWQ1H7CRH
Audited each request, response, error and background task, and closed the gaps.
The set went from 9 metric families to 23.

**The upstream was entirely uninstrumented, which was the biggest hole.** PESU
Academy is the only dependency this service has and the only thing that can be
slow or down, yet nothing measured it. Every call now goes through one helper
that records count, outcome, latency and the upstream status code, labelled by
operation: `csrf_fetch`, `login`, `profile_fetch`. When a request is slow, this
is what says whether it is us or them. It also distinguishes "the call failed"
from "the call succeeded and we could not parse what came back" -- a missing
CSRF tag counts as a successful 200, because it is our parsing that failed.

**Failures now say whose fault they are.** `failures_total{fault}` splits 4xx
from 5xx, so an alert can fire on "our fault" without enumerating statuses.

**Authentication says why it failed, not just that it did.**
`authentication_results_total{result}` records success, invalid_credentials,
csrf_token_error, profile_fetch_error, profile_parse_error and internal_error.
Keyed on the exception class, because CSRFTokenError and ProfileFetchError are
both 502 and mean entirely different things.

**Validation errors say which field.** Bounded by a known-field set, since the
request body is caller-controlled and an open label would be a cardinality hole.

**Profile parsing says what broke**: key_missing, value_missing, unknown_field,
page_structure, no_data, unknown_campus_code. The last one previously only
emitted a warning and raised nothing -- an unknown campus code means the PRN
format changed, which nothing else would have surfaced.

**The internal machinery is visible**: CSRF cache hit/miss (which is the whole
point of the prefetch, and previously invisible), prefetch task outcomes,
background refresh outcomes, and client lifecycle events where created minus
closed is what is still open -- the leak indicator for the bug class this
module spent a release learning to avoid.

Plus `requests_in_flight`, which explains the one gap in the accounting:
total exceeds success + failed by exactly what is still being served.

PESUAcademy now takes a collector, defaulting to a private one so a bare
PESUAcademy() still works. The singleton is still created only in app/app.py.

202 tests, 100% coverage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU7YUhP71KSsotWQ1H7CRH
Found by the metrics added in the previous commit. An idle process, seconds
after boot, reported two `csrf_fetch` calls and two clients created with one
already closed:

    upstream.csrf_fetch.success  2
    httpClients                  {created: 2, closed: 1}

`lifespan` prefetches a client and caches it, then starts the refresh loop --
which refreshed *immediately* on its first iteration, fetching a second token
and discarding the one just prefetched. Every startup paid an extra upstream
round trip for a client it threw away, and on Render, which restarts often,
that is every restart.

The loop now sleeps before its first refresh, which is all it was ever meant to
do: lifespan primes the cache, the loop keeps it fresh afterwards. One fetch,
one client, none discarded.

The same class of waste as the duplicate prefetch fixed in pesu-dev#156, and invisible
for the same reason -- nothing counted the upstream calls. It took about two
minutes for the new metrics to surface it, which is a fair argument for them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU7YUhP71KSsotWQ1H7CRH
README gains a table of what is measured, grouped by area, and a note on the
two most useful entries: `upstream`, because it is the only dependency this
service has and its latency is measured separately from the API's own, so a
slow request can be attributed rather than guessed at; and `httpClients`,
where created minus closed is the leak indicator and should sit at one at rest.

Minor: new functionality, existing APIs unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU7YUhP71KSsotWQ1H7CRH
@aditeyabaral aditeyabaral changed the title feat: metrics endpoints, benchmark output paths, and a benchmark import fix feat: /metrics with full app instrumentation, benchmark output paths, and two bug fixes Sep 13, 2026
## Defects

**`requests_in_flight` leaked on every client disconnect.** The decrement sat
in the two branches of the middleware rather than in a `finally`, and
`except Exception` does not catch `CancelledError` -- so an abandoned request
incremented the gauge and never decremented it. On a real server it would have
climbed forever, and it is the number documented as explaining the gap between
`total` and `success + failed`, so it would have actively misled. The
cancellation test asserted total, success and failed but not the gauge, which is
why it passed; it now asserts the gauge and fails against the old code.

**A label could shadow a positional parameter.** `increment(family, value=...)`
and `observe(family, seconds=...)` took their amount as an ordinary parameter,
so a family declaring a label named `value` or `seconds` would have had it
silently captured as the amount. Both are positional-only now, which
immediately caught a test relying on exactly that ambiguity.

**A cancelled upstream call was counted as an upstream error.** A disconnect or
a shutdown is not PESU Academy failing. It has its own outcome now, so the error
rate does not spike on every deploy -- precisely when someone is looking.

Also closes the last uninstrumented branch: `profile_field_filtering_total`
records whether a caller's field list actually narrowed the response, measured
at the branch rather than from the request body, so a caller passing exactly the
default list counts as no filtering.

`app/metrics/__init__.py` drops its re-export list, which had to be edited every
time a family was added; modules are imported directly, as `app.exceptions`
already does.

## Documentation

**Swagger documented a response that cannot happen.** FastAPI adds a 422
carrying its own `HTTPValidationError` body to every route whose parameters can
fail validation -- but this API converts every `RequestValidationError` into a
**400** with the same `{status, message, timestamp}` body as every other error.
`/metrics` advertised a status it never returns, in a shape it never emits. The
schema is now built through an override that drops those, matched on their
schema so `/authenticate`'s real 422 (a profile parse failure, using this API's
own response model) is kept. `HTTPValidationError` and `ValidationError` go with
them.

Every response on every route now carries both an example and a schema, which
the `/readme` 308 and the `/metrics` text/plain body previously lacked. The
`/metrics` examples were hand-written and had drifted; both are generated from a
real snapshot now, so the JSON example is complete and round-trips through
`MetricsModel`.

`tests/unit/test_openapi_docs.py` makes the documentation self-checking: every
route documents a success and a 500, every response has an example and a schema,
every JSON example validates against the model it claims, the request examples
cover all three username forms plus profile and field filtering, and the
documented 400, 401 and 200 bodies are compared against real responses. It found
both defects above. Validation runs in JSON mode rather than Python mode
deliberately -- the models are strict, and strict Python-mode rejects the ISO
*string* these responses carry in `timestamp`; JSON mode is the mode a caller
parsing the body is in.

The two test-only exception routes are now `include_in_schema=False`; they were
appearing in the published schema whenever their module was imported.

**README** gains a full metrics reference: how collection works and which of the
three layers records what, the two accounting identities that hold at all times,
the definitions that are easy to assume wrongly (latency is time to response
*start*; success is below 400, not 300; summaries expose sum and count, not
quantiles; scrapes count themselves), a table explaining every metric, and
complete generated examples of both formats.

232 tests, 100% coverage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU7YUhP71KSsotWQ1H7CRH
@aditeyabaral
aditeyabaral force-pushed the feat/metrics-and-benchmark-outputs branch from 0e89a53 to 25a7a9a Compare September 13, 2026 02:16
@aditeyabaral aditeyabaral changed the title feat: /metrics with full app instrumentation, benchmark output paths, and two bug fixes feat: /metrics with full app instrumentation, complete API docs, benchmark output paths, and three bug fixes Sep 13, 2026
aditeyabaral and others added 2 commits September 12, 2026 21:28
`timestamp` is declared `datetime` under a model-wide `strict=True`, but the API
serializes an ISO string onto the wire. So the published model could not
validate a real response: a client holding a decoded dict -- which is what every
HTTP library hands back -- got a ValidationError from the schema the API
publishes for exactly that purpose.

It also made the documentation tests weaker than they looked. They validated
examples in JSON mode, where pydantic accepts a string for a datetime because
JSON has no datetime type. That passed, but it was working around the problem
rather than finding it.

`strict=False` on that one field accepts both the datetime the API builds with
and the ISO string it returns. Every other field stays strict, which a test now
pins.

The documentation tests assert **both** modes rather than whichever passes, and
a new test round-trips a real `/health` response through the model both ways.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU7YUhP71KSsotWQ1H7CRH
…n twice

`authentication_results_total` carried six values -- `success`,
`internal_error`, and one per failure class -- four of which restated what
`errors_total{type}` already recorded about the same event.

Three of those four were not even a different vocabulary, just the class name in
snake_case: `CSRFTokenError` -> `csrf_token_error`, `ProfileFetchError` ->
`profile_fetch_error`, `ProfileParseError` -> `profile_parse_error`. Only
`AuthenticationError` -> `invalid_credentials` said anything the class name did
not, and the audience for these metrics knows the class names.

Worse than redundant, it was a drift risk. The mapping was a hand-written dict
read through `.get(type(exc), "other")`, so adding a fifth exception class and
forgetting the entry would have left two counters disagreeing about one event --
with the less informative one failing silently, which is the failure mode this
PR removes everywhere else.

Now `success` or `failure`, and the reason lives in exactly one place. The
family survives at all because it answers the one question nothing else can: the
login success rate, with success and failure in one family sharing a
denominator. Computing that from `errorsByType` would mean subtracting several
error classes from a different family -- the fragile cross-family arithmetic
this avoids. A test pins `sum(authenticationResults) == authentication.total`.

`internal_error` goes with it: a non-PESUAcademyError escaping `authenticate()`
is now `failure`, and `errors_total` still names the class, so nothing became
unobservable.

app/app.py loses the mapping dict and four imports that existed only to feed it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU7YUhP71KSsotWQ1H7CRH
@aditeyabaral aditeyabaral changed the title feat: /metrics with full app instrumentation, complete API docs, benchmark output paths, and three bug fixes feat: /metrics with full app instrumentation, complete API docs, benchmark output paths, and five bug fixes Sep 13, 2026
…diff

**The version bumped twice in one pull request.** 4.1.0 -> 4.2.0 -> 4.3.0,
because the second batch of work read as another feature. One merge to dev is
one bump, so this is 4.2.0; 4.3.0 would have skipped a version that never
reaches dev.

The guidance in `check_version_bump.py` is what led there. It described picking
a level by change type -- "minor: new functionality", "patch: a bug fix" --
which invites exactly that reading when a PR contains several kinds of change.
It now states the rule in force: raise the minor by one, once per pull request,
with major reserved for a backwards-incompatible change.

**The OpenAPI override restated FastAPI's argument list.** It called
`get_openapi()` with five arguments where FastAPI passes fourteen. Every one
missing is None or a default today, so nothing was visibly wrong -- but setting
`servers=` or `license_info=` on the app later would have silently vanished from
the schema. It now captures and delegates to FastAPI's own builder, so it
inherits whatever that grows.

**`_upstream_call` recorded in three places.** Latency and outcome were written
once per branch. A `finally` with a pessimistic default makes "every call is
counted and timed exactly once" structural rather than three copies that have to
stay in step -- and anything escaping without setting the outcome is an error,
which is the right thing to fail to.

**A test still used a label value the app can no longer emit**
(`result="invalid_credentials"`). It passed because the collector validates label
names, not values, so it was quietly asserting a behaviour that no longer exists.

Also guards `FAMILIES` against the drift that made the last two findings
possible: a family defined but left out of the registry would be collected into
and never exposed, silently. Tests now assert the registry matches the module,
that names are unique and valid Prometheus identifiers, that no counter name
collides with a summary's `_sum`/`_count` series, and that every HELP line fits
the exposition example's line limit.

240 tests, 100% coverage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU7YUhP71KSsotWQ1H7CRH
@aditeyabaral aditeyabaral changed the title feat: /metrics with full app instrumentation, complete API docs, benchmark output paths, and five bug fixes feat: /metrics with full app instrumentation, complete API docs, benchmark output paths, and six bug fixes Sep 13, 2026
@aditeyabaral
aditeyabaral merged commit a4d72e9 into pesu-dev:dev Sep 13, 2026
5 checks passed
aditeyabaral added a commit to aditeyabaral/auth that referenced this pull request Sep 13, 2026
…etrics

The benchmark exists to measure authentication, so `--route metrics` is removed from its choices.
It was added in pesu-dev#157 and should not have been: the script has no way to present a metrics token, so
against a protected deployment it measured 401s and reported them as failed requests. `/readme` is
now the only non-JSON route, so the comment justifying that fallback in util.py says so.

The README's scraping section carried a click-path through a third-party console ("Connections →
Metrics Endpoint → ...") which will rot the moment that product's UI changes, and belongs in
whoever-runs-it's notes rather than this repo. The generic Prometheus `scrape_config` stays, since
that is the format the endpoint actually serves.

It also restated two things already documented under "How collection works" four hundred lines
above -- that counters reset on restart, and that a scrape counts itself. One sentence there said
the same thing twice within itself, and is trimmed to the half that carries meaning.

A code comment in the collector explained seeding by naming a specific dashboard product. The
reason holds for any consumer, so it no longer names one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU7YUhP71KSsotWQ1H7CRH
aditeyabaral added a commit that referenced this pull request Sep 13, 2026
* feat: allow /metrics to require a bearer token

`/metrics` publishes request volumes, error counts, login success ratios, upstream PESU latency
and restart times to anyone who asks. It is also unscrapeable by Grafana Cloud's agentless Metrics
Endpoint integration, which refuses a target that is not behind authentication -- and that
integration is what avoids hosting a collector.

Set `METRICS_TOKEN` and the endpoint requires that bearer token, answering 401 with
`WWW-Authenticate: Bearer` otherwise. Leave it unset and behaviour is byte-identical to before, so
nothing breaks on merge. Both `fmt` values are covered, so the format selector is not a way around
it. This is the first environment variable the app reads: config is CLI-only today and the
Dockerfile's CMD is fixed, so an env var is the only channel the host can reach.

`HTTPBearer(auto_error=False)` never raises on its own -- every failure path returns None -- so the
401 is raised as a `PESUAcademyError` subclass and comes out in this API's `{status, message,
timestamp}` shape and in `errors_total{type="MetricsAuthorizationError"}`. `HTTPBasic` could not be
used the same way: it raises `HTTPException` on a malformed credential regardless of `auto_error`,
which would answer in Starlette's `{"detail": ...}` shape and bypass the error metric entirely.

`PESUAcademyError` gains an optional `headers`, which is None for every existing error, so the 401
can carry `WWW-Authenticate` without a second handler.

The OpenAPI override is untouched: FastAPI emits the `MetricsToken` scheme and the operation's
security requirement from the dependency, and adds no phantom 422, so the existing stripper keeps
working. Verified against the installed FastAPI rather than assumed.

20 new tests, 11 of which fail if enforcement is removed. 260 tests, 100% coverage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU7YUhP71KSsotWQ1H7CRH

* docs: document METRICS_TOKEN and how to scrape /metrics

Two subsections under `/metrics`: how to turn the token on and what a rejection looks like, and how
to point a scraper at the endpoint -- a Prometheus `scrape_config`, plus Grafana Cloud's
collector-free Metrics Endpoint integration, which is the reason the token exists.

Also states the two things that surprise people when they first graph this: counters are
per-process and reset on restart, and a scrape is itself a request, so it appears in the metrics it
collects.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU7YUhP71KSsotWQ1H7CRH

* chore: bump version to 4.3.0

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU7YUhP71KSsotWQ1H7CRH

* fix: compare the metrics token as bytes, and pin it in the test suite

Two defects found reviewing the commit before this one.

**`secrets.compare_digest` raises TypeError on a str holding any non-ASCII character.** So
`Authorization: Bearer ü` -- three bytes any caller can send, and curl sends them happily -- turned
the 401 into a **500** with a logged traceback, counted in `failures_total{fault="server"}`, which
is the one metric worth alerting on. A passing scanner could have tripped a server-fault alert.

Worse: a non-ASCII `METRICS_TOKEN` made *every* request 500, the correct one included, leaving the
endpoint unreachable with nothing but a traceback to explain why -- and the README tells operators
to pick a token.

Now compared as bytes, which has no ASCII restriction. The two codecs are not interchangeable: a
header value arrives already latin-1 decoded, so encoding it back that way recovers the exact
bytes the client sent, while the configured token comes from the environment as UTF-8 with
surrogates standing in for any invalid byte sequence. Encoding each back the way it arrived makes
the comparison byte-exact, so a non-ASCII token now works rather than silently never matching.

Ten new tests cover it, all of which fail against the str comparison. They pass header values as
**bytes**: httpx refuses to encode a non-ASCII str header, so a str would fail in the client and
never reach the app, which is why the unit tests missed this entirely and only curl found it. One
test drops below the HTTP layer to call the dependency directly, including with a lone surrogate --
the case that rules out encoding UTF-8 on the presented side.

**The suite was not hermetic, and a test of mine was hiding it.** With `METRICS_TOKEN` exported,
27 tests across test_metrics_endpoints.py and test_openapi_docs.py failed with 401s that had
nothing to do with what they assert -- the trap flagged in the plan, which I then only guarded in
my own new file. The suite still looked green, because
`test_an_empty_environment_variable_counts_as_unset` reloaded the module and left METRICS_TOKEN as
None for every test that followed it. Green for the wrong reason is worse than red.

An autouse fixture in tests/conftest.py now pins it to None for the whole suite, and the reload
test is replaced by one that exercises a small `_configured_token()` reader instead of mutating
module state. Verified by running the suite with the variable exported, with and without this file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU7YUhP71KSsotWQ1H7CRH

* docs: surface the token from the /metrics intro

The token was documented two screens below a long sample payload, where a reader scanning the
endpoint would not find it, so the intro now points at it. The generation example also handed the
token straight to `docker run` through a subshell, which meant never seeing the value the scraper
needs.

Also stops the app/metrics package docstring enumerating the modules in it: it named two of four,
having already fallen out of date once before this change added a third.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU7YUhP71KSsotWQ1H7CRH

* docs: drop vendor specifics and duplication, and stop benchmarking /metrics

The benchmark exists to measure authentication, so `--route metrics` is removed from its choices.
It was added in #157 and should not have been: the script has no way to present a metrics token, so
against a protected deployment it measured 401s and reported them as failed requests. `/readme` is
now the only non-JSON route, so the comment justifying that fallback in util.py says so.

The README's scraping section carried a click-path through a third-party console ("Connections →
Metrics Endpoint → ...") which will rot the moment that product's UI changes, and belongs in
whoever-runs-it's notes rather than this repo. The generic Prometheus `scrape_config` stays, since
that is the format the endpoint actually serves.

It also restated two things already documented under "How collection works" four hundred lines
above -- that counters reset on restart, and that a scrape counts itself. One sentence there said
the same thing twice within itself, and is trimmed to the half that carries meaning.

A code comment in the collector explained seeding by naming a specific dashboard product. The
reason holds for any consumer, so it no longer names one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU7YUhP71KSsotWQ1H7CRH

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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.

1 participant