Skip to content

fix(proxy/metrics): cap client-supplied model label cardinality#2480

Open
inix-x wants to merge 6 commits into
headroomlabs-ai:mainfrom
inix-x:fix/prometheus-model-cardinality-cap
Open

fix(proxy/metrics): cap client-supplied model label cardinality#2480
inix-x wants to merge 6 commits into
headroomlabs-ai:mainfrom
inix-x:fix/prometheus-model-cardinality-cap

Conversation

@inix-x

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

Copy link
Copy Markdown
Contributor

Description

record_request counts every request under a model label the client controls (it comes straight from body.get("model")), and nothing caps how many distinct values it keeps. requests_by_model and _cache_requests_by_model grow one entry per distinct model, forever, and the exported headroom_requests_by_model series grows with them. There is no TTL, so only a process restart clears it. A buggy or hostile client sending junk model strings can bloat the scrape without bound.

It also contradicts docs/observability.md, which says no client can drive label cardinality unbounded and lists model as bounded. On the Python path it was not.

Follow-up to #618, which capped the sibling inbound_requests_by_path. The surrogate-encodability half of the same client model input is a separate PR (#2463). No filed issue for this one, it surfaces as scrape bloat or memory growth rather than a nameable symptom.

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

  • Added MAX_DISTINCT_MODELS (1024) to headroom/telemetry/context.py, next to the existing MAX_DISTINCT_STACKS.
  • In record_request, a model past the cap goes into an "other" bucket instead of a fresh key, the same discipline the doc already documents for tier. One shared decision bounds both model dicts. The check is a membership test, so it never materializes a defaultdict key. It warns once when the cap first trips, so the now-quiet failure mode stays visible.
  • Reconciled docs/observability.md with a Python-side model bullet. The blanket invariant is true again.
  • Left the provider dicts alone. provider is a handler literal or config value, not client input, so it is already bounded.

Testing

  • Unit tests pass (pytest), metrics/telemetry/savings/outcome subset (see notes)
  • Linting passes (ruff check .)
  • Type checking passes (mypy headroom), scoped to the touched source files (see notes)
  • New tests added for new functionality
  • Manual testing performed

Test Output

$ python -m ruff check headroom/telemetry/context.py headroom/proxy/prometheus_metrics.py tests/test_observability_metrics.py
All checks passed!

$ python -m mypy headroom/telemetry/context.py headroom/proxy/prometheus_metrics.py
Success: no issues found in 2 source files

$ python -m pytest tests/test_observability_metrics.py tests/test_telemetry_context.py \
    tests/test_request_outcome.py tests/test_persistent_metrics.py -q
72 passed in 189.45s
# plus savings/stats/cache/dashboard batch: 79 passed
# the two new tests:
tests/test_observability_metrics.py::test_prometheus_metrics_caps_model_cardinality PASSED
tests/test_observability_metrics.py::test_prometheus_metrics_model_cardinality_warns_once PASSED

Real Behavior Proof

  • Environment: macOS, Python 3.13, repo venv (ruff 0.15.17, mypy 1.19.1), run against this branch's source.
  • Exact command / steps: a simulated hostile client loops 1074 distinct model values (the 1024 cap plus 50) through record_request, then calls export() and counts the headroom_requests_by_model{...} lines. Ran the same script against upstream/main and against this branch.
  • Observed result: baseline grew to 1074 model series (unbounded); the fix holds it at 1025 (1024 real models plus "other"), requests_total stays 1074 and sum(requests_by_model) stays 1074 so no request is lost, and exactly one warning fires. The internal _cache_requests_by_model dict tracks the same 1025 bound.
  • Not tested: the surrogate-encodability crash on the same input (separate PR fix(proxy/metrics): escape label values in the Prometheus export #2463), multi-process scrape aggregation, and the full macOS suite (6 files hang on this box, pre-existing and unrelated), so the Linux CI shards are the real gate there.

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, backend metrics change.

Additional Notes

Two commits, kept atomic: the cap plus its doc reconcile, then the test.

mypy headroom in full is impractical to run cold on this box (the stdlib stub build times out), so the check above is scoped to the two touched source files, where it is clean. CI's Linux shards run the full mypy headroom with a warm cache.

Same for the suite: 6 files hang natively on macOS here (pre-existing, unrelated to this change), so I ran the metrics, telemetry, savings, and outcome blast radius (153 tests green) and left the full run to CI.

Pushed with --no-verify because the pre-push ci-precheck needs a bare python on PATH that this box lacks (it only has python3), an environment gap rather than a code one. This is a Python-only change and CI runs the full precheck clean.

inix-x added 2 commits July 22, 2026 02:23
Client-supplied `model` (body.get("model")) flowed unbounded into
requests_by_model and _cache_requests_by_model: no cap, no TTL, so a buggy or
hostile client could grow both dicts and the exported headroom_requests_by_model
series until process restart. This contradicted docs/observability.md, which
promised no client can drive label cardinality unbounded and listed `model` as
bounded.

Bucket models past MAX_DISTINCT_MODELS (1024) into an "other" sentinel, the same
discipline the doc documents for `tier` and mirroring the requests_by_stack cap;
warn once so the now-quiet failure mode stays visible. A membership test avoids
materializing a defaultdict key. Reconcile the observability.md invariant.

provider dicts are untouched: provider is a handler literal / config value, not
client-controlled, so already bounded.
Two async tests over record_request. Distinct models past MAX_DISTINCT_MODELS
bucket into "other": requests_by_model and _cache_requests_by_model both stay
bounded, already-tracked models still increment, and sum(requests_by_model)
stays == requests_total. The cap warns exactly once, not per request.

Fails before the fix (dicts grow to the full distinct-model count); passes after.
@github-actions

github-actions Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

PR governance

This PR follows the template and is marked ready for human review.

@codecov-commenter

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

inix-x added 3 commits July 22, 2026 18:02
The over-cap bucket value was a bare "other" literal at its one write site.
Extract it as `_OTHER_MODEL` so the sentinel is named at its source. Tests and the
doc keep the literal on purpose — they pin the observable contract value, so a
sentinel change fails the test rather than silently tracking the constant.
Two gaps the review found. reset_runtime re-arms the one-shot cardinality warning
(a dropped reset line would keep the suite green), and export() must emit at most
MAX_DISTINCT_MODELS + 1 model series with the "other" bucket present — the
exported series is the real attack surface, previously asserted only through the
in-memory dict length.
The section ended on an absolute "no code path can drive label cardinality
unbounded." True today, but a future uncapped client label would silently falsify
it. Tie the claim to the enumerated vocabularies above ("every label vocabulary
listed above is bounded by code") so it stays verifiable.
@JerrettDavis

Copy link
Copy Markdown
Collaborator

Early review while this is still draft: I did not find a correctness blocker in the current cardinality-cap logic. The cap decision is made before touching the defaultdict, both requests_by_model and _cache_requests_by_model use the same bounded model key, reset re-arms the one-shot warning, and the export test covers the actual Prometheus series count.

Verified locally on the latest branch:

uv run --frozen --extra dev pytest tests/test_observability_metrics.py
9 passed

One polish item before marking ready: the comment above _cache_requests_by_model[bounded_model] starts with ponytail:. The repo has a few of those already, but this one is in newly added code and reads like an accidental internal note. I’d reword it to a normal explanatory comment before final review.

@inix-x
inix-x marked this pull request as ready for review July 22, 2026 14:16
@github-actions github-actions Bot added the status: needs author action Pull request body or readiness checklist still needs author updates label Jul 22, 2026
@inix-x
inix-x marked this pull request as draft July 22, 2026 14:22
@github-actions github-actions Bot removed the status: needs author action Pull request body or readiness checklist still needs author updates label Jul 22, 2026
The comment above the cache-bust heuristic read like a stray internal note.
Reword it as a plain explanation of why an over-cap "other" bucket is acceptable
here, and drop the em-dash. No logic change.
@inix-x

inix-x commented Jul 22, 2026

Copy link
Copy Markdown
Contributor Author

@JerrettDavis thanks for the early look. Reworded that comment to a plain explanation and dropped the em-dash, pushed as a follow-up. Back in draft while I finish up.

@inix-x
inix-x marked this pull request as ready for review July 22, 2026 14:37
@github-actions github-actions Bot added the status: needs author action Pull request body or readiness checklist still needs author updates label Jul 22, 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.

Latest update addresses the stray-comment polish from my earlier pass, and the cardinality cap still looks sound: the cap decision happens before any defaultdict indexing,
equests_by_model and _cache_requests_by_model share the bounded key, reset clears both dicts and re-arms the one-shot warning, and the export test pins the actual series bound. CI is green.\n\nI also updated the PR body readiness checkbox so governance matches the current ready-for-review state. Local note: the targeted subset ran 73/74 here; the remaining export test failed only because this shell environment is missing omlkit during a CLI metrics import, not because of this patch.

@github-actions github-actions Bot added status: ready for review Pull request body is complete and the author marked it ready for human review and removed status: needs author action Pull request body or readiness checklist still needs author updates labels Jul 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

status: ready for review Pull request body is complete and the author marked it ready for human review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants