headroom/headroom/proxy/prometheus_metrics.py
Serge ARADJ a3d9424de9
fix(proxy): return 502, not 200, when upstream connect retries are exhausted (#3083)
## Description

When every connect retry to the upstream API fails,
`_stream_response_inner` synthesizes its own SSE error response (added
in #1639, so an h2 `StreamReset` wouldn't surface as an unhandled 502).
It was built without a `status_code`, so Starlette defaulted it to
**200**.

A 200 carrying a lone `event: error` frame and no `message_start` is
indistinguishable, to every Anthropic/OpenAI SDK, from a successful
stream that produced no events. Claude Code reports:

```
API Error: API returned an empty or malformed response (HTTP 200)
 - check for a proxy or gateway intercepting the request
```

The client also cannot recover, because 200 is not a retryable status.

**It does not self-heal.** Compression fails open on timeout, so the
proxy forwards the full uncompressed body; the client retries, re-sends
the same oversized payload, hits the same transport failure, and gets
another 200. The session is stuck until the client is pointed away from
the proxy.

Related — same *symptom*, different root cause, so this closes none of
them: #3040, #3055, #3019, #2952 (CCR buffered-stream conversion),
#3071, #3017. Worth noting that #3040 ("first messages succeed, fails
after several turns", closed `NOT_PLANNED`) matches this failure's shape
exactly.

## Type of Change

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

Marked breaking because the status code on this path changes 200 to 502.
See **Runtime Rollout Safety**.

## Changes Made

- `handlers/streaming.py` — the synthesized transport-error response now
returns **502**. The structured SSE body is unchanged for clients that
read it. No body byte has been forwarded at that point, so the status
line is still ours to set.
- `prometheus_metrics.py` — new
`headroom_upstream_connection_errors_total{provider}`. This path
forwards no upstream status, so there was nothing to attribute the
failure to in `/metrics`; it survived only as a log line. Mirrors
`record_compression_failed` and takes the same `_obs_counter_lock`.
- `server.py` — `HEADROOM_LOG_LEVEL` for uvicorn's level, previously
hardcoded to `"warning"` with no env var and no CLI flag. Default
unchanged. An unrecognized value warns and falls back rather than
raising (uvicorn raises `KeyError` on unknown levels).
- `docs/content/docs/proxy.mdx` — documents the new env var in the
Observability table.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed

`test_stream_reset_exhaustion_yields_sse_error_not_crash` asserted the
SSE body but never the status — which is how the 200 survived. Added a
test that pins the status specifically, a happy-path guard, and coverage
for the counter and the env-var resolver.

### Test Output

```text
$ python -m pytest tests/test_h2_stream_reset_retry.py tests/test_prometheus_obs_counters.py tests/test_uvicorn_log_level_env.py -q
29 passed in 5.26s

$ python -m ruff check .
All checks passed!

$ python -m ruff format --check .
1506 files already formatted

$ python -m mypy headroom/proxy/handlers/streaming.py headroom/proxy/prometheus_metrics.py headroom/proxy/server.py
Success: no issues found in 3 source files

# Fails before the fix (status_code=502 line removed, nothing else changed):
$ python -m pytest tests/test_h2_stream_reset_retry.py -k status_is_not_200
    assert result.status_code == 502
E   assert 200 == 502
FAILED tests/test_h2_stream_reset_retry.py::test_stream_reset_exhaustion_status_is_not_200
1 failed, 5 deselected in 1.28s
```

Broader regression run (181 passed): `test_h2_stream_reset_retry`,
`test_prometheus_obs_counters`, `test_uvicorn_log_level_env`,
`test_prometheus_label_escaping`, `test_observability_metrics`,
`test_prometheus_stage_timing_concurrency`,
`test_proxy_streaming_ratelimit_headers`, `test_proxy_retry_429`,
`test_proxy_byte_faithful_forwarding`, `test_ws_http_fallback`,
`test_mid_turn_steering`, `test_proxy_anthropic_cache_stability`.

## Real Behavior Proof

- Environment: Windows 11, Python 3.13.15, headroom @ this branch.
Genuine `create_app()` FastAPI app under real uvicorn — no mocks, no
TestClient. Upstream pinned to `http://127.0.0.1:59999` (a closed port),
so every connect attempt is a real TCP refusal, producing a real
`httpx.ConnectError` (an `httpx.TransportError`) into the branch under
test. `retry_max_attempts=2`.
- Exact command / steps: boot the real app with
`HEADROOM_LOG_LEVEL=info` and
`ProxyConfig(anthropic_api_url="http://127.0.0.1:59999")`, POST a
`stream:true` request to `/v1/messages`, then scrape `/metrics`.
Verbatim commands below.
- Observed result: `HTTP_STATUS=502` (previously 200), structured SSE
error body intact,
`headroom_upstream_connection_errors_total{provider="anthropic"} 1`, and
a uvicorn access line present only because `HEADROOM_LOG_LEVEL=info` was
honored. Verbatim output below.
- Not tested: the h2 `StreamReset` variant specifically — reproduced via
`ConnectError`, a sibling `httpx.TransportError` travelling the
identical code path (the existing `test_stream_reset_exhaustion_*` tests
cover `RemoteProtocolError` at unit level). Not exercised against the
OpenAI, Gemini, or Bedrock streaming handlers, which have their own
error paths. No load or concurrency testing.

Commands run after the patch:

```bash
# boot the real app with a dead upstream and the new env var set
HEADROOM_LOG_LEVEL=info python run_proxy_proof.py   # ProxyConfig(anthropic_api_url="http://127.0.0.1:59999")

curl -s -o resp.txt -w "HTTP_STATUS=%{http_code}\ncontent_type=%{content_type}\n" \
  http://127.0.0.1:8799/v1/messages \
  -H "content-type: application/json" \
  -H "x-api-key: proof-key" \
  -H "anthropic-version: 2023-06-01" \
  -d @request.json    # {"model":"claude-opus-5","max_tokens":64,"stream":true,"messages":[...]}
```

After-fix evidence:

```text
PROOF: HEADROOM_LOG_LEVEL='info' -> uvicorn log_level='info'
PROOF: upstream pinned to http://127.0.0.1:59999 (closed port)

HTTP_STATUS=502
content_type=text/event-stream; charset=utf-8

event: error
data: {"type": "error", "error": {"type": "connection_error", "message": "Failed to connect to upstream API: All connection attempts failed"}}
```

```text
$ curl -s http://127.0.0.1:8799/metrics | grep upstream_connection_errors
# HELP headroom_upstream_connection_errors_total Exhausted-retries upstream transport failures by provider; the proxy answered 502 itself because no upstream response arrived
# TYPE headroom_upstream_connection_errors_total counter
headroom_upstream_connection_errors_total{provider="anthropic"} 1
```

```text
# uvicorn access log — present only because HEADROOM_LOG_LEVEL=info was honored:
INFO:     127.0.0.1:62472 - "POST /v1/messages HTTP/1.1" 502 Bad Gateway
INFO:     127.0.0.1:62479 - "GET /metrics HTTP/1.1" 200 OK
```

All three changes are exercised end to end: the status is 502, the
structured body survives, the counter increments, and the env var takes
effect.

Separately, this ran against a real deployment: the fix is live on a
self-hosted proxy at `0.35.1-alpha.3` (Azure Container Apps, Cloudflare
in front), where the original HTTP 200 was first observed against
`0.35.1-alpha.1`.

## Runtime Rollout Safety

- Rollout-managed feature(s): none — unconditional bug fix, no flag.
- Minimum rollout channel: n/a — ships with the change.
- Stable/default behavior changed: yes. This path returns 502 instead of
200. `HEADROOM_LOG_LEVEL` and the new counter both default to current
behavior (`warning`; the counter is absent from `/metrics` until the
first occurrence).
- Kill switch / disable path: none. Happy to add an env guard if you
would prefer it staged, though a 200 on this path is never correct.
- Unsafe override required: no.
- Qualification impact: any client treating the synthesized 200 as
success now sees a 5xx. That is the fix — such a client was silently
accepting a truncated response. Retry-on-5xx logic in the Anthropic and
OpenAI SDKs will now retry a transient transport failure, which is the
intended behavior.
- Rollback path: revert the commit; single and self-contained.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] 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 — terminal output above.

## Additional Notes

**Scope.** Three changes in one PR, against the "one logical change"
guidance. They share a single root cause: this bug was only findable by
reading `/metrics`, because the failing path emitted no status, no
counter, and (see below) no usable log line. The counter and the env var
are the observability that should have made it a five-minute diagnosis
instead of a forensic exercise. Happy to split the `HEADROOM_LOG_LEVEL`
change into its own PR if you would rather keep the fix minimal — just
say so.

**Related defect, filed separately as #3087.** While producing the proof
above I found that the proxy's own `logger.error("Connection error to
upstream API: ...")` never reaches stdout: that run produced **zero**
`headroom.proxy` logger lines, only uvicorn's own. Root cause is
`_setup_file_logging()` setting `propagate = False` on the `headroom`
logger (`helpers.py:1536`), which sends every application record to
`~/.headroom/logs/proxy.log` and nowhere else — invisible in any
container, where stdout is the log channel. That is precisely why this
PR adds a counter rather than trusting a log line. Not fixed here: the
right remedy is a maintainer call, so it is written up in #3087 with a
repro rather than folded into this PR.

**No dependency changes.**

The dead-upstream harness used for the proof above is ~25 lines
(`ProxyConfig(anthropic_api_url="http://127.0.0.1:59999")` +
`uvicorn.run(create_app(config))`); happy to contribute it as an e2e
test if that is useful.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 06:32:30 -07:00

1777 lines
82 KiB
Python

"""Prometheus-compatible metrics for the Headroom proxy.
Tracks request counts, token usage, latency, overhead, TTFB,
per-transform timing, waste signals, prefix cache stats, and
cumulative savings history.
Extracted from server.py for maintainability.
"""
from __future__ import annotations
import asyncio
import logging
import threading
from collections import defaultdict
from datetime import datetime
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from headroom.observability import HeadroomOtelMetrics
from headroom.proxy.cost import CostTracker
from headroom import savings_ledger
from headroom.observability import get_otel_metrics
from headroom.proxy.savings_tracker import SavingsTracker, estimate_request_savings_usd
logger = logging.getLogger("headroom.proxy")
# Sentinel label value that models past MAX_DISTINCT_MODELS collapse into, so
# client-supplied model cardinality stays bounded (see record_request).
_OTHER_MODEL = "other"
def _escape_label_value(value: str) -> str:
# The /metrics body is emitted whole with .encode("utf-8") (server.py). A
# client-supplied value can be a valid str that is not UTF-8-encodable — a
# lone surrogate decoded from a JSON model id — which raises in the response
# encoder and 500s every scrape, not just its own line. Drop un-encodable
# code points before escaping so one malformed request can't down the
# endpoint. Byte-identical for encodable values, including non-ASCII.
value = value.encode("utf-8", "replace").decode("utf-8")
return value.replace("\\", "\\\\").replace("\n", "\\n").replace('"', '\\"')
def _format_labels(labels: dict[str, str] | None = None) -> str:
if not labels:
return ""
rendered = ",".join(
f'{key}="{_escape_label_value(str(value))}"' for key, value in sorted(labels.items())
)
return f"{{{rendered}}}"
def _append_metric(
lines: list[str],
*,
name: str,
metric_type: str,
help_text: str,
value: int | float,
labels: dict[str, str] | None = None,
) -> None:
lines.extend(
[
f"# HELP {name} {help_text}",
f"# TYPE {name} {metric_type}",
f"{name}{_format_labels(labels)} {value}",
"",
]
)
# The proxy persists savings state on every request. Batch that write so a busy
# event loop isn't blocked re-serializing + fsyncing the whole history each time;
# the tracker still flushes on graceful shutdown (see HeadroomProxy.shutdown).
PROXY_SAVINGS_FLUSH_EVERY = 25
class PrometheusMetrics:
"""Prometheus-compatible metrics."""
def __init__(
self,
savings_tracker: SavingsTracker | None = None,
cost_tracker: CostTracker | None = None,
otel_metrics: HeadroomOtelMetrics | None = None,
stateless: bool = False,
):
# Stateless mode: keep live in-memory metrics but never write the
# durable savings files (proxy_savings.json, savings_events.jsonl).
self._stateless = stateless
self.requests_total = 0
self.requests_by_provider: dict[str, int] = defaultdict(int)
self.requests_by_model: dict[str, int] = defaultdict(int)
# Set once when requests_by_model first reaches MAX_DISTINCT_MODELS, so the
# cardinality-cap warning fires exactly once instead of per request.
self._model_cardinality_warned = False
# Populated via X-Headroom-Stack header (TS SDK adapters, etc.)
self.requests_by_stack: dict[str, int] = defaultdict(int)
self.requests_cached = 0
self.requests_rate_limited = 0
self.requests_failed = 0
self.inbound_requests_total = 0
self.inbound_requests_completed = 0
self.inbound_requests_active = 0
self.inbound_requests_by_method: dict[str, int] = defaultdict(int)
self.inbound_requests_by_path: dict[str, int] = defaultdict(int)
self.inbound_responses_by_status: dict[str, int] = defaultdict(int)
self.tokens_input_total = 0
self.tokens_output_total = 0
self.tokens_saved_total = 0
# Tool-schema savings (deferral + turn-hook tool shrink), aggregated from
# per-request tags. Tracked apart from tokens_saved_total (which is message
# compression only — tool bytes never move tok_before/after) so every sink
# can surface the tool-schema layer instead of silently dropping it.
self.tool_search_saved_total = 0
# Sum of tokens we actually attempted to compress across the
# session: extracted units that passed all gates + tool-schema
# tokens we ran compaction against. Excludes prefix-frozen
# content (instructions, user/system messages, prior turns).
# This is the right denominator for an "active compression
# ratio" — what fraction of the compressible-eligible tokens
# did we actually save?
self.attempted_input_tokens_total = 0
# Per-strategy compression counters. Populated lazily as we see
# each strategy tag — no hardcoded list of strategies; the keys
# come from ContentRouter's `CompressionStrategy.value` and
# SmartCrusher's literal `"smart_crusher"`. The forcing
# function for catching strategy-level silent regressions:
# if SmartCrusher events drop to zero in production, the
# `headroom_compressions_total{strategy="smart_crusher"}`
# counter shows it on day 1, not week 3.
self.compressions_by_strategy: dict[str, int] = defaultdict(int)
self.tokens_saved_by_strategy: dict[str, int] = defaultdict(int)
# Per-extension token savings, keyed by the extension-supplied
# ``key``. Populated lazily by ``record_extension_savings`` — no
# hardcoded list of extensions. Proxy extensions report the tokens
# they saved so per-extension contribution is observable via /stats,
# mirroring the per-strategy compression breakdown above.
self.extension_savings: dict[str, int] = defaultdict(int)
# Named savings attribution; realized and projected rows stay separate.
self.savings_by_source: dict[str, dict[str, str | int | float | bool]] = {}
# Fail-open compression failures, keyed by reason ("timeout",
# "error"). The proxy fails open on any optimization error so the
# request still succeeds; without this counter the failure is only
# visible as a log line. Splitting timeout from other errors tells us
# whether the compression budget is too tight vs. a real bug.
self.compression_failed_by_reason: dict[str, int] = defaultdict(int)
# Upstream transport failures on the streaming path, keyed by provider.
# Raised when every connect retry is exhausted and the proxy synthesizes
# its own error response instead of forwarding an upstream status. That
# path emits no upstream status code to attribute the failure to, so
# without this counter it is invisible in metrics and survives only as a
# log line.
self.upstream_connection_errors_by_provider: dict[str, int] = defaultdict(int)
# Kompress size-gate outcomes, keyed by outcome ("within",
# "exceeded"). The gate routes oversized blocks away from ML
# compression (ContentRouter._kompress_max_tokens, #1171). This
# counter proves whether the gate ever fires on live traffic.
self.kompress_size_gate_by_outcome: dict[str, int] = defaultdict(int)
# Timeout-debt quarantine events. ``activated`` means a request timed
# out while its worker kept running; ``skipped`` means later work was
# rejected before executor admission behind that worker.
self.compression_quarantine_by_event: dict[str, int] = defaultdict(int)
# These counters are mutated from compression worker threads and the
# event-loop thread, while export()/reset_runtime() read and clear them.
# asyncio.Lock can't be taken off-loop, so guard them with a plain
# threading.Lock, mirroring _stage_timing_lock below. Without it a
# first-time key insert can race an export iteration ("dictionary
# changed size during iteration") and concurrent increments can be
# lost.
self._obs_counter_lock = threading.Lock()
# Codex WebSocket compression observability. These are intentionally
# aggregate counters/sums, not per-unit storage, so /stats can answer
# routing questions without growing with traffic volume.
self.codex_ws_units_total = 0
self.codex_ws_units_modified_total = 0
self.codex_ws_units_to_kompress_total = 0
self.codex_ws_units_kompress_attempted_total = 0
self.codex_ws_units_by_strategy: dict[str, int] = defaultdict(int)
self.codex_ws_units_by_category: dict[str, int] = defaultdict(int)
self.codex_ws_units_by_content_type: dict[str, int] = defaultdict(int)
self.codex_ws_units_by_text_shape: dict[str, int] = defaultdict(int)
self.codex_ws_unit_elapsed_ms_sum = 0.0
self.codex_ws_unit_elapsed_ms_max = 0.0
self.codex_ws_unit_bytes_sum = 0
self.codex_ws_unit_tokens_before_sum = 0
self.codex_ws_unit_tokens_after_sum = 0
self.codex_ws_unit_tokens_saved_sum = 0
self.codex_ws_frames_attempted_total = 0
self.codex_ws_frames_compressed_total = 0
self.codex_ws_frames_failed_total = 0
self.codex_ws_frames_to_kompress_total = 0
self.codex_ws_frames_kompress_attempted_total = 0
self.codex_ws_frame_elapsed_ms_sum = 0.0
self.codex_ws_frame_elapsed_ms_max = 0.0
self.codex_ws_frame_bytes_before_sum = 0
self.codex_ws_frame_bytes_after_sum = 0
self.codex_ws_frame_attempted_tokens_sum = 0
self.codex_ws_frame_tokens_saved_sum = 0
self.latency_sum_ms = 0.0
self.latency_min_ms = float("inf")
self.latency_max_ms = 0.0
self.latency_count = 0
# Headroom overhead (optimization time only, excludes LLM)
self.overhead_sum_ms = 0.0
self.overhead_min_ms = float("inf")
self.overhead_max_ms = 0.0
self.overhead_count = 0
# Time to first byte (TTFB) from upstream — what the user actually feels
self.ttfb_sum_ms = 0.0
self.ttfb_min_ms = float("inf")
self.ttfb_max_ms = 0.0
self.ttfb_count = 0
# Per-transform timing (name → cumulative ms, count)
self.transform_timing_sum: dict[str, float] = defaultdict(float)
self.transform_timing_count: dict[str, int] = defaultdict(int)
self.transform_timing_max: dict[str, float] = defaultdict(float)
# Per-stage timing (Unit 2). Keyed by ``(path, stage)`` tuples so
# a single metric name can distinguish between, e.g.,
# ``openai_responses_ws`` ``upstream_connect`` and
# ``anthropic_messages`` ``upstream_connect``.
self.stage_timing_sum: dict[tuple[str, str], float] = defaultdict(float)
self.stage_timing_count: dict[tuple[str, str], int] = defaultdict(int)
self.stage_timing_max: dict[tuple[str, str], float] = defaultdict(float)
# WS session lifecycle (Unit 3). Gauges are live counters updated
# by the Codex handler on register/deregister + attach_tasks/
# detach. Histograms record completed-session durations bucketed
# by termination cause so we can distinguish slow happy-path
# sessions from long client-hold followed by client_disconnect.
self.active_ws_sessions: int = 0
self.active_relay_tasks: int = 0
self.ws_session_duration_sum_ms: dict[str, float] = defaultdict(float)
self.ws_session_duration_count: dict[str, int] = defaultdict(int)
self.ws_session_duration_max_ms: dict[str, float] = defaultdict(float)
# Aggregate waste signals
self.waste_signals_total: dict[str, int] = defaultdict(int)
# Cumulative ContentRouter protection counts. Each routing pass
# categorises every message — `user_msg`, `system_msg`,
# `recent_code`, `excluded_tool`, `analysis_ctx`, `small`,
# `ratio_too_high`, `already_compressed`, `non_string`,
# `content_blocks`. Surfacing these in `/stats` gives operators a
# way to diagnose "why is my compression rate low?" — e.g. a high
# `user_msg` count on OpenAI/Azure traffic explains why most
# input was protected and never reached the compressor (#454).
self.router_route_counts: dict[str, int] = defaultdict(int)
# Provider-specific prefix cache tracking
# Each provider has different cache economics:
# Anthropic: cache_read=0.1x, cache_write=1.25x, explicit breakpoints
# OpenAI: cache_read=0.5x, no write penalty, automatic
# Google: cache_read=~0.1x, explicit cachedContent API, storage cost
# Bedrock: no cache metrics
self.cache_by_provider: dict[str, dict[str, int | float]] = defaultdict(
lambda: {
"cache_read_tokens": 0,
"cache_write_tokens": 0,
"cache_write_5m_tokens": 0,
"cache_write_1h_tokens": 0,
"cache_write_5m_requests": 0,
"cache_write_1h_requests": 0,
"uncached_input_tokens": 0,
"requests": 0,
"hit_requests": 0, # requests with cache_read > 0
"bust_count": 0,
"bust_write_tokens": 0,
}
)
# Track per-model cache request count to distinguish cold starts from busts
self._cache_requests_by_model: dict[str, int] = defaultdict(int)
# Prefix freeze stats (cache-aware compression)
self.prefix_freeze_busts_avoided: int = 0
self.prefix_freeze_tokens_preserved: int = 0
self.prefix_freeze_compression_foregone: int = 0
# Cache bust tracking: how many tokens lost their cache discount due to compression
self.cache_bust_tokens_lost: int = 0
self.cache_bust_count: int = 0
# Cache-miss attribution (#1313): when a turn expected a prompt-cache
# hit but got none, why? Bucketed by reason so operators can tell a
# TTL lapse (idle longer than the cache lifetime → consider a longer
# TTL) from a prefix-content change (the cacheable prefix shifted).
# Reasons are the MISS_* literals from prefix_tracker. Per provider so
# the dashboard can scope; populated by `record_cache_miss_attribution`.
self.cache_miss_attribution_by_provider: dict[str, dict[str, int]] = defaultdict(
lambda: defaultdict(int)
)
# Cumulative savings history (timestamp → cumulative tokens saved)
self.savings_history: list[tuple[str, int]] = []
self.savings_tracker = savings_tracker or SavingsTracker(
stateless=stateless, save_flush_every=PROXY_SAVINGS_FLUSH_EVERY
)
self.cost_tracker = cost_tracker
tracker_lifetime = self.savings_tracker.snapshot()["lifetime"]
self._savings_tracker_input_tokens_offset = max(
int(tracker_lifetime.get("total_input_tokens", 0) or 0),
0,
)
self._savings_tracker_input_cost_usd_offset = max(
float(tracker_lifetime.get("total_input_cost_usd", 0.0) or 0.0),
0.0,
)
self._lock = asyncio.Lock()
# Tiny synchronous critical section for stage-timing triple updates
# (sum + count + max must move together for a consistent scrape).
# threading.Lock is cheaper than asyncio.Lock and does NOT contend
# with the async ``export()`` path — scrapes snapshot these dicts
# under this lock in a microsecond block, then build the metrics
# string without holding anything.
self._stage_timing_lock = threading.Lock()
self._otel_metrics = otel_metrics
async def reset_runtime(self) -> None:
"""Reset in-memory request/compression counters for local test/debug use."""
async with self._lock:
self.requests_total = 0
self.requests_by_provider.clear()
self.requests_by_model.clear()
self._model_cardinality_warned = False
self.requests_by_stack.clear()
self.requests_cached = 0
self.requests_rate_limited = 0
self.requests_failed = 0
self.inbound_requests_total = 0
self.inbound_requests_completed = 0
self.inbound_requests_active = 0
self.inbound_requests_by_method.clear()
self.inbound_requests_by_path.clear()
self.inbound_responses_by_status.clear()
self.tokens_input_total = 0
self.tokens_output_total = 0
self.tokens_saved_total = 0
self.tool_search_saved_total = 0
self.attempted_input_tokens_total = 0
self.compressions_by_strategy.clear()
self.tokens_saved_by_strategy.clear()
self.extension_savings.clear()
self.savings_by_source.clear()
with self._obs_counter_lock:
self.compression_failed_by_reason.clear()
self.upstream_connection_errors_by_provider.clear()
self.kompress_size_gate_by_outcome.clear()
self.compression_quarantine_by_event.clear()
self.codex_ws_units_total = 0
self.codex_ws_units_modified_total = 0
self.codex_ws_units_to_kompress_total = 0
self.codex_ws_units_kompress_attempted_total = 0
self.codex_ws_units_by_strategy.clear()
self.codex_ws_units_by_category.clear()
self.codex_ws_units_by_content_type.clear()
self.codex_ws_units_by_text_shape.clear()
self.codex_ws_unit_elapsed_ms_sum = 0.0
self.codex_ws_unit_elapsed_ms_max = 0.0
self.codex_ws_unit_bytes_sum = 0
self.codex_ws_unit_tokens_before_sum = 0
self.codex_ws_unit_tokens_after_sum = 0
self.codex_ws_unit_tokens_saved_sum = 0
self.codex_ws_frames_attempted_total = 0
self.codex_ws_frames_compressed_total = 0
self.codex_ws_frames_failed_total = 0
self.codex_ws_frames_to_kompress_total = 0
self.codex_ws_frames_kompress_attempted_total = 0
self.codex_ws_frame_elapsed_ms_sum = 0.0
self.codex_ws_frame_elapsed_ms_max = 0.0
self.codex_ws_frame_bytes_before_sum = 0
self.codex_ws_frame_bytes_after_sum = 0
self.codex_ws_frame_attempted_tokens_sum = 0
self.codex_ws_frame_tokens_saved_sum = 0
self.latency_sum_ms = 0.0
self.latency_min_ms = float("inf")
self.latency_max_ms = 0.0
self.latency_count = 0
self.overhead_sum_ms = 0.0
self.overhead_min_ms = float("inf")
self.overhead_max_ms = 0.0
self.overhead_count = 0
self.ttfb_sum_ms = 0.0
self.ttfb_min_ms = float("inf")
self.ttfb_max_ms = 0.0
self.ttfb_count = 0
self.transform_timing_sum.clear()
self.transform_timing_count.clear()
self.transform_timing_max.clear()
self.waste_signals_total.clear()
self.cache_by_provider.clear()
self._cache_requests_by_model.clear()
self.prefix_freeze_busts_avoided = 0
self.prefix_freeze_tokens_preserved = 0
self.prefix_freeze_compression_foregone = 0
self.cache_bust_tokens_lost = 0
self.cache_bust_count = 0
self.cache_miss_attribution_by_provider.clear()
self.savings_history = []
with self._stage_timing_lock:
self.stage_timing_sum.clear()
self.stage_timing_count.clear()
self.stage_timing_max.clear()
self.ws_session_duration_sum_ms.clear()
self.ws_session_duration_count.clear()
self.ws_session_duration_max_ms.clear()
def _get_otel_metrics(self) -> HeadroomOtelMetrics:
return self._otel_metrics or get_otel_metrics()
def _current_savings_tracker_totals(self) -> tuple[int, float]:
total_input_tokens = self._savings_tracker_input_tokens_offset + self.tokens_input_total
total_input_cost_usd = self._savings_tracker_input_cost_usd_offset
if self.cost_tracker is None:
return total_input_tokens, total_input_cost_usd
try:
# totals() rather than stats(): identical numbers, without the
# 31-day cost-record walk that stats()["budget_basis"] performs and
# this caller throws away. See CostTracker.totals.
tracked_input_tokens, tracked_input_cost_usd = self.cost_tracker.totals()
except Exception:
logger.debug("Failed to read cost tracker totals for savings history", exc_info=True)
return total_input_tokens, total_input_cost_usd
if tracked_input_tokens is not None:
try:
total_input_tokens = self._savings_tracker_input_tokens_offset + max(
int(tracked_input_tokens),
0,
)
except (TypeError, ValueError):
pass
if tracked_input_cost_usd is not None:
try:
total_input_cost_usd = self._savings_tracker_input_cost_usd_offset + max(
float(tracked_input_cost_usd),
0.0,
)
except (TypeError, ValueError):
pass
return total_input_tokens, total_input_cost_usd
def record_stack(self, stack: str | None) -> None:
"""Increment the per-stack request counter.
``stack`` is the ``X-Headroom-Stack`` header value (e.g.
``adapter_ts_openai``). Called once per inbound request from the
proxy's stack middleware; a no-op when the header is absent, fails
validation, or would exceed the cardinality cap.
"""
from headroom.telemetry.context import MAX_DISTINCT_STACKS, normalize_stack
slug = normalize_stack(stack)
if not slug:
return
if (
slug not in self.requests_by_stack
and len(self.requests_by_stack) >= MAX_DISTINCT_STACKS
):
return
self.requests_by_stack[slug] += 1
self.savings_tracker.record_lifetime_stack(slug)
# Same fan-out as record_compression. This header is the only signal
# that names the harness when an agent is pointed at a persistent proxy
# rather than launched by `headroom wrap`, and the beacon cannot import
# headroom.proxy to read requests_by_stack itself.
from headroom.telemetry.session import record_stack as _beacon_stack
_beacon_stack(slug)
def record_compression(
self,
strategy: str,
original_tokens: int,
compressed_tokens: int,
) -> None:
"""Implements `headroom.transforms.observability.CompressionObserver`.
Called once per real compression event by the configured
transforms (ContentRouter at routing-decision granularity;
SmartCrusher at message granularity in the legacy direct-
pipeline path). Increments the per-strategy counters that
get exported as labelled Prometheus metrics, so silent
regressions in any single strategy become visible in the
scrape.
Synchronous + lock-free: `defaultdict(int)` writes are
atomic under the GIL for these key types; the proxy serves
many requests concurrently and the contention here would be
a single dict write per routing decision.
Tokens saved is `max(0, original - compressed)` — the
observer never records "negative savings" even if a
compressor goofs and emits more tokens than it received.
"""
self.compressions_by_strategy[strategy] += 1
saved = original_tokens - compressed_tokens
if saved > 0:
self.tokens_saved_by_strategy[strategy] += saved
# Fan out to the beacon. This object is the configured
# CompressionObserver for the proxy's pipelines, so it is where those
# events already arrive with both token counts — a second observer here
# would mean a second measurement pass for numbers in hand. (The paths
# that have no observer at all pass telemetry's
# BeaconCompressionObserver directly instead.)
#
# The beacon is ON by default, so this does not short-circuit in
# practice and must stay off the aggregator's lock: it stages into a
# dedicated mutex that the request path never takes, which is what
# keeps this method's "synchronous + lock-free" contract honest with
# respect to everything else in the process.
from headroom.telemetry.session import record_compression as _beacon_compression
_beacon_compression(strategy, original_tokens, compressed_tokens)
def record_extension_savings(self, key: str, saved: int) -> None:
"""Accumulate tokens saved by a proxy extension, keyed by ``key``.
Called by proxy extensions that perform their own token
reduction and want that contribution surfaced alongside the
built-in compression metrics. The per-extension totals are
exposed via /stats (``extension_savings``), mirroring how
``record_compression`` accumulates ``tokens_saved_by_strategy``.
Synchronous + lock-free: ``defaultdict(int)`` writes are atomic
under the GIL for these key types, matching ``record_compression``.
Non-positive ``saved`` values are ignored — the metric never
records "negative savings".
"""
if saved > 0:
self.extension_savings[key] += saved
def record_compression_failed(self, reason: str) -> None:
"""Record one fail-open compression failure, bucketed by ``reason``.
Called from the optimization fail-open site (handlers/anthropic.py)
with ``reason`` in ``{"timeout", "error"}``. Guarded by
``_obs_counter_lock`` so it stays consistent with the off-thread
``record_kompress_size_gate`` writer and the export/reset readers.
"""
with self._obs_counter_lock:
self.compression_failed_by_reason[reason or "error"] += 1
def record_upstream_connection_error(self, provider: str) -> None:
"""Record one exhausted-retries upstream transport failure.
Called from the streaming handler's ``httpx.TransportError`` fallback
(handlers/streaming.py), where the proxy synthesizes its own 502
because no upstream response ever arrived. Guarded by
``_obs_counter_lock`` for the same reason as
``record_compression_failed``.
"""
with self._obs_counter_lock:
self.upstream_connection_errors_by_provider[provider or "unknown"] += 1
def record_kompress_size_gate(self, outcome: str) -> None:
"""Record one kompress size-gate decision, bucketed by ``outcome``.
Called from ContentRouter via the observer hook with ``outcome`` in
``{"within", "exceeded"}`` — "exceeded" when an eligible block is too
large and routed off ML, "within" when it passes the gate. Proves
whether the size gate (#1171) ever fires on live traffic. Runs on the
compression executor thread, so the increment is guarded by
``_obs_counter_lock`` to stay safe against the export/reset readers.
"""
with self._obs_counter_lock:
self.kompress_size_gate_by_outcome[outcome or "within"] += 1
def record_compression_quarantine(self, event: str) -> None:
"""Record one timeout-debt quarantine event.
``event`` is ``"activated"`` when a timed-out worker is confirmed to
still be running, or ``"skipped"`` when new compression is rejected
before executor admission while that worker remains. Both worker and
event-loop threads call this method, so updates share the
observer-counter lock.
"""
with self._obs_counter_lock:
self.compression_quarantine_by_event[event or "skipped"] += 1
def record_router_route_counts(self, counts: dict[str, int]) -> None:
"""Accumulate ContentRouter routing-category counts for a single
pass. The router emits a dict like ``{"user_msg": 12,
"recent_code": 4, ...}`` summarising how it categorised each
message in that request. Adding these into a long-running
counter gives `/stats` a session-level breakdown so operators
can see, e.g., that 80% of messages were protected as
`user_msg` and only 5% reached the compressor (#454).
"""
for category, count in counts.items():
if count > 0:
self.router_route_counts[category] += int(count)
def record_codex_ws_unit(
self,
*,
strategy: str,
reason_category: str,
elapsed_ms: float,
text_bytes: int,
tokens_before: int,
tokens_after: int,
tokens_saved: int,
modified: bool,
strategy_chain: list[str] | None = None,
content_type: str = "unknown",
text_shape: str = "unknown",
) -> None:
"""Record one Codex WS compression unit decision."""
strategy = strategy or "unknown"
reason_category = reason_category or "unknown"
chain = strategy_chain or []
self.codex_ws_units_total += 1
self.codex_ws_units_by_strategy[strategy] += 1
self.codex_ws_units_by_category[reason_category] += 1
self.codex_ws_units_by_content_type[content_type or "unknown"] += 1
self.codex_ws_units_by_text_shape[text_shape or "unknown"] += 1
if modified:
self.codex_ws_units_modified_total += 1
if strategy == "kompress":
self.codex_ws_units_to_kompress_total += 1
if "kompress" in chain or strategy == "kompress":
self.codex_ws_units_kompress_attempted_total += 1
elapsed_ms = max(0.0, float(elapsed_ms))
self.codex_ws_unit_elapsed_ms_sum += elapsed_ms
self.codex_ws_unit_elapsed_ms_max = max(self.codex_ws_unit_elapsed_ms_max, elapsed_ms)
self.codex_ws_unit_bytes_sum += max(0, int(text_bytes))
self.codex_ws_unit_tokens_before_sum += max(0, int(tokens_before))
self.codex_ws_unit_tokens_after_sum += max(0, int(tokens_after))
self.codex_ws_unit_tokens_saved_sum += max(0, int(tokens_saved))
def record_codex_ws_frame(
self,
*,
elapsed_ms: float,
bytes_before: int,
bytes_after: int = 0,
attempted_tokens: int = 0,
tokens_saved: int = 0,
modified: bool = False,
failed: bool = False,
strategy_chain: list[str] | None = None,
final_strategies: list[str] | None = None,
) -> None:
"""Record one Codex WS response.create compression attempt."""
chain = strategy_chain or []
strategies = final_strategies or []
self.codex_ws_frames_attempted_total += 1
if modified:
self.codex_ws_frames_compressed_total += 1
if failed:
self.codex_ws_frames_failed_total += 1
if "kompress" in strategies:
self.codex_ws_frames_to_kompress_total += 1
if "kompress" in chain or "kompress" in strategies:
self.codex_ws_frames_kompress_attempted_total += 1
elapsed_ms = max(0.0, float(elapsed_ms))
self.codex_ws_frame_elapsed_ms_sum += elapsed_ms
self.codex_ws_frame_elapsed_ms_max = max(self.codex_ws_frame_elapsed_ms_max, elapsed_ms)
self.codex_ws_frame_bytes_before_sum += max(0, int(bytes_before))
self.codex_ws_frame_bytes_after_sum += max(0, int(bytes_after))
self.codex_ws_frame_attempted_tokens_sum += max(0, int(attempted_tokens))
self.codex_ws_frame_tokens_saved_sum += max(0, int(tokens_saved))
def record_inbound_request(self, *, method: str, path: str) -> None:
self.inbound_requests_total += 1
self.inbound_requests_active += 1
self.inbound_requests_by_method[method.upper()] += 1
self.inbound_requests_by_path[path] += 1
def record_inbound_response(self, *, status_code: int | str) -> None:
self.inbound_requests_completed += 1
self.inbound_requests_active = max(0, self.inbound_requests_active - 1)
self.inbound_responses_by_status[str(status_code)] += 1
def record_inbound_aborted(self, *, reason: str) -> None:
self.inbound_requests_completed += 1
self.inbound_requests_active = max(0, self.inbound_requests_active - 1)
self.inbound_responses_by_status[f"aborted:{reason}"] += 1
def inbound_snapshot(self) -> dict[str, object]:
return {
"total": self.inbound_requests_total,
"completed": self.inbound_requests_completed,
"active": self.inbound_requests_active,
"by_method": dict(self.inbound_requests_by_method),
"by_path": dict(self.inbound_requests_by_path),
"by_status": dict(self.inbound_responses_by_status),
}
async def record_request(
self,
provider: str,
model: str,
input_tokens: int,
output_tokens: int,
tokens_saved: int,
latency_ms: float,
cached: bool = False,
overhead_ms: float = 0,
ttfb_ms: float = 0,
pipeline_timing: dict[str, float] | None = None,
waste_signals: dict[str, int] | None = None,
cache_read_tokens: int = 0,
cache_write_tokens: int = 0,
cache_write_5m_tokens: int = 0,
cache_write_1h_tokens: int = 0,
uncached_input_tokens: int = 0,
attempted_input_tokens: int = 0,
output_tokens_saved: int = 0,
project: str | None = None,
client: str | None = None,
tool_search_saved: int = 0,
local_input_tokens: int | None = None,
savings_attribution: list[dict[str, Any]] | None = None,
):
"""Record metrics for a request.
``input_tokens`` is the billed/volume figure and may be the provider's own
count. ``local_input_tokens`` is the same request measured with the SAME
tokenizer as ``tokens_saved``; it is used wherever a delta is derived, so
reduction/yield/ledger math never straddles two rulers. Defaults to
``input_tokens`` when omitted, preserving pre-split behaviour.
"""
# Local import mirrors record_stack: defers to call-time (the telemetry
# package is fully loaded by then), avoiding an import cycle at module load.
from headroom.telemetry.context import MAX_DISTINCT_MODELS
ledger_input_tokens = input_tokens if local_input_tokens is None else local_input_tokens
# Post-guard invariant (all providers): Headroom never forwards a request
# larger than the original — handlers revert any inflation before sending
# (verified clean on the wire). So compression savings are >= 0; a negative
# here is an intermediate/hook token-count artifact that never reached the
# model. Clamp so total_tokens_removed / avg_compression_pct reflect the
# actually-forwarded bytes instead of surfacing spurious negatives.
if tokens_saved < 0:
logger.debug(
"metrics.record: clamping negative tokens_saved=%d to 0 for %s (artifact; wire not inflated)",
tokens_saved,
model,
)
tokens_saved = 0
savings_usd = estimate_request_savings_usd(
model,
compression_tokens_saved=tokens_saved,
tool_schema_tokens_saved=tool_search_saved,
output_tokens_saved=output_tokens_saved,
cache_read_tokens=cache_read_tokens,
)
async with self._lock:
self.requests_total += 1
self.requests_by_provider[provider] += 1
# Cap client-supplied model cardinality. `model` is client-controlled
# (body.get("model") in the openai/gemini/bedrock handlers), so an
# arbitrary-model client would otherwise grow requests_by_model and the
# exported series without bound. Bucket over-cap models into "other"
# (the sentinel docs/observability.md documents for `tier`), mirroring
# the requests_by_stack cap. Membership test, never a defaultdict index:
# indexing would materialize the key and defeat the cap.
if model in self.requests_by_model or len(self.requests_by_model) < MAX_DISTINCT_MODELS:
bounded_model = model
else:
bounded_model = _OTHER_MODEL
if not self._model_cardinality_warned:
self._model_cardinality_warned = True
logger.warning(
"metrics.record: model cardinality cap (%d) reached; "
'bucketing further models into "other"',
MAX_DISTINCT_MODELS,
)
self.requests_by_model[bounded_model] += 1
if cached:
self.requests_cached += 1
self.tokens_input_total += input_tokens
self.tokens_output_total += output_tokens
self.tokens_saved_total += tokens_saved
self.tool_search_saved_total += max(0, int(tool_search_saved))
for item in savings_attribution or ():
source = str(item.get("source") or "other")[:64]
realized = bool(item.get("realized", True))
key = f"{source}:{int(realized)}"
row = self.savings_by_source.setdefault(
key,
{
"source": source,
"realized": realized,
"events": 0,
"tokens": 0,
"usd": 0.0,
},
)
row["events"] = int(row["events"]) + 1
row["tokens"] = int(row["tokens"]) + max(0, int(item.get("tokens", 0) or 0))
row["usd"] = round(
float(row["usd"]) + float(item.get("usd", 0.0) or 0.0),
12,
)
# See the attribute definition for why this is the right
# denominator for the active-compression ratio.
self.attempted_input_tokens_total += max(0, int(attempted_input_tokens))
# Track provider-specific prefix cache metrics
if cache_read_tokens > 0 or cache_write_tokens > 0:
pc = self.cache_by_provider[provider]
pc["cache_read_tokens"] += cache_read_tokens
pc["cache_write_tokens"] += cache_write_tokens
pc["cache_write_5m_tokens"] += cache_write_5m_tokens
pc["cache_write_1h_tokens"] += cache_write_1h_tokens
if cache_write_5m_tokens > 0:
pc["cache_write_5m_requests"] += 1
if cache_write_1h_tokens > 0:
pc["cache_write_1h_requests"] += 1
pc["uncached_input_tokens"] += uncached_input_tokens
pc["requests"] += 1
if cache_read_tokens > 0:
pc["hit_requests"] += 1
# Model-aware bust detection: the first request for any model
# is always a cold start (100% write, 0% read) — not a bust.
# Only flag as bust when a previously-warm model suddenly has
# high write ratio, indicating prefix invalidation.
# bounded_model can be "other" once the cardinality cap trips, which
# mixes distinct models in this bust heuristic. That is acceptable:
# it only happens past MAX_DISTINCT_MODELS distinct models on cached
# anthropic traffic, the worst case is a mis-attributed bust stat,
# and it keeps _cache_requests_by_model bounded.
model_req_num = self._cache_requests_by_model[bounded_model]
self._cache_requests_by_model[bounded_model] += 1
if provider == "anthropic" and model_req_num > 0:
total_cached = cache_read_tokens + cache_write_tokens
if total_cached > 0 and cache_write_tokens > total_cached * 0.5:
pc["bust_count"] += 1
pc["bust_write_tokens"] += cache_write_tokens
self.latency_sum_ms += latency_ms
self.latency_min_ms = min(self.latency_min_ms, latency_ms)
self.latency_max_ms = max(self.latency_max_ms, latency_ms)
self.latency_count += 1
# Track Headroom overhead separately
if overhead_ms > 0:
self.overhead_sum_ms += overhead_ms
self.overhead_min_ms = min(self.overhead_min_ms, overhead_ms)
self.overhead_max_ms = max(self.overhead_max_ms, overhead_ms)
self.overhead_count += 1
# Track TTFB (time to first byte from upstream)
if ttfb_ms > 0:
self.ttfb_sum_ms += ttfb_ms
self.ttfb_min_ms = min(self.ttfb_min_ms, ttfb_ms)
self.ttfb_max_ms = max(self.ttfb_max_ms, ttfb_ms)
self.ttfb_count += 1
# Track per-transform timing
if pipeline_timing:
for name, ms in pipeline_timing.items():
self.transform_timing_sum[name] += ms
self.transform_timing_count[name] += 1
self.transform_timing_max[name] = max(self.transform_timing_max[name], ms)
# Track waste signals
if waste_signals:
for signal_name, token_count in waste_signals.items():
self.waste_signals_total[signal_name] += token_count
# Track cumulative savings history (record every request)
self.savings_history.append((datetime.now().isoformat(), self.tokens_saved_total))
# Keep last 500 data points
if len(self.savings_history) > 500:
self.savings_history = self.savings_history[-500:]
self.savings_tracker.record_lifetime_request(
persist=False,
provider=provider,
stack=None,
record_stack=False,
model=model,
input_tokens=input_tokens,
output_tokens=output_tokens,
attempted_input_tokens=attempted_input_tokens,
tokens_saved=tokens_saved,
cached=cached,
cache_read_tokens=cache_read_tokens,
cache_write_tokens=cache_write_tokens,
cache_write_5m_tokens=cache_write_5m_tokens,
cache_write_1h_tokens=cache_write_1h_tokens,
uncached_input_tokens=uncached_input_tokens,
waste_signals=waste_signals,
)
total_input_tokens, total_input_cost_usd = self._current_savings_tracker_totals()
self.savings_tracker.record_request(
model=model,
input_tokens=input_tokens,
tokens_saved=tokens_saved,
provider=provider,
project=project,
cache_read_tokens=cache_read_tokens,
cache_write_tokens=cache_write_tokens,
uncached_input_tokens=uncached_input_tokens,
total_input_tokens=total_input_tokens,
total_input_cost_usd=total_input_cost_usd,
output_tokens_saved=output_tokens_saved,
estimated_savings_usd=savings_usd,
)
# Also append to the durable, multi-process savings ledger so
# `headroom savings` reflects proxy traffic alongside MCP-tool usage.
# The real upstream model means litellm prices it accurately. The
# client is the harness classified from the User-Agent / X-Client
# (claude-code, codex, cursor, ...); it falls back to "proxy" only
# when the harness is unidentified.
#
# Deliberately outside `self._lock` and off the loop. The append does
# synchronous open + fcntl.flock + write, and rewrites the whole file
# once it passes 1 MB — and it fires on every compressed request. Under
# the lock that queued every other metrics caller behind the disk,
# including `export()`, which holds the same lock for the full
# Prometheus serialization. The ledger takes its own flock across
# processes, so the metrics lock was never what made it safe. Moving it
# out is not optional once it becomes an await: awaiting inside the lock
# would hold the lock for the whole write instead of just the syscall.
# ponytail: default thread pool, not a dedicated executor -- give it one
# if a profile ever shows writers parked on flock saturating the pool.
# tool_search_deferral saves tool-SCHEMA tokens that never move the
# message-level tok_before/after, so a tool-heavy turn can have
# tokens_saved=0 while genuinely deferring thousands of tokens. Fold that
# component into the ledger delta the same way the PERF headline and
# perf/analyzer do (`headline_before = before + tool_saved`); otherwise
# `headroom savings` understates real compression 7-10x on tool-search
# sessions and drops deferral-only turns from the ledger entirely (#2795).
deferral_saved = max(0, int(tool_search_saved))
ledger_saved = tokens_saved + deferral_saved
if ledger_saved > 0 and not self._stateless:
# `input_tokens` here is the optimized (post-compression) count
# that was actually forwarded — see emit_request_outcome, which
# passes `input_tokens=outcome.optimized_tokens`. The ledger's
# `before` is the pre-compression original and `after` is what we
# forwarded, and `headroom savings` derives the reduction percent
# as saved / before. Passing the forwarded count as `before`
# understated the original by `ledger_saved`, inflating that
# percentage (e.g. a real 40% reduction was reported as ~67%).
# Reconstruct the original as forwarded + saved.
await asyncio.to_thread(
savings_ledger.record_savings_event,
# The ledger stores a DELTA, so both ends must be on one ruler.
# `input_tokens` is the billed/volume figure and may be the
# provider's own count; pairing it with a locally-counted
# `tokens_saved` yields a mixed-ruler before/after (local 10->6
# with the provider reporting 8 would record 12->8). Use the
# caller's local count when supplied.
tokens_before=ledger_input_tokens + ledger_saved,
tokens_after=ledger_input_tokens,
model=model,
client=client or "proxy",
source="proxy",
)
otel_metrics = self._get_otel_metrics()
otel_metrics.record_proxy_request(
provider=provider,
model=model,
input_tokens=input_tokens,
output_tokens=output_tokens,
tokens_saved=tokens_saved,
tool_search_saved=tool_search_saved,
latency_ms=latency_ms,
cached=cached,
overhead_ms=overhead_ms,
ttfb_ms=ttfb_ms,
cache_read_tokens=cache_read_tokens,
cache_write_tokens=cache_write_tokens,
cache_write_5m_tokens=cache_write_5m_tokens,
cache_write_1h_tokens=cache_write_1h_tokens,
uncached_input_tokens=uncached_input_tokens,
attempted_input_tokens=attempted_input_tokens,
output_tokens_saved=output_tokens_saved,
savings_usd=savings_usd,
project=project,
client=client,
)
record_attribution = getattr(otel_metrics, "record_savings_attribution", None)
if record_attribution is not None and savings_attribution:
record_attribution(savings_attribution)
async def record_stage_timings(
self,
path: str,
timings: dict[str, float],
) -> None:
"""Record per-stage timings as histogram-style observations.
``path`` identifies the code path that emitted the timings (e.g.
``openai_responses_ws`` or ``anthropic_messages``). ``timings``
maps stage names to millisecond durations. Mirrors the
``transform_timing_*`` aggregation pattern so the ``/metrics``
endpoint exposes sum/count/max series per ``(path, stage)``.
Uses a tiny synchronous ``threading.Lock`` around the triple
update (sum + count + max) rather than the async
``self._lock``: (1) the updates have no awaits, so there is no
async contention benefit, and (2) the async lock is also held
by ``export()`` during Prometheus scrapes — which does
string-building while holding it. Under N concurrent request
finalizations + an active scrape, callers would queue behind
the scrape's string-building.
"""
if not timings:
return
with self._stage_timing_lock:
for stage, ms in timings.items():
try:
ms_val = float(ms)
except (TypeError, ValueError):
continue
key = (path, stage)
self.stage_timing_sum[key] += ms_val
self.stage_timing_count[key] += 1
if ms_val > self.stage_timing_max[key]:
self.stage_timing_max[key] = ms_val
async def record_cache_bust(self, tokens_lost: int) -> None:
"""Record tokens that lost their cache discount due to compression."""
async with self._lock:
self.cache_bust_tokens_lost += tokens_lost
self.cache_bust_count += 1
self.savings_tracker.record_lifetime_cache_bust(tokens_lost=tokens_lost)
self._get_otel_metrics().record_proxy_cache_bust(tokens_lost=tokens_lost)
async def record_cache_miss_attribution(self, provider: str, reason: str) -> None:
"""Record why a turn that expected a prompt-cache hit missed instead.
``reason`` is one of the MISS_* literals produced by
``PrefixCacheTracker.classify_cache_miss`` (``ttl_expiry``,
``prefix_change``, ``unknown``). Cold starts and hits are not recorded
— only actual misses against a previously-cached prefix reach here.
Bucketed per provider so the dashboard can scope or aggregate.
"""
async with self._lock:
self.cache_miss_attribution_by_provider[provider][reason] += 1
self.savings_tracker.record_lifetime_cache_miss(provider=provider, reason=reason)
# ------------------------------------------------------------------
# Unit 3: WS session lifecycle gauges / histogram
# ------------------------------------------------------------------
def inc_active_ws_sessions(self) -> None:
"""Increment the live WS session gauge (called on register)."""
self.active_ws_sessions += 1
def dec_active_ws_sessions(self) -> None:
"""Decrement the live WS session gauge (called on deregister)."""
self.active_ws_sessions = max(0, self.active_ws_sessions - 1)
def inc_active_relay_tasks(self, n: int = 1) -> None:
"""Increment the live relay-task gauge (attach_tasks)."""
self.active_relay_tasks += n
def dec_active_relay_tasks(self, n: int = 1) -> None:
"""Decrement the live relay-task gauge (deregister)."""
self.active_relay_tasks = max(0, self.active_relay_tasks - n)
def record_ws_session_duration(
self,
duration_ms: float,
cause: str = "unknown",
) -> None:
"""Record a completed WS session's duration, bucketed by cause.
Mirrors the ``stage_timing_*`` shape so ``/metrics`` exposes
sum/count/max per termination cause. Uses synchronous dict
updates (no ``_lock``) because Unit 3 callers run on the event
loop — matching the gauges above.
"""
try:
ms_val = float(duration_ms)
except (TypeError, ValueError):
return
self.ws_session_duration_sum_ms[cause] += ms_val
self.ws_session_duration_count[cause] += 1
if ms_val > self.ws_session_duration_max_ms[cause]:
self.ws_session_duration_max_ms[cause] = ms_val
async def record_rate_limited(self, *, provider: str | None = None, model: str | None = None):
async with self._lock:
self.requests_rate_limited += 1
self.savings_tracker.record_lifetime_rate_limited(provider=provider, model=model)
self._get_otel_metrics().record_proxy_rate_limited(provider=provider, model=model)
async def record_failed(self, *, provider: str | None = None, model: str | None = None):
async with self._lock:
self.requests_failed += 1
self.savings_tracker.record_lifetime_failed(provider=provider, model=model)
self._get_otel_metrics().record_proxy_failed(provider=provider, model=model)
async def export(self) -> str:
"""Export metrics in Prometheus format."""
# Snapshot stage-timing dicts under the tiny synchronous lock so
# we don't race a concurrent ``record_stage_timings`` and observe
# an inconsistent (sum, count, max) triple. Freeze into plain
# dicts so the scrape's string-building below doesn't hold the
# stage-timing lock during I/O-ish work.
with self._stage_timing_lock:
stage_timing_sum_snapshot = dict(self.stage_timing_sum)
stage_timing_count_snapshot = dict(self.stage_timing_count)
stage_timing_max_snapshot = dict(self.stage_timing_max)
async with self._lock:
lifetime_savings = self.savings_tracker.snapshot()["lifetime"]
lines: list[str] = []
_append_metric(
lines,
name="headroom_requests_total",
metric_type="counter",
help_text="Total number of requests",
value=self.requests_total,
)
_append_metric(
lines,
name="headroom_requests_cached_total",
metric_type="counter",
help_text="Cached request count",
value=self.requests_cached,
)
_append_metric(
lines,
name="headroom_requests_rate_limited_total",
metric_type="counter",
help_text="Rate limited requests",
value=self.requests_rate_limited,
)
_append_metric(
lines,
name="headroom_requests_failed_total",
metric_type="counter",
help_text="Failed requests",
value=self.requests_failed,
)
_append_metric(
lines,
name="headroom_inbound_requests_total",
metric_type="counter",
help_text="All inbound HTTP requests accepted by the proxy",
value=self.inbound_requests_total,
)
_append_metric(
lines,
name="headroom_inbound_requests_completed_total",
metric_type="counter",
help_text="Inbound HTTP requests completed or aborted by the proxy",
value=self.inbound_requests_completed,
)
_append_metric(
lines,
name="headroom_inbound_requests_active",
metric_type="gauge",
help_text="Inbound HTTP requests currently active in the proxy",
value=self.inbound_requests_active,
)
_append_metric(
lines,
name="headroom_tokens_input_total",
metric_type="counter",
help_text="Total input tokens",
value=self.tokens_input_total,
)
_append_metric(
lines,
name="headroom_tokens_output_total",
metric_type="counter",
help_text="Total output tokens",
value=self.tokens_output_total,
)
_append_metric(
lines,
name="headroom_tokens_saved_total",
metric_type="counter",
help_text="Tokens saved by optimization",
value=self.tokens_saved_total,
)
if self.savings_by_source:
lines.extend(
[
"# HELP headroom_savings_attribution_events_total Per-request savings attribution events",
"# TYPE headroom_savings_attribution_events_total counter",
]
)
for row in self.savings_by_source.values():
labels = _format_labels(
{"source": str(row["source"]), "realized": str(row["realized"]).lower()}
)
lines.append(
f"headroom_savings_attribution_events_total{labels} {row['events']}"
)
lines.extend(
[
"",
"# HELP headroom_savings_attributed_tokens_total Tokens attributed to a savings source",
"# TYPE headroom_savings_attributed_tokens_total counter",
]
)
for row in self.savings_by_source.values():
labels = _format_labels(
{"source": str(row["source"]), "realized": str(row["realized"]).lower()}
)
lines.append(
f"headroom_savings_attributed_tokens_total{labels} {row['tokens']}"
)
lines.extend(
[
"",
"# HELP headroom_savings_attributed_usd_total Cost savings attributed to a source; may be negative",
"# TYPE headroom_savings_attributed_usd_total gauge",
]
)
for row in self.savings_by_source.values():
labels = _format_labels(
{"source": str(row["source"]), "realized": str(row["realized"]).lower()}
)
lines.append(f"headroom_savings_attributed_usd_total{labels} {row['usd']}")
lines.append("")
_append_metric(
lines,
name="headroom_persistent_savings_requests_total",
metric_type="counter",
help_text="Durable lifetime requests recorded by the proxy savings tracker",
value=lifetime_savings["requests"],
)
_append_metric(
lines,
name="headroom_persistent_savings_tokens_saved_total",
metric_type="counter",
help_text="Durable lifetime input tokens saved by proxy compression",
value=lifetime_savings["tokens_saved"],
)
_append_metric(
lines,
name="headroom_persistent_savings_input_tokens_total",
metric_type="counter",
help_text="Durable lifetime input tokens recorded by the proxy savings tracker",
value=lifetime_savings["total_input_tokens"],
)
_append_metric(
lines,
name="headroom_persistent_savings_input_cost_usd_total",
metric_type="counter",
help_text="Durable lifetime input spend in USD estimated by the proxy savings tracker",
value=lifetime_savings["total_input_cost_usd"],
)
_append_metric(
lines,
name="headroom_persistent_savings_compression_savings_usd_total",
metric_type="counter",
help_text=(
"Durable lifetime compression savings in USD estimated by the "
"proxy savings tracker"
),
value=lifetime_savings["compression_savings_usd"],
)
# NOTE: per-strategy compression breakdown is tracked
# internally on `self.compressions_by_strategy` and
# `self.tokens_saved_by_strategy` (populated by
# `record_compression`) but **deliberately not exported
# here** as individual Prometheus series. The state is
# still observable via /stats + tests + programmatic
# introspection.
_append_metric(
lines,
name="headroom_latency_ms_sum",
metric_type="counter",
help_text="Sum of request latencies in milliseconds",
value=round(self.latency_sum_ms, 2),
)
_append_metric(
lines,
name="headroom_latency_ms_count",
metric_type="counter",
help_text="Count of observed request latencies",
value=self.latency_count,
)
_append_metric(
lines,
name="headroom_latency_ms_min",
metric_type="gauge",
help_text="Minimum observed request latency in milliseconds",
value=0 if self.latency_count == 0 else round(self.latency_min_ms, 2),
)
_append_metric(
lines,
name="headroom_latency_ms_max",
metric_type="gauge",
help_text="Maximum observed request latency in milliseconds",
value=round(self.latency_max_ms, 2),
)
_append_metric(
lines,
name="headroom_overhead_ms_sum",
metric_type="counter",
help_text="Sum of Headroom processing overhead in milliseconds",
value=round(self.overhead_sum_ms, 2),
)
_append_metric(
lines,
name="headroom_overhead_ms_count",
metric_type="counter",
help_text="Count of observed Headroom overhead samples",
value=self.overhead_count,
)
_append_metric(
lines,
name="headroom_overhead_ms_min",
metric_type="gauge",
help_text="Minimum observed Headroom overhead in milliseconds",
value=0 if self.overhead_count == 0 else round(self.overhead_min_ms, 2),
)
_append_metric(
lines,
name="headroom_overhead_ms_max",
metric_type="gauge",
help_text="Maximum observed Headroom overhead in milliseconds",
value=round(self.overhead_max_ms, 2),
)
_append_metric(
lines,
name="headroom_ttfb_ms_sum",
metric_type="counter",
help_text="Sum of time to first byte in milliseconds",
value=round(self.ttfb_sum_ms, 2),
)
_append_metric(
lines,
name="headroom_ttfb_ms_count",
metric_type="counter",
help_text="Count of observed time-to-first-byte samples",
value=self.ttfb_count,
)
_append_metric(
lines,
name="headroom_ttfb_ms_min",
metric_type="gauge",
help_text="Minimum observed time to first byte in milliseconds",
value=0 if self.ttfb_count == 0 else round(self.ttfb_min_ms, 2),
)
_append_metric(
lines,
name="headroom_ttfb_ms_max",
metric_type="gauge",
help_text="Maximum observed time to first byte in milliseconds",
value=round(self.ttfb_max_ms, 2),
)
_append_metric(
lines,
name="headroom_cache_bust_total",
metric_type="counter",
help_text="Requests that lost provider cache efficiency because of compression",
value=self.cache_bust_count,
)
_append_metric(
lines,
name="headroom_cache_bust_tokens_lost_total",
metric_type="counter",
help_text="Tokens that lost provider cache discount because of compression",
value=self.cache_bust_tokens_lost,
)
if self.cache_miss_attribution_by_provider:
lines.extend(
[
"# HELP headroom_cache_miss_attribution_total Cache misses on an "
"expected-cached prefix, bucketed by reason (ttl_expiry|prefix_change|unknown)",
"# TYPE headroom_cache_miss_attribution_total counter",
]
)
for _provider, _reasons in self.cache_miss_attribution_by_provider.items():
_safe_provider = _escape_label_value(str(_provider))
for _reason, _count in _reasons.items():
lines.append(
f'headroom_cache_miss_attribution_total{{provider="{_safe_provider}",'
f'reason="{_escape_label_value(str(_reason))}"}} {_count}'
)
lines.append("")
# Snapshot the off-thread observer counters under their own lock,
# then format outside it (see _obs_counter_lock).
with self._obs_counter_lock:
compression_failed = dict(self.compression_failed_by_reason)
upstream_conn_errors = dict(self.upstream_connection_errors_by_provider)
kompress_size_gate = dict(self.kompress_size_gate_by_outcome)
compression_quarantine = dict(self.compression_quarantine_by_event)
if upstream_conn_errors:
lines.extend(
[
"# HELP headroom_upstream_connection_errors_total Exhausted-retries upstream transport failures by provider; the proxy answered 502 itself because no upstream response arrived",
"# TYPE headroom_upstream_connection_errors_total counter",
]
)
for prov, count in upstream_conn_errors.items():
lines.append(
f'headroom_upstream_connection_errors_total{{provider="{_escape_label_value(prov)}"}} {count}'
)
lines.append("")
if compression_failed:
lines.extend(
[
"# HELP headroom_compression_failed_total Fail-open compression failures by reason",
"# TYPE headroom_compression_failed_total counter",
]
)
for reason, count in compression_failed.items():
lines.append(
f'headroom_compression_failed_total{{reason="{_escape_label_value(reason)}"}} {count}'
)
lines.append("")
if kompress_size_gate:
lines.extend(
[
"# HELP headroom_kompress_size_gate_total Kompress size-gate decisions by outcome; within counts a gate pass, not whether ML compression then ran",
"# TYPE headroom_kompress_size_gate_total counter",
]
)
for outcome, count in kompress_size_gate.items():
lines.append(
f'headroom_kompress_size_gate_total{{outcome="{_escape_label_value(outcome)}"}} {count}'
)
lines.append("")
if compression_quarantine:
lines.extend(
[
"# HELP headroom_compression_quarantine_total Timeout-debt quarantine events by type",
"# TYPE headroom_compression_quarantine_total counter",
]
)
for event, count in compression_quarantine.items():
lines.append(
f'headroom_compression_quarantine_total{{event="{_escape_label_value(event)}"}} {count}'
)
lines.append("")
lines.extend(
[
"# HELP headroom_requests_by_provider Requests by provider",
"# TYPE headroom_requests_by_provider counter",
]
)
for provider, count in self.requests_by_provider.items():
lines.append(
f'headroom_requests_by_provider{{provider="{_escape_label_value(str(provider))}"}} {count}'
)
lines.append("")
lines.extend(
[
"# HELP headroom_requests_by_model Requests by model",
"# TYPE headroom_requests_by_model counter",
]
)
for model, count in self.requests_by_model.items():
lines.append(
f'headroom_requests_by_model{{model="{_escape_label_value(str(model))}"}} {count}'
)
lines.append("")
if self.transform_timing_sum:
lines.extend(
[
"# HELP headroom_transform_timing_ms_sum Sum of transform timing in milliseconds",
"# TYPE headroom_transform_timing_ms_sum counter",
]
)
for name, total in self.transform_timing_sum.items():
lines.append(
f'headroom_transform_timing_ms_sum{{transform="{_escape_label_value(name)}"}} {round(total, 2)}'
)
lines.extend(
[
"",
"# HELP headroom_transform_timing_ms_count Count of transform timing samples",
"# TYPE headroom_transform_timing_ms_count counter",
]
)
for name, count in self.transform_timing_count.items():
lines.append(
f'headroom_transform_timing_ms_count{{transform="{_escape_label_value(name)}"}} {count}'
)
lines.extend(
[
"",
"# HELP headroom_transform_timing_ms_max Maximum transform timing in milliseconds",
"# TYPE headroom_transform_timing_ms_max gauge",
]
)
for name, max_value in self.transform_timing_max.items():
lines.append(
f'headroom_transform_timing_ms_max{{transform="{_escape_label_value(name)}"}} {round(max_value, 2)}'
)
lines.append("")
if stage_timing_sum_snapshot:
lines.extend(
[
"# HELP headroom_stage_timing_ms_sum Sum of per-stage handler timings in milliseconds",
"# TYPE headroom_stage_timing_ms_sum counter",
]
)
for (path_label, stage), total in stage_timing_sum_snapshot.items():
lines.append(
f'headroom_stage_timing_ms_sum{{path="{_escape_label_value(path_label)}",stage="{_escape_label_value(stage)}"}} {round(total, 2)}'
)
lines.extend(
[
"",
"# HELP headroom_stage_timing_ms_count Count of per-stage handler timing samples",
"# TYPE headroom_stage_timing_ms_count counter",
]
)
for (path_label, stage), count in stage_timing_count_snapshot.items():
lines.append(
f'headroom_stage_timing_ms_count{{path="{_escape_label_value(path_label)}",stage="{_escape_label_value(stage)}"}} {count}'
)
lines.extend(
[
"",
"# HELP headroom_stage_timing_ms_max Maximum per-stage handler timing in milliseconds",
"# TYPE headroom_stage_timing_ms_max gauge",
]
)
for (path_label, stage), max_value in stage_timing_max_snapshot.items():
lines.append(
f'headroom_stage_timing_ms_max{{path="{_escape_label_value(path_label)}",stage="{_escape_label_value(stage)}"}} {round(max_value, 2)}'
)
lines.append("")
# Unit 3: WS session lifecycle gauges + duration histogram.
lines.extend(
[
"# HELP headroom_active_ws_sessions Active Codex WebSocket sessions",
"# TYPE headroom_active_ws_sessions gauge",
f"headroom_active_ws_sessions {self.active_ws_sessions}",
"",
"# HELP headroom_active_relay_tasks Active Codex WS relay tasks",
"# TYPE headroom_active_relay_tasks gauge",
f"headroom_active_relay_tasks {self.active_relay_tasks}",
"",
]
)
if self.ws_session_duration_sum_ms:
lines.extend(
[
"# HELP headroom_ws_session_duration_ms_sum Sum of Codex WS session durations",
"# TYPE headroom_ws_session_duration_ms_sum counter",
]
)
for cause, total in self.ws_session_duration_sum_ms.items():
lines.append(
f'headroom_ws_session_duration_ms_sum{{cause="{_escape_label_value(cause)}"}} {round(total, 2)}'
)
lines.extend(
[
"",
"# HELP headroom_ws_session_duration_ms_count Count of completed Codex WS sessions",
"# TYPE headroom_ws_session_duration_ms_count counter",
]
)
for cause, count in self.ws_session_duration_count.items():
lines.append(
f'headroom_ws_session_duration_ms_count{{cause="{_escape_label_value(cause)}"}} {count}'
)
lines.extend(
[
"",
"# HELP headroom_ws_session_duration_ms_max Maximum Codex WS session duration",
"# TYPE headroom_ws_session_duration_ms_max gauge",
]
)
for cause, max_value in self.ws_session_duration_max_ms.items():
lines.append(
f'headroom_ws_session_duration_ms_max{{cause="{_escape_label_value(cause)}"}} {round(max_value, 2)}'
)
lines.append("")
if self.waste_signals_total:
lines.extend(
[
"# HELP headroom_waste_signal_tokens_total Tokens attributed to detected waste signals",
"# TYPE headroom_waste_signal_tokens_total counter",
]
)
for signal_name, token_count in self.waste_signals_total.items():
lines.append(
f'headroom_waste_signal_tokens_total{{signal="{_escape_label_value(signal_name)}"}} {token_count}'
)
lines.append("")
if self.cache_by_provider:
# The exposition format wants each family's samples grouped, so the
# blocks below re-walk this dict once per family. Escape the provider
# keys once here instead of at all eleven emission sites.
cache_by_provider = {
_escape_label_value(str(name)): stats
for name, stats in self.cache_by_provider.items()
}
lines.extend(
[
"# HELP headroom_cache_read_tokens_total Provider cache read tokens",
"# TYPE headroom_cache_read_tokens_total counter",
]
)
for provider, stats in cache_by_provider.items():
lines.append(
f'headroom_cache_read_tokens_total{{provider="{provider}"}} {stats["cache_read_tokens"]}'
)
lines.extend(
[
"",
"# HELP headroom_cache_write_tokens_total Provider cache write tokens",
"# TYPE headroom_cache_write_tokens_total counter",
]
)
for provider, stats in cache_by_provider.items():
lines.append(
f'headroom_cache_write_tokens_total{{provider="{provider}"}} {stats["cache_write_tokens"]}'
)
lines.extend(
[
"",
"# HELP headroom_cache_write_ttl_tokens_total Provider cache write tokens by observed TTL bucket",
"# TYPE headroom_cache_write_ttl_tokens_total counter",
]
)
for provider, stats in cache_by_provider.items():
lines.append(
f'headroom_cache_write_ttl_tokens_total{{provider="{provider}",ttl="5m"}} {stats["cache_write_5m_tokens"]}'
)
lines.append(
f'headroom_cache_write_ttl_tokens_total{{provider="{provider}",ttl="1h"}} {stats["cache_write_1h_tokens"]}'
)
lines.extend(
[
"",
"# HELP headroom_cache_write_ttl_requests_total Provider cache write requests by observed TTL bucket",
"# TYPE headroom_cache_write_ttl_requests_total counter",
]
)
for provider, stats in cache_by_provider.items():
lines.append(
f'headroom_cache_write_ttl_requests_total{{provider="{provider}",ttl="5m"}} {stats["cache_write_5m_requests"]}'
)
lines.append(
f'headroom_cache_write_ttl_requests_total{{provider="{provider}",ttl="1h"}} {stats["cache_write_1h_requests"]}'
)
lines.extend(
[
"",
"# HELP headroom_uncached_input_tokens_total Input tokens not served from provider cache",
"# TYPE headroom_uncached_input_tokens_total counter",
]
)
for provider, stats in cache_by_provider.items():
lines.append(
f'headroom_uncached_input_tokens_total{{provider="{provider}"}} {stats["uncached_input_tokens"]}'
)
lines.extend(
[
"",
"# HELP headroom_provider_cache_requests_total Requests with provider cache observations",
"# TYPE headroom_provider_cache_requests_total counter",
]
)
for provider, stats in cache_by_provider.items():
lines.append(
f'headroom_provider_cache_requests_total{{provider="{provider}"}} {stats["requests"]}'
)
lines.extend(
[
"",
"# HELP headroom_provider_cache_hit_requests_total Requests with provider cache reads",
"# TYPE headroom_provider_cache_hit_requests_total counter",
]
)
for provider, stats in cache_by_provider.items():
lines.append(
f'headroom_provider_cache_hit_requests_total{{provider="{provider}"}} {stats["hit_requests"]}'
)
lines.extend(
[
"",
"# HELP headroom_provider_cache_bust_total Provider-specific cache bust count",
"# TYPE headroom_provider_cache_bust_total counter",
]
)
for provider, stats in cache_by_provider.items():
lines.append(
f'headroom_provider_cache_bust_total{{provider="{provider}"}} {stats["bust_count"]}'
)
lines.extend(
[
"",
"# HELP headroom_provider_cache_bust_write_tokens_total Provider cache write tokens attributed to busts",
"# TYPE headroom_provider_cache_bust_write_tokens_total counter",
]
)
for provider, stats in cache_by_provider.items():
lines.append(
f'headroom_provider_cache_bust_write_tokens_total{{provider="{provider}"}} {stats["bust_write_tokens"]}'
)
lines.append("")
# Phase G PR-G3 remediation (C3): image-redacted counter
# lives Python-side because base64 redaction is purely a
# Python-proxy concern (request_logger.py). The Rust
# proxy previously held a dead counter for this; that's
# been removed in favour of this Python export.
#
# The counter is read at scrape-time from the module-
# level redaction tracker rather than mirrored into the
# PrometheusMetrics instance, so we never lose a count
# to ordering between RequestLogger setup and metrics
# init.
from headroom.proxy.request_logger import redactions_total
_append_metric(
lines,
name="proxy_image_generation_call_log_redacted_total",
metric_type="counter",
help_text=(
"Count of base64-encoded image payloads redacted from request "
"logs by the Python proxy's request logger"
),
value=redactions_total(),
)
return "\n".join(lines)