headroom/tests/test_proxy_cache_ttl_metrics.py
Lakshya Sharma 4658721ea0
feat(cache): attribute prompt-cache misses to TTL lapse vs prefix change (#1313) (#1343)
## Description

A low prompt-cache hit rate is hard to act on without knowing *why*
turns miss. Two very different causes need very different responses:

- **TTL lapse** — the session went idle longer than the provider's cache
lifetime, so the entry expired. The fix is a longer TTL (e.g.
Anthropic's 1h breakpoint instead of the 5m default).
- **Prefix change** — the cacheable message prefix shifted, so the new
request couldn't match the cached key. A longer TTL won't help here at
all.

Right now those look identical from the dashboard (just "cache_read was
0"). This adds the attribution so a user can actually decide 5m vs 1h.

Closes #1313

## Type of Change

- [ ] Bug fix (non-breaking change that fixes an issue)
- [x] 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

`PrefixCacheTracker` already kept the previous turn's forwarded messages
and a per-turn activity timestamp, so the signal was already there — it
just wasn't being read.

- **`prefix_tracker.py`** — `classify_cache_miss()`: when a turn
expected a cached prefix (non-zero cached tokens last turn) but read 0
this turn, returns `ttl_expiry` if the idle gap exceeded the provider
cache TTL, else `prefix_change` if the forwarded prefix differs from
last turn's, else `unknown`. **TTL wins ties** — once the entry lapsed,
a coincident content change is moot, and the 5m-vs-1h decision is
exactly what the TTL signal answers. A 1h-breakpoint session can widen
the window via `PrefixFreezeConfig.cache_ttl_seconds`. Cold starts and
hits return `is_miss=False`.
- **Anthropic handlers (streaming + non-streaming)** — classify BEFORE
`update_from_response` overwrites the last-turn state the classifier
reads, then record the reason.
- **`prometheus_metrics.py`** — a per-provider/per-reason counter,
`record_cache_miss_attribution()`, reset handling, and a
`headroom_cache_miss_attribution_total{provider,reason}` export series.
- **`cost.py`** — `build_prefix_cache_stats()` aggregates a
`miss_attribution` block (per-provider + totals, with the ttl/prefix
split as a % of *attributed* misses, so `unknown` doesn't dilute the
headline).
- **dashboard** — a "Cache Miss Attribution" panel (TTL expiry / prefix
change / unknown / total) with a "mostly TTL lapse" vs "mostly prefix
change" headline.

Scoped to Anthropic for this first cut (where the tracker is fully
wired); OpenAI/Gemini can follow once the shape is proven.

## Testing

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

### Test Output

```text
$ python -m pytest tests/test_cache/test_prefix_tracker.py -q
38 passed
# 29 existing + 9 new classifier tests (TestClassifyCacheMiss).

$ python -m pytest tests/test_proxy_cache_ttl_metrics.py -k "miss_attribution or reset_runtime_clears" -q
5 passed, 8 deselected
# new: counter bucketing, stats aggregation, empty case, /metrics export, reset.
```

The full `test_proxy_cache_ttl_metrics.py` /
`test_proxy_dashboard_stats_cache.py` files have some failures in this
sandbox (`test_stats_endpoint_*`, streaming-parser, reset-counters) —
those spin up the proxy server / Rust `_core` extension, which isn't
built here. I confirmed via `git stash` that they fail identically on
`main` without my changes, so they're pre-existing and unrelated. My
additions to the stats dict are purely additive and don't break any
passing assertion.

## Real Behavior Proof

- Environment: Windows 11, Python 3.10. The Rust `_core` extension and a
live proxy aren't available in this checkout.
- Exact command / steps: drove `classify_cache_miss()` through every
branch with a faithful warm-then-miss sequence; drove
`record_cache_miss_attribution()` → `build_prefix_cache_stats()` →
`export()` end to end.
- Observed result: classifier returns
`cold_start`/`hit`/`ttl_expiry`/`prefix_change`/`unknown` correctly, TTL
wins the tie when both signals fire, a growing (append-only) prefix is
treated as stable, and the 1h override widens the window. The stats
builder produces `miss_attribution.totals`
(`ttl_expiry`/`prefix_change`/`unknown`/`total` +
`ttl_expiry_pct`/`prefix_change_pct` over attributed misses) and
`by_provider`; `/metrics` emits
`headroom_cache_miss_attribution_total{provider="anthropic",reason="ttl_expiry"}`.
- Not tested: a live Anthropic session through the running proxy with a
real idle-then-resume to confirm the handler wiring fires end-to-end. I
verified the handler integration by reading scope/order (classify before
`update_from_response`, `provider_name`/`self.metrics` in scope) and
unit-tested every layer it calls, but didn't exercise the actual server
loop.

## 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
- [ ] 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
- [ ] I have updated the CHANGELOG.md if applicable

## Additional Notes

- The classifier is intentionally pure (takes the cache-read result +
current forwarded messages + an optional idle override) so it's
order-independent and unit-testable without a live tracker clock.
- No README/docs change yet — this surfaces in the dashboard and
`/metrics`, which are self-describing; happy to add a docs page if you'd
like one.
- CHANGELOG.md isn't touched — release-please generates it from the
`feat(cache):` commit subject.
- Follow-ups if useful: extend to OpenAI/Gemini handlers, and add a
per-provider breakdown row in the dashboard panel (the stats already
carry `by_provider`).
2026-06-24 09:50:34 -05:00

321 lines
11 KiB
Python

"""Tests for observed Anthropic cache TTL bucket metrics."""
from __future__ import annotations
import asyncio
import pytest
from headroom.observability import reset_headroom_tracing, reset_otel_metrics
from headroom.proxy.cost import CostTracker, build_prefix_cache_stats
from headroom.proxy.prometheus_metrics import PrometheusMetrics
def test_prometheus_metrics_tracks_observed_ttl_buckets() -> None:
metrics = PrometheusMetrics()
asyncio.run(
metrics.record_request(
provider="anthropic",
model="claude-opus-4-6",
input_tokens=100,
output_tokens=20,
tokens_saved=5,
latency_ms=10.0,
cache_read_tokens=40,
cache_write_tokens=60,
cache_write_5m_tokens=10,
cache_write_1h_tokens=50,
)
)
stats = metrics.cache_by_provider["anthropic"]
assert stats["cache_write_5m_tokens"] == 10
assert stats["cache_write_1h_tokens"] == 50
assert stats["cache_write_5m_requests"] == 1
assert stats["cache_write_1h_requests"] == 1
def test_cost_tracker_exposes_observed_ttl_buckets_per_model() -> None:
tracker = CostTracker()
tracker.record_tokens(
"claude-opus-4-6",
tokens_saved=10,
tokens_sent=90,
cache_read_tokens=40,
cache_write_tokens=60,
cache_write_5m_tokens=10,
cache_write_1h_tokens=50,
uncached_tokens=20,
)
stats = tracker.stats()
assert stats["cache_write_5m_tokens"] == 10
assert stats["cache_write_1h_tokens"] == 50
assert stats["per_model"]["claude-opus-4-6"]["cache_write_5m_tokens"] == 10
assert stats["per_model"]["claude-opus-4-6"]["cache_write_1h_tokens"] == 50
def test_prefix_cache_stats_include_observed_ttl_mix() -> None:
metrics = PrometheusMetrics()
provider_stats = metrics.cache_by_provider["anthropic"]
provider_stats["requests"] = 2
provider_stats["hit_requests"] = 1
provider_stats["cache_read_tokens"] = 40
provider_stats["cache_write_tokens"] = 60
provider_stats["cache_write_5m_tokens"] = 15
provider_stats["cache_write_1h_tokens"] = 45
provider_stats["cache_write_5m_requests"] = 1
provider_stats["cache_write_1h_requests"] = 1
stats = build_prefix_cache_stats(metrics, None)
anthropic = stats["by_provider"]["anthropic"]
assert anthropic["observed_ttl_buckets"]["5m"]["tokens"] == 15
assert anthropic["observed_ttl_buckets"]["1h"]["tokens"] == 45
assert anthropic["observed_ttl_mix"]["5m_pct"] == 25.0
assert anthropic["observed_ttl_mix"]["1h_pct"] == 75.0
assert stats["totals"]["observed_ttl_buckets"]["5m"]["tokens"] == 15
assert stats["totals"]["observed_ttl_buckets"]["1h"]["tokens"] == 45
def test_prometheus_metrics_export_includes_extended_fields() -> None:
metrics = PrometheusMetrics()
asyncio.run(
metrics.record_request(
provider="anthropic",
model="claude-opus-4-6",
input_tokens=100,
output_tokens=20,
tokens_saved=5,
latency_ms=12.5,
overhead_ms=3.0,
ttfb_ms=9.0,
pipeline_timing={"router": 4.5},
waste_signals={"json_bloat": 7},
cache_read_tokens=40,
cache_write_tokens=60,
cache_write_5m_tokens=10,
cache_write_1h_tokens=50,
uncached_input_tokens=20,
)
)
asyncio.run(metrics.record_cache_bust(11))
exported = asyncio.run(metrics.export())
assert "headroom_latency_ms_count 1" in exported
assert 'headroom_transform_timing_ms_sum{transform="router"} 4.5' in exported
assert 'headroom_waste_signal_tokens_total{signal="json_bloat"} 7' in exported
assert 'headroom_cache_write_ttl_tokens_total{provider="anthropic",ttl="5m"} 10' in exported
assert 'headroom_provider_cache_hit_requests_total{provider="anthropic"} 1' in exported
assert "headroom_cache_bust_tokens_lost_total 11" in exported
def test_streaming_parser_extracts_anthropic_ttl_bucket_usage() -> None:
from headroom.proxy.server import HeadroomProxy, ProxyConfig
proxy = HeadroomProxy(
ProxyConfig(
optimize=False,
cache_enabled=False,
rate_limit_enabled=False,
cost_tracking_enabled=False,
log_requests=False,
ccr_inject_tool=False,
ccr_handle_responses=False,
ccr_context_tracking=False,
)
)
chunk = (
b'data: {"type":"message_start","message":{"usage":{"input_tokens":12,'
b'"cache_read_input_tokens":3,"cache_creation_input_tokens":9,'
b'"cache_creation":{"ephemeral_5m_input_tokens":4,"ephemeral_1h_input_tokens":5}}}}\n\n'
)
usage = proxy._parse_sse_usage(chunk, "anthropic")
assert usage is not None
assert usage["cache_creation_ephemeral_5m_input_tokens"] == 4
assert usage["cache_creation_ephemeral_1h_input_tokens"] == 5
def test_stats_endpoint_reports_observed_ttl_buckets() -> None:
pytest.importorskip("fastapi")
from fastapi.testclient import TestClient
from headroom.proxy.server import ProxyConfig, create_app
app = create_app(
ProxyConfig(
optimize=False,
cache_enabled=False,
rate_limit_enabled=False,
cost_tracking_enabled=False,
log_requests=False,
ccr_inject_tool=False,
ccr_handle_responses=False,
ccr_context_tracking=False,
)
)
proxy = app.state.proxy
provider_stats = proxy.metrics.cache_by_provider["anthropic"]
provider_stats["requests"] = 1
provider_stats["hit_requests"] = 1
provider_stats["cache_read_tokens"] = 30
provider_stats["cache_write_tokens"] = 70
provider_stats["cache_write_5m_tokens"] = 20
provider_stats["cache_write_1h_tokens"] = 50
provider_stats["cache_write_5m_requests"] = 1
provider_stats["cache_write_1h_requests"] = 1
with TestClient(app) as client:
response = client.get("/stats")
assert response.status_code == 200
prefix_cache = response.json()["prefix_cache"]
anthropic = prefix_cache["by_provider"]["anthropic"]
assert anthropic["observed_ttl_buckets"]["5m"]["tokens"] == 20
assert anthropic["observed_ttl_buckets"]["1h"]["tokens"] == 50
assert prefix_cache["totals"]["observed_ttl_mix"]["active_buckets"] == ["5m", "1h"]
def test_stats_endpoint_reports_otel_configuration(monkeypatch: pytest.MonkeyPatch) -> None:
pytest.importorskip("fastapi")
from fastapi.testclient import TestClient
from headroom.proxy.server import ProxyConfig, create_app
reset_otel_metrics()
monkeypatch.setenv("HEADROOM_OTEL_METRICS_ENABLED", "1")
monkeypatch.setenv("HEADROOM_OTEL_METRICS_EXPORTER", "console")
app = create_app(
ProxyConfig(
optimize=False,
cache_enabled=False,
rate_limit_enabled=False,
cost_tracking_enabled=False,
log_requests=False,
ccr_inject_tool=False,
ccr_handle_responses=False,
ccr_context_tracking=False,
)
)
with TestClient(app) as client:
response = client.get("/stats")
assert response.status_code == 200
otel = response.json()["otel"]
assert otel["configured"] is True
assert otel["enabled"] is True
assert otel["service_name"] == "headroom-proxy"
assert otel["exporter"] == "console"
def test_stats_endpoint_reports_langfuse_configuration(monkeypatch: pytest.MonkeyPatch) -> None:
pytest.importorskip("fastapi")
from fastapi.testclient import TestClient
from headroom.proxy.server import ProxyConfig, create_app
reset_headroom_tracing()
monkeypatch.setenv("HEADROOM_LANGFUSE_ENABLED", "1")
monkeypatch.setenv("LANGFUSE_PUBLIC_KEY", "pk-lf-test")
monkeypatch.setenv("LANGFUSE_SECRET_KEY", "sk-lf-test")
monkeypatch.setenv("LANGFUSE_BASE_URL", "https://cloud.langfuse.com")
app = create_app(
ProxyConfig(
optimize=False,
cache_enabled=False,
rate_limit_enabled=False,
cost_tracking_enabled=False,
log_requests=False,
ccr_inject_tool=False,
ccr_handle_responses=False,
ccr_context_tracking=False,
)
)
with TestClient(app) as client:
response = client.get("/stats")
assert response.status_code == 200
langfuse = response.json()["langfuse"]
assert langfuse["configured"] is True
assert langfuse["enabled"] is True
assert langfuse["service_name"] == "headroom-proxy"
assert langfuse["endpoint"] == "https://cloud.langfuse.com/api/public/otel/v1/traces"
# --- Cache-miss attribution (#1313) ---
def test_record_cache_miss_attribution_buckets_by_provider_and_reason() -> None:
metrics = PrometheusMetrics()
asyncio.run(metrics.record_cache_miss_attribution("anthropic", "ttl_expiry"))
asyncio.run(metrics.record_cache_miss_attribution("anthropic", "ttl_expiry"))
asyncio.run(metrics.record_cache_miss_attribution("anthropic", "prefix_change"))
asyncio.run(metrics.record_cache_miss_attribution("anthropic", "unknown"))
buckets = metrics.cache_miss_attribution_by_provider["anthropic"]
assert buckets["ttl_expiry"] == 2
assert buckets["prefix_change"] == 1
assert buckets["unknown"] == 1
def test_prefix_cache_stats_include_miss_attribution() -> None:
metrics = PrometheusMetrics()
asyncio.run(metrics.record_cache_miss_attribution("anthropic", "ttl_expiry"))
asyncio.run(metrics.record_cache_miss_attribution("anthropic", "ttl_expiry"))
asyncio.run(metrics.record_cache_miss_attribution("anthropic", "prefix_change"))
asyncio.run(metrics.record_cache_miss_attribution("anthropic", "unknown"))
stats = build_prefix_cache_stats(metrics, None)
ma = stats["miss_attribution"]
assert ma["totals"]["ttl_expiry"] == 2
assert ma["totals"]["prefix_change"] == 1
assert ma["totals"]["unknown"] == 1
assert ma["totals"]["total"] == 4
# Percentages are over attributed (non-unknown) misses: 2 / 3, 1 / 3.
assert ma["totals"]["ttl_expiry_pct"] == 66.7
assert ma["totals"]["prefix_change_pct"] == 33.3
assert ma["by_provider"]["anthropic"]["total"] == 4
def test_prefix_cache_stats_miss_attribution_empty_when_no_misses() -> None:
metrics = PrometheusMetrics()
stats = build_prefix_cache_stats(metrics, None)
ma = stats["miss_attribution"]
assert ma["totals"]["total"] == 0
assert ma["totals"]["ttl_expiry_pct"] == 0.0
assert ma["by_provider"] == {}
def test_prometheus_export_includes_miss_attribution() -> None:
metrics = PrometheusMetrics()
asyncio.run(metrics.record_cache_miss_attribution("anthropic", "ttl_expiry"))
asyncio.run(metrics.record_cache_miss_attribution("anthropic", "prefix_change"))
exported = asyncio.run(metrics.export())
assert (
'headroom_cache_miss_attribution_total{provider="anthropic",reason="ttl_expiry"} 1'
in exported
)
assert (
'headroom_cache_miss_attribution_total{provider="anthropic",reason="prefix_change"} 1'
in exported
)
def test_reset_runtime_clears_miss_attribution() -> None:
metrics = PrometheusMetrics()
asyncio.run(metrics.record_cache_miss_attribution("anthropic", "ttl_expiry"))
asyncio.run(metrics.reset_runtime())
assert dict(metrics.cache_miss_attribution_by_provider) == {}