feat: /metrics with full app instrumentation, complete API docs, benchmark output paths, and six bug fixes - #157
Merged
aditeyabaral merged 17 commits intoSep 13, 2026
Conversation
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
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
## 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
force-pushed
the
feat/metrics-and-benchmark-outputs
branch
from
September 13, 2026 02:16
0e89a53 to
25a7a9a
Compare
`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
…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
This was referenced Sep 13, 2026
Closed
Closed
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
42 tasks
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>
42 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
📌 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.devsince #156585c07ec30344b5e36a010c8aff5dc94f43/metrics, serving either format by query parameter3f23c1d,0ae0a0a--tag, and three adjacent bugsf9b708892db11e688eb0025a7a9a25a7a9aResponseModelcould not parse the responses it describes7a37ec937357a9776bad4Important
scripts/benchmark/util.pystill importedhttpx, which #156 removed. All three benchmark scripts have been dying at import ondevandmainsince that merge: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 overapp/,tests/,pyproject.toml,DockerfileandREADME.md, which simply omittedscripts/. Production was never affected, since the Dockerfile copies onlyapp/. 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
exceptsees handled errors. It does not, and the real layering cuts both ways.starlette/applications.pybuilds:PESUAcademyErrorbecomes a response below us, socall_nextreturns an ordinary 401 and theexceptbranch never fires.exceptbranch has to recordstatus=500itself, orrequests_totalsilently stops matchingsum(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:
CSRFTokenErrorandProfileFetchErrorare both502, and onlyerrors_totaltells them apart.Two invariants you can check on a running instance, and there is a test for each:
Other corrections to the pseudo-code
200 <= status < 300as success/readmereturns 308, so every readme hit counted as a failure.< 400.f"requests_failed_status_{code}"sum by (status)is impossible. Labels instead.request_latency_sumwith no countsummary.time.time()perf_counter().scope["route"]call_next, and reached viagetattr— plain Starlette routes (Swagger UI,/openapi.json) never set it.Latency is also named for what it measures:
call_nextreturns athttp.response.startand 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_clientinstalls 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,/.envand 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.
fmtis aStrEnum, 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
Acceptwas the alternative and is worse: real scrapers,curland 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_modelisNone, since the response type depends on the parameter and cannot be declared once; both shapes are documented underresponses=, 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]anddict[str, RouteMetricsModel]validate every value and generate correctadditionalProperties. Notestrict=Truerejects 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
/authenticatehandler. 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 fromroute_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.
csrf_fetch,login,profile_fetch— with count, outcome, latency and the upstream status codefailures_total{fault}splits 4xx from 5xx, so an alert can fire on only our own faultsauthentication_results_total{result}:successorfailure. The reason stays inerrors_total{type}— see the de-duplication note belowvalidation_errors_total{field}, bounded against a caller-controlled bodyprofile_parse_errors_total{reason}— six reasons, includingunknown_campus_code, which previously only logged a warning and raised nothing at allcsrf_cache_total{hit,miss}— the thing #156 was aboutcsrf_refreshes_total{outcome}(present in #132, and I had dropped it)prefetch_tasks_total{success,failure,cancelled}http_clients_total{created,closed,close_failed}— created minus closed is what is still openrequests_in_flightlifespan_events_totalThe 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:
lifespanprefetches 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
688eb00by 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_flightleaked on every client disconnect. The decrement sat in the two branches of the middleware rather than in afinally— andexcept Exceptiondoes not catchCancelledError, 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 betweentotalandsuccess + failed, it would have actively misled. The cancellation test asserted total, success and failed but not the gauge, which is exactly why it passed:A label could shadow a positional parameter.
increment(family, value=...)andobserve(family, seconds=...)took their amount as an ordinary parameter, so a family declaring a label namedvalueorsecondswould 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_totalrecords 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:
📖 Swagger and README
Swagger documented a response that cannot happen. FastAPI adds a
422carrying its ownHTTPValidationErrorbody to every route whose parameters can fail validation — but this API converts everyRequestValidationErrorinto a 400 with the same{status, message, timestamp}body as every other error./metricstherefore 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.HTTPValidationErrorandValidationErrorleave the components with them.Every response on every route now carries both an example and a schema — the
/readme308 and the/metricstext/plain body previously had neither. The/metricsexamples 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 throughMetricsModel.POST /authenticateGET /healthGET /metricsfmtparameter)GET /readmetests/unit/test_openapi_docs.pymakes 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_pathlives in the existingutil.pyrather 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,--tagand--outputall honoured and parent directories created.Three adjacent bugs fixed in the files being rewritten:
make_requestcrashed on non-JSON routes. It ended in an unconditionalresponse.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 --fileswas notrequired, so omitting it raised a bareTypeErrorfrom a list comprehension instead of an argparse error.unauthenticated_csrf_token_expiry.pylost 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
ResponseModelcould not parse the responses it describes.timestampis adatetimeunder a model-widestrict=True, but the API serializes an ISO string onto the wire — so a client holding a decoded dict (what every HTTP library returns) got aValidationErrorfrom 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=Falseon 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_totalcarried a value per failure class, duplicating whaterrors_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: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
successorfailurenow, 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 fromerrorsByTypewould 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
devis one bump, so this is 4.2.0; 4.3.0 would have skipped a version that never reachesdev.The guidance in
check_version_bump.pyis 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:
get_openapi()with five arguments where FastAPI passes fourteen. Every missing one is a default today, so nothing was visibly wrong — but settingservers=orlicense_info=on the app later would have silently vanished from the schema. It captures and delegates to FastAPI's own builder now._upstream_callrecorded in three places, once per branch. Afinallywith a pessimistic default makes "every call is counted and timed exactly once" structural instead of three copies that have to stay in step.result="invalid_credentials"). It passed because the collector validates label names, not values — so it was quietly asserting a behaviour that no longer exists.FAMILIESis 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
🧪 How Has This Been Tested?
tests/unit/)tests/integration/)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 throughServerErrorMiddleware, 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:
The
308counted as a success is the< 400correction 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-8confirmed 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-dirand explicit--outputall land where intended, and--route readmeand--route metricsboth succeed where they previously could not.✅ Checklist
scripts/run_tests.py)pre-commit run --all-files)🛠️ Affected API Behaviour
app/app.py– Metrics middleware, three one-line handler increments, the profile split, two new routesNew endpoint:
/metrics— Prometheus text by default, JSON with?fmt=json🧩 Models
app/models/metrics.py– New:MetricsModeland 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 requestREADME.md– full metrics reference: how collection works, what every metric means, generated examples of both formatsrequirements.txtis deliberately untouched: it is a staleuv pip compileartefact (still pinningfastapi==0.136.1against an installed 0.141.1) and the Dockerfile builds fromuv.lock --frozen. The Dockerfile copiesappwholesale, soapp/metrics/ships with no build change.📊 Benchmarks & Analysis
scripts/benchmark/util.py–resolve_output_path, plus the non-JSON fix and the httpx2 importscripts/benchmark/benchmark_requests.py,analyze_benchmark.py,unauthenticated_csrf_token_expiry.py🧠 Additional Notes
#130 (Grafana) is unblocked by this —
/metricsspeaks 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")meansBaseHTTPMiddleware, 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 offhttp.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.totalexceedssuccess + failedwhile 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