Skip to content

feat: allow /metrics to require a bearer token - #158

Merged
aditeyabaral merged 6 commits into
pesu-dev:devfrom
aditeyabaral:feat/metrics-bearer-token
Sep 13, 2026
Merged

aditeyabaral merged 6 commits into
pesu-dev:devfrom
aditeyabaral:feat/metrics-bearer-token

Conversation

@aditeyabaral

@aditeyabaral aditeyabaral commented Sep 13, 2026

Copy link
Copy Markdown
Member

📌 Description

Makes /metrics able to require a bearer token, controlled by a METRICS_TOKEN environment
variable. Unset, behaviour is byte-identical to today; set, the endpoint answers 401 unless
the request carries that token.

  • Purpose: unblock hosted metrics scraping without running any infrastructure. A hosted scraper
    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
    /metrics already serves the Prometheus exposition format by default.
  • Problem it solves: /metrics shipped open in v4.2.0, publishing request volumes, error
    counts, login success ratios, upstream PESU Academy latency and process restart times to anyone
    who asks. Worth closing on its own merits.
  • Context: both prod and staging now serve v4.2.0 with /metrics live (48 and 60 series), so
    both become scrapeable once a token exists.
METRICS_TOKEN Request Result
unset anything 200 — unchanged from today
set Authorization: Bearer <token> 200
set no header, wrong scheme, or wrong token 401, this API's {status, message, timestamp} body, plus WWW-Authenticate: Bearer

Both fmt values are covered, so the format selector is not a way around it. No other route is
touched — /health stays open, because Render's own health check and the four cron-job.org
monitors send no credentials.

ℹ️ Fixes / Related Issues
Related: #130 — closed with a Grafana Cloud dashboard reading /metrics?fmt=json. This is the
prerequisite for giving that dashboard real history via a scrape job.

🧱 Type of Change

  • 🐛 Bug fix – Non-breaking fix for a functional/logic error
  • ✨ New feature – Adds functionality without breaking existing APIs
  • ⚠️ Breaking change – Introduces backward-incompatible changes (API, schema, etc.)
  • 📝 Documentation update – README, docstrings, OpenAPI tags, etc.
  • 🧪 Test suite change – Adds/updates unit, functional, or integration tests
  • ⚙️ CI/CD pipeline update – Modifies GitHub Actions, pre-commit, or Docker build
  • 🧹 Code quality / Refactor – Improves structure, readability, or style (no functional changes)
  • 🐢 Performance improvement – Speeds up auth, scraping, or reduces I/O
  • 🕵️ Debug/logging enhancement – Adds or improves logging/debug support
  • 🔧 Developer tooling – Scripts, benchmarks, local testing improvements
  • 🔒 Security fix – Addresses auth/session/data validation vulnerabilities
  • 🧰 Dependency update – Updates libraries in requirements.txt, pyproject.toml

Not a breaking change: the token is off unless the variable is set, so every existing caller
keeps working untouched. No dependencies addedHTTPBearer and secrets are already
available.

🧪 How Has This Been Tested?

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

276 tests, 100.00% coverage (was 260). 33 new in tests/unit/test_metrics_auth.py, 3 added to
test_openapi_docs.py. pre-commit run --all-files passes 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, /health is
unaffected, WWW-Authenticate is present, the 401 is attributed to GET /metrics with route
latency recorded, both metric accounting identities hold, and no server-fault errors are
logged
.

⚙️ Test Configuration:

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

✅ 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 (if applicable)
  • Feature flags or .env vars updated (if applicable)
  • I've tested across multiple environments (if applicable)
  • Benchmarks still meet expected performance (scripts/benchmark/benchmark_requests.py)

Three notes on that list:

  • METRICS_TOKEN is documented in the README, deliberately not in .env.example. That file
    holds the TEST_* values the suite consumes, and .env is loaded by tests/conftest.py — never
    by 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.
  • Environments: local uv venv and a Docker container. Staging and prod are post-merge steps —
    see Additional Notes for the order they need to happen in.
  • Benchmarks: --route health --num-requests 30 against a local server, 30/30 successful at
    ~1 ms per request. --route metrics is removed from the script's choices in this PR — see
    Additional Notes.

🛠️ Affected API Behaviour

  • app/app.py – Modified /authenticate route logic
  • app/pesu.py – Updated scraping or authentication handling

Two changes in app/app.py, neither of them to /authenticate's own logic: the /metrics route
gains the dependency, and pesu_exception_handler now passes exc.headers into its
JSONResponse so a 401 can carry WWW-Authenticate. That handler is shared, so /authenticate's
error path is on the diff — it is None for every existing error, and there is a regression test
asserting an AuthenticationError still comes back with no stray headers.

New files: app/metrics/auth.py (the dependency) and app/exceptions/metrics.py
(MetricsAuthorizationError). app/exceptions/base.py gains an optional headers argument.

🧩 Models

  • app/models/request.py – Input validation or request schema changes
  • app/models/response.py – Authentication response formatting
  • app/models/profile.py – Profile extraction logic

No model changes. app/docs/metrics.py documents the new 401.

🐳 DevOps & Config

  • Dockerfile – Changes to base image or build process
  • .github/workflows/*.yaml – CI/CD pipeline or deployment updates
  • pyproject.toml / requirements.txt – Dependency version changes
  • .pre-commit-config.yaml – Linting or formatting hook changes

pyproject.toml carries the project version only, 4.2.0 → 4.3.0, plus the matching uv.lock.
No dependency versions change and nothing is added.

📊 Benchmarks & Analysis

  • scripts/benchmark/benchmark_requests.py – Performance or latency measurement changes
  • scripts/benchmark/analyze_benchmark.py – Benchmark result analysis changes
  • scripts/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:

$ docker run -d -p 5082:5000 -e METRICS_TOKEN=dockertoken pesu-auth
$ curl -s -o /dev/null -w '%{http_code}\n' localhost:5082/metrics
401
$ curl -s localhost:5082/metrics
{"status":false,"message":"Invalid or missing metrics token.","timestamp":"2026-09-13T20:15:31+05:30"}
$ curl -sD - -o /dev/null localhost:5082/metrics | grep -i www-authenticate
www-authenticate: Bearer
$ curl -s -o /dev/null -w '%{http_code}\n' -H 'Authorization: Bearer dockertoken' localhost:5082/metrics
200
$ curl -s -o /dev/null -w '%{http_code}\n' localhost:5082/health
200

And with no variable set, which is what every existing deployment is:

$ docker run -d -p 5081:5000 pesu-auth
$ curl -s -o /dev/null -w '%{http_code}\n' localhost:5081/metrics
200

Swagger gains an Authorize button, and /metrics carries security: [{"MetricsToken": []}].

🧠 Additional Notes

Two bugs found in self-review, after the first commit

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"}, the
one metric worth alerting on. Worse, a non-ASCII METRICS_TOKEN made every request 500, the
correct 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_TOKEN
exported, 27 tests across test_metrics_endpoints.py and test_openapi_docs.py failed with 401s
unrelated to what they assert. The suite still looked green, because a test of mine reloaded the
module and left the token as None for everything that ran after it. An autouse fixture in
tests/conftest.py now pins it for the whole suite, and that test was replaced by one exercising
a small reader function instead of mutating module state.

Design decisions worth a reviewer's attention

Why HTTPBearer(auto_error=False) and not HTTPBasic. With auto_error=False, HTTPBearer
returns None on every failure path and never raises, so the 401 is raised by our own code as a
PESUAcademyError subclass — which keeps it in this API's error shape and counts it in
errors_total{type="MetricsAuthorizationError"}. HTTPBasic cannot be used that way: it raises
HTTPException on a malformed base64 credential regardless of auto_error, which would answer
in Starlette's {"detail": ...} shape and bypass the error metric entirely. Bearer is also what
Grafana'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.MetricsToken and
the 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_errors keeps working and /authenticate's genuine 422
survives.

First environment variable the app reads. Config is CLI-only today (--host, --port,
--debug) and the Dockerfile's CMD is fixed, so an env var is the only channel the host can
reach. 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 metrics is removed from benchmark_requests.py. It was added in #157 and should not have
been: 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. /readme is
now the only non-JSON route, so the comment justifying that fallback in util.py says so.

Before setting the variable on any environment

The existing Grafana dashboard reads /metrics?fmt=json through the Infinity datasource with no
auth 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

aditeyabaral and others added 3 commits September 13, 2026 09:46
`/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
@aditeyabaral
aditeyabaral requested a review from a team as a code owner September 13, 2026 14:47
aditeyabaral and others added 3 commits September 13, 2026 10:24
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
@aditeyabaral
aditeyabaral merged commit e741e04 into pesu-dev:dev Sep 13, 2026
5 checks passed
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