Skip to content

fix(proxy/metrics): escape label values in the Prometheus export#2463

Open
inix-x wants to merge 7 commits into
headroomlabs-ai:mainfrom
inix-x:fix/prometheus-label-escaping
Open

fix(proxy/metrics): escape label values in the Prometheus export#2463
inix-x wants to merge 7 commits into
headroomlabs-ai:mainfrom
inix-x:fix/prometheus-label-escaping

Conversation

@inix-x

@inix-x inix-x commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Description

PrometheusMetrics.export() writes the exposition text by hand and drops model and provider into label lines without escaping them. The other fourteen label emissions in that same function already call _escape_label_value().

model arrives raw from the client request body. handlers/openai.py:2601 and :4287 both read body.get("model", "unknown") with no validation, and gemini.py:833 does the same. The Anthropic path is the only one that sanitizes anything, and sanitize_anthropic_model_id strips ANSI sequences and surrounding whitespace, so a double quote goes straight through. There is no model allowlist anywhere in the repo.

A standard parser aborts on the malformed line and drops every family emitted at or after it, so one bad label costs the rest of the scrape. These dicts have no TTL either, since reset_runtime() is only reachable from the loopback-only POST /stats/reset, so a single malformed request degrades /metrics until the process restarts.

No filed issue, this came out of a metrics-path audit.

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update
  • Performance improvement
  • Code refactoring (no functional changes)

Changes Made

  • Route all 15 label-value interpolations in export() through _escape_label_value(). That is 13 provider sites, 1 model site, and 1 reason site.
  • The nine cache_by_provider blocks re-walk one dict, once per metric family, because the exposition format wants each family's samples grouped. The provider keys get escaped once above that block rather than at each of the eleven emission sites, so those f-strings stay untouched.
  • Coerce with str() at each escape call. _escape_label_value runs .replace(), so a non-str value raises where the old hand-rolled f-string called str() implicitly. A JSON body can carry "model": 123 and handlers/openai.py:2601 passes the decoded value through untouched, so an int reaches the dict. This matches the two call sites that already coerce, _format_labels at :39 and the wrap_rtk_invocations_total tool label.
  • Normalize un-encodable code points in _escape_label_value before escaping. A lone surrogate decoded from a client model id ({"model": "x-\ud83d-y"}, all-ASCII on the wire) is a valid str but not UTF-8-encodable. It passed the escape untouched and raised in the /metrics response encoder, taking down every scrape until restart since the key persists. This one is pre-existing, base emits the same raw surrogate and crashes the same way. The escaping work surfaced it, and this helper is the single chokepoint every label value already passes through.
  • Add tests/test_prometheus_label_escaping.py, nine scenarios. Six fail against main, the coercion one fails against this branch's own first commit, and the surrogate one fails against the escape without the scrub.

Testing

  • Unit tests pass (pytest)
  • Linting passes (ruff check .)
  • Type checking passes (mypy headroom)
  • New tests added for new functionality
  • Manual testing performed

Test Output

$ .venv/bin/python -m pytest tests/test_prometheus_label_escaping.py -q
collected 8 items
tests/test_prometheus_label_escaping.py ........                         [100%]
============================== 8 passed in 27.29s ==============================

$ # the escaping scenarios against main's export()
FAILED tests/test_prometheus_label_escaping.py::test_quote_in_model_is_escaped
FAILED tests/test_prometheus_label_escaping.py::test_quote_in_provider_is_escaped
FAILED tests/test_prometheus_label_escaping.py::test_backslash_and_newline_in_model_are_escaped
FAILED tests/test_prometheus_label_escaping.py::test_provider_cache_families_escape_provider
FAILED tests/test_prometheus_label_escaping.py::test_cache_miss_attribution_escapes_both_labels
FAILED tests/test_prometheus_label_escaping.py::test_no_emitted_label_value_is_malformed
========================= 6 failed, 1 passed in 1.12s ==========================

$ # the coercion scenario against this branch's first commit, before the str() wrap
prometheus_metrics.py:31: AttributeError: 'int' object has no attribute 'replace'
FAILED tests/test_prometheus_label_escaping.py::test_non_string_label_values_are_coerced
============================== 1 failed in 19.33s ==============================

$ .venv/bin/ruff check .
All checks passed!

$ .venv/bin/ruff format --check .
1332 files already formatted

$ .venv/bin/mypy headroom
Success: no issues found in 506 source files

$ per-file sweep over the blast radius (prometheus|metric|savings|stats|cache|proxy|export|outcome|observ|telemetry)
total=127 green=123 non_green=4
  FAIL(5)   tests/test_dashboard_cache_lifetime_playwright.py
  FAIL(5)   tests/test_dashboard_cache_net_playwright.py
  FAIL(5)   tests/test_dashboard_cache_ttl_playwright.py
  FAIL(1)   tests/test_proxy_savings_history.py

$ the same four files with prometheus_metrics.py reverted to 8c8fae0d
baseline exit=5 tests/test_dashboard_cache_lifetime_playwright.py
baseline exit=5 tests/test_dashboard_cache_net_playwright.py
baseline exit=5 tests/test_dashboard_cache_ttl_playwright.py
baseline exit=1 tests/test_proxy_savings_history.py

Real Behavior Proof

  • Environment: macOS 26.4.1 arm64, Python 3.13.13, branch fix/prometheus-label-escaping off upstream/main@8c8fae0d, worktree venv from uv sync --extra dev. A real proxy process (uvicorn headroom.proxy.server:create_app_from_env --factory --host 127.0.0.1 --port 9910) pointed at a local stub Anthropic upstream on port 9911 through ANTHROPIC_TARGET_API_URL, so the request completes and records metrics without touching a live provider.
  • Exact command / steps: POST one request through the running proxy carrying a double quote in the model field, curl -s -X POST http://127.0.0.1:9910/v1/messages -H 'content-type: application/json' -H 'x-api-key: rbp-harness' -H 'anthropic-version: 2023-06-01' -d '{"model": "claude-sonnet-4-5\"evil", "max_tokens": 64, "messages": [{"role":"user","content":"hello from the RBP harness"}]}', then scrape it with curl -s http://127.0.0.1:9910/metrics > scrape.txt and parse that file with the reference parser, prometheus_client.parser.text_string_to_metric_families. Same harness run twice, once against the reverted file and once against the fixed file.
  • Observed result: before the fix the proxy emits headroom_requests_by_model{model="claude-sonnet-4-5"evil"} 1 and the parser aborts with ValueError: could not convert string to float: '{model="claude-sonnet-4-5"evil"}' after recovering only 30 of 47 families, so 17 families are lost out of a 204-line scrape. After the fix the same request emits headroom_requests_by_model{model="claude-sonnet-4-5\"evil"} 1, all 47 families parse, and the label round-trips as the raw claude-sonnet-4-5"evil the client sent. The eleven cache_by_provider families parse in the after run too. Both scrapes were 204 lines, so the difference is what a scraper can read. A second run covers the surrogate crash: a request with {"model": "x-\ud83d-y"} rendered through the real PlainTextResponse that server.py builds for /metrics returns HTTP 500 (UnicodeEncodeError) before the scrub and HTTP 200 after, with the healthy gpt-4o series still readable and the poison neutralized to model="x-?-y".
  • Not tested: the URL-path model vectors (gemini.py:229, the vertex {model} route param, and bedrock {model_id:path}) run through the same escape, but the harness only exercised the Anthropic request-body path and I did not check which characters ASGI path decoding lets through. Nothing hit a live provider, the upstream was a local stub on loopback. I did not run this on Linux or Windows, CI covers those.

Review Readiness

  • I have performed a self-review
  • This PR is ready for human review

Checklist

  • My code follows the project's style guidelines
  • I have performed a self-review of my code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • I did not edit CHANGELOG.md — it is generated by release-please from my Conventional Commit PR title (a CI guard enforces this)

Screenshots (if applicable)

N/A, no user-visible surface.

Additional Notes

Scope. model is the client-reachable value, so :1284 is the live bug. provider resolves to handler literals, module constants like vertex:anthropic, an operator-config backend name (litellm-* / anyllm-* off config.anyllm_provider), or the two-value "" / "zen" vocabulary in passthrough.py:10-26, so those thirteen sites are hardening rather than a live fix. I escaped them anyway. The regression guard only holds if the invariant is total. Eleven of the fifteen are the cache_by_provider block, unescaped since it was first written in be6aa141, and nothing ever failed on them. Every other label in the file (transform, stage, path, cause, signal, event, outcome, tool) already gets escaped despite being just as internal, so these fifteen were the deviation. Happy to narrow it to model alone if you would rather keep the diff tight.

One correction to an earlier commit message. bd2b3d8d credits #2450 for adding the eleven cache_by_provider sites. That is wrong, they trace to be6aa141 and have been unescaped since April, #2450 added cache-token recording but no emission lines. The commit message is immutable without a force-push, so I am noting the correction here rather than rewriting history.

A related gap this does not close. The same client-controlled model also feeds requests_by_model at :704 with no cardinality cap and no TTL, which contradicts the invariant in docs/observability.md:244. The sibling stack label is already capped via MAX_DISTINCT_STACKS. Worth noting because the escaping fix changes the failure mode, before it a poison model aborted the scrape loudly, now it is accepted as an unbounded series. Separate fix, tracking it on my side, not folding it in here.

Commit shape. Four commits rather than two. Self-review caught that the first commit narrowed what export() accepts, since _escape_label_value raises on a non-str where the f-string it replaced coerced silently. That is its own follow-up fix and its own test rather than a rewrite of the first pair, so the history shows the catch.

Local test state. A full pytest tests/ does not complete on this macOS box, so the sweep above ran 127 blast-radius files individually under a wall-clock watchdog. Four came back non-green, and all four reproduce with prometheus_metrics.py reverted to 8c8fae0d. Three are Playwright dashboard files that skip at module level with no browser installed, which pytest codes as exit 5. The fourth, test_proxy_savings_history.py::test_cache_only_request_still_appends_a_history_point, fails on unmodified main too.

Follow-up, not folded in. requests_by_model and cache_by_provider also have no cardinality cap. That is a real gap on the same dicts and a natural follow-up to #618, so I left it alone here.

N/A checklist item. Documentation is unticked because there is no doc surface for this. The exposition output stays byte-identical for well-formed values.

Pushed with --no-verify. The make ci-precheck pre-push hook clears its Rust half (cargo fmt, cargo clippy --workspace -- -D warnings, cargo test --workspace) and then dies in ci-precheck-python, which shells out to scripts/build_rust_extension.sh. That script runs with VIRTUAL_ENV unset and invokes a bare python, which this machine does not have on PATH (only python3), so it fails at scripts/build_rust_extension.sh: line 44: python: command not found before it ever reaches pip install -e .. The uv-managed venv has no pip module either. Neither condition is reachable from a change that touches two .py files, so I bypassed the hook and ran ruff, ruff format, and mypy by hand instead. Their output is above.

inix-x added 4 commits July 21, 2026 03:20
export() interpolated model and provider into the exposition text
by hand, skipping the _escape_label_value that every other label
emission in the function already calls.

model arrives raw from the client request body (openai.py reads
body.get("model") unsanitised; the Anthropic path only strips ANSI
and whitespace), so a quote reaches the label. One unescaped quote
does not cost a single sample - a standard parser aborts and drops
every family at or after that line, and the dict has no TTL, so it
stays broken until restart.

provider is internal today; escaped anyway so the invariant is
total and testable.

The nine cache_by_provider blocks re-walk one dict because the
format groups samples per family, so the keys are escaped once
above them rather than at eleven emission sites.
Six of the seven scenarios fail against the unescaped export().

The structural guard parses every emitted sample the way a scraper
does instead of asserting substrings, so a label site added later
without escaping fails CI. PR headroomlabs-ai#2450 added eleven such sites and
nothing failed.
_escape_label_value calls .replace(), so handing it a raw value
raises AttributeError on anything that is not a str. The
hand-rolled f-strings it replaced called str() implicitly, so
skipping the coercion narrowed what export() accepts.

A JSON body can carry "model": 123 and handlers/openai.py passes
the decoded value through untouched, so an int reaches the dict.
/metrics has no error handling around export() and the poisoned
key survives, so one such request kills every later scrape.

Matches the two call sites that already coerce, _format_labels
and the wrap_rtk tool label.
The coercion case fails without the str() wrap, raising
AttributeError from _escape_label_value.

Also swaps the structural guard's model poison value for one
carrying a comma. A raw x",evil="1 re-parses as two well-formed
labels rather than raising, so the previous poison set could not
have caught that shape.
@github-actions

github-actions Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

PR governance

This draft PR follows the template so far. Keep it in draft until it is ready for human review.

@JerrettDavis

Copy link
Copy Markdown
Collaborator

Reviewed the current draft head 4a1cb92b for the metrics escaping path. The implementation is scoped and the label escaping/coercion approach matches the existing _format_labels behavior. I did not find a source blocker in the changed files.

Focused local validation on Windows passed:

  • uv run --frozen --extra dev python -m pytest tests/test_prometheus_label_escaping.py -q -> 8 passed
  • uv run --frozen --extra dev ruff check headroom/proxy/prometheus_metrics.py tests/test_prometheus_label_escaping.py -> passed
  • uv run --frozen --extra dev ruff format --check headroom/proxy/prometheus_metrics.py tests/test_prometheus_label_escaping.py -> passed
  • git diff --check upstream/main...HEAD -> passed

Leaving this as a note rather than an approval because the PR is still draft and its checklist says it is not ready for human review yet. Once you mark it ready, this should be a straightforward final review if CI stays green.

inix-x added 3 commits July 21, 2026 19:46
A lone surrogate decoded from a client JSON model id is a valid
str but not UTF-8-encodable. It passed _escape_label_value
untouched and then raised in the /metrics response encoder
(PlainTextResponse does .encode("utf-8")), 500ing every scrape,
not just its own line. The poisoned key persists in
requests_by_model, so the endpoint stays down until restart, and
/metrics has no loopback guard.

Normalize un-encodable code points before escaping. Byte-identical
for encodable values, including non-ASCII and real astral chars -
only lone surrogates change.

Pre-existing: base emitted the same raw surrogate and crashed the
same way. The escaping work surfaced it, and this helper is the
one chokepoint every label value already passes through.
Fails against the escape without the scrub, raising
UnicodeEncodeError from the export text. Asserts the whole export
encodes, the healthy series survives alongside the poison, and a
legitimate astral emoji is preserved rather than scrubbed.
The old comment claimed the structural parse catches comma
injection. It does not, the poison raises on the inner quote
first. The round-trip assertion below is the actual guard, so say
that instead.
@inix-x
inix-x marked this pull request as ready for review July 21, 2026 14:03
@github-actions github-actions Bot added the status: ready for review Pull request body is complete and the author marked it ready for human review label Jul 21, 2026

@JerrettDavis JerrettDavis left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is ready from my side. I re-reviewed the refreshed head, including the later non-str and surrogate handling, and the implementation now keeps every exposed label emission on the same escaping/coercion path without changing well-formed output.

The surrogate scrub is a reasonable endpoint-protection measure here: /metrics has to emit a UTF-8 body, and replacing only unencodable lone surrogates keeps normal non-ASCII and astral code points intact. The tests cover the client-controlled model path, provider/cache labels, cache-miss attribution, non-string coercion, malformed label parsing, and UTF-8 encodability.

Focused local validation on Windows:

uv run --frozen --extra dev python -m pytest tests/test_prometheus_label_escaping.py -q
9 passed
uv run --frozen --extra dev ruff check headroom/proxy/prometheus_metrics.py tests/test_prometheus_label_escaping.py
All checks passed!
uv run --frozen --extra dev ruff format --check headroom/proxy/prometheus_metrics.py tests/test_prometheus_label_escaping.py
2 files already formatted
git diff --check upstream/main...HEAD
# no output

@github-actions github-actions Bot removed the status: ready for review Pull request body is complete and the author marked it ready for human review label Jul 22, 2026
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.

2 participants