feat: allow /metrics to require a bearer token - #158
Merged
aditeyabaral merged 6 commits intoSep 13, 2026
Merged
Conversation
`/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
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
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VU7YUhP71KSsotWQ1H7CRH
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
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
…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
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
Makes
/metricsable to require a bearer token, controlled by aMETRICS_TOKENenvironmentvariable. Unset, behaviour is byte-identical to today; set, the endpoint answers
401unlessthe request carries that token.
that pulls a public Prometheus endpoint directly, with no collector to run anywhere, refuses a
target that is not behind authentication. A token is the entire cost of that route, and
/metricsalready serves the Prometheus exposition format by default./metricsshipped open in v4.2.0, publishing request volumes, errorcounts, login success ratios, upstream PESU Academy latency and process restart times to anyone
who asks. Worth closing on its own merits.
/metricslive (48 and 60 series), soboth become scrapeable once a token exists.
METRICS_TOKEN200— unchanged from todayAuthorization: Bearer <token>200401, this API's{status, message, timestamp}body, plusWWW-Authenticate: BearerBoth
fmtvalues are covered, so the format selector is not a way around it. No other route istouched —
/healthstays open, because Render's own health check and the four cron-job.orgmonitors send no credentials.
🧱 Type of Change
requirements.txt,pyproject.tomlNot a breaking change: the token is off unless the variable is set, so every existing caller
keeps working untouched. No dependencies added —
HTTPBearerandsecretsare alreadyavailable.
🧪 How Has This Been Tested?
tests/unit/)tests/functional/)tests/integration/)276 tests, 100.00% coverage (was 260). 33 new in
tests/unit/test_metrics_auth.py, 3 added totest_openapi_docs.py.pre-commit run --all-filespasses all 12 hooks.Both behaviours were checked by breaking them and re-running, so the tests are not vacuous:
11 fail if enforcement is removed, 10 fail against the string comparison described below.
Manual testing, in a container built from this branch and using the exact command the README
gives — every rejection path 401s, the correct token works in both formats,
/healthisunaffected,
WWW-Authenticateis present, the 401 is attributed toGET /metricswith routelatency recorded, both metric accounting identities hold, and no server-fault errors are
logged.
✅ Checklist
scripts/run_tests.py)pre-commit run --all-files).envvars updated (if applicable)scripts/benchmark/benchmark_requests.py)Three notes on that list:
METRICS_TOKENis documented in the README, deliberately not in.env.example. That fileholds the
TEST_*values the suite consumes, and.envis loaded bytests/conftest.py— neverby the app. Putting a server runtime variable there would imply that setting it protects a
locally-run server, which it would not. The README documents it where it actually applies, on
docker run -e.uvvenv and a Docker container. Staging and prod are post-merge steps —see Additional Notes for the order they need to happen in.
--route health --num-requests 30against a local server, 30/30 successful at~1 ms per request.
--route metricsis removed from the script's choices in this PR — seeAdditional Notes.
🛠️ Affected API Behaviour
app/app.py– Modified/authenticateroute logicapp/pesu.py– Updated scraping or authentication handlingTwo changes in
app/app.py, neither of them to/authenticate's own logic: the/metricsroutegains the dependency, and
pesu_exception_handlernow passesexc.headersinto itsJSONResponseso a 401 can carryWWW-Authenticate. That handler is shared, so/authenticate'serror path is on the diff — it is
Nonefor every existing error, and there is a regression testasserting an
AuthenticationErrorstill comes back with no stray headers.New files:
app/metrics/auth.py(the dependency) andapp/exceptions/metrics.py(
MetricsAuthorizationError).app/exceptions/base.pygains an optionalheadersargument.🧩 Models
app/models/request.py– Input validation or request schema changesapp/models/response.py– Authentication response formattingapp/models/profile.py– Profile extraction logicNo model changes.
app/docs/metrics.pydocuments the new401.🐳 DevOps & Config
Dockerfile– Changes to base image or build process.github/workflows/*.yaml– CI/CD pipeline or deployment updatespyproject.toml/requirements.txt– Dependency version changes.pre-commit-config.yaml– Linting or formatting hook changespyproject.tomlcarries the project version only, 4.2.0 → 4.3.0, plus the matchinguv.lock.No dependency versions change and nothing is added.
📊 Benchmarks & Analysis
scripts/benchmark/benchmark_requests.py– Performance or latency measurement changesscripts/benchmark/analyze_benchmark.py– Benchmark result analysis changesscripts/run_tests.py– Custom test runner logic or behavior updates📸 Screenshots / API Demos
From a container built on this branch, started exactly as the README documents:
And with no variable set, which is what every existing deployment is:
Swagger gains an Authorize button, and
/metricscarriessecurity: [{"MetricsToken": []}].🧠 Additional Notes
Two bugs found in self-review, after the first commit
secrets.compare_digestraisesTypeErroron a str holding any non-ASCII character. SoAuthorization: Bearer ü— three bytes any caller can send, and curl sends them happily — turnedthe 401 into a 500 with a logged traceback, counted in
failures_total{fault="server"}, theone metric worth alerting on. Worse, a non-ASCII
METRICS_TOKENmade every request 500, thecorrect one included, leaving the endpoint unreachable with only a traceback to explain why.
Now compared as bytes. The two codecs are deliberately different: a header value arrives already
latin-1 decoded, so encoding it back that way recovers the exact bytes the client sent, while the
token comes from the environment as UTF-8. Encoding each back the way it arrived makes the
comparison byte-exact, so a non-ASCII token now works rather than silently never matching.
Worth knowing why the unit tests missed it: httpx refuses to encode a non-ASCII str header, so
such a request fails inside the client and never reaches the app. Only curl found it. The
regression tests pass header values as bytes, and one 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 one of my own tests was hiding it. With
METRICS_TOKENexported, 27 tests across
test_metrics_endpoints.pyandtest_openapi_docs.pyfailed with 401sunrelated to what they assert. The suite still looked green, because a test of mine reloaded the
module and left the token as
Nonefor everything that ran after it. An autouse fixture intests/conftest.pynow pins it for the whole suite, and that test was replaced by one exercisinga small reader function instead of mutating module state.
Design decisions worth a reviewer's attention
Why
HTTPBearer(auto_error=False)and notHTTPBasic. Withauto_error=False,HTTPBearerreturns
Noneon every failure path and never raises, so the 401 is raised by our own code as aPESUAcademyErrorsubclass — which keeps it in this API's error shape and counts it inerrors_total{type="MetricsAuthorizationError"}.HTTPBasiccannot be used that way: it raisesHTTPExceptionon a malformed base64 credential regardless ofauto_error, which would answerin Starlette's
{"detail": ...}shape and bypass the error metric entirely. Bearer is also whatGrafana's scrape-job form offers. If a scraper ever needs Basic, parse the header by hand.
The OpenAPI override is untouched. FastAPI emits
components.securitySchemes.MetricsTokenandthe operation's security requirement from the dependency, and adds no phantom 422 — verified by
building a schema with this exact dependency shape before writing any of it, so
_openapi_without_phantom_validation_errorskeeps working and/authenticate's genuine 422survives.
First environment variable the app reads. Config is CLI-only today (
--host,--port,--debug) and the Dockerfile'sCMDis fixed, so an env var is the only channel the host canreach. Read once at import, so whether a process enforces a token is fixed for its lifetime —
changing it needs a restart, which on Render a variable change triggers anyway. A blank value
counts as unset.
The benchmark no longer targets
/metrics--route metricsis removed frombenchmark_requests.py. It was added in #157 and should not havebeen: that script exists to measure authentication, and it has no way to present a token, so
against a protected deployment it measured 401s and reported them as failed requests.
/readmeisnow the only non-JSON route, so the comment justifying that fallback in
util.pysays so.Before setting the variable on any environment
The existing Grafana dashboard reads
/metrics?fmt=jsonthrough the Infinity datasource with noauth headers, so those panels start returning 401 the moment the variable is set.
The obvious fix has a trap: do not put the token in a panel query. The public dashboard API
returns target configuration verbatim, so a token pasted into a query is readable by anyone holding
the dashboard link. It belongs on the Infinity datasource as a secure header.
Order: merge (nothing changes) → set the variable on staging → add the header to the Infinity
datasource → create the scrape job against staging → repeat for prod.
🤖 Generated with Claude Code
https://claude.ai/code/session_01VU7YUhP71KSsotWQ1H7CRH