Skip to content

Commit 5fe4e7b

Browse files
authored
fix(proxy): expose persistent savings metrics (#1647)
## Description Closes #1616 Expose the proxy's durable `persistent_savings.lifetime` totals through `/metrics` so Prometheus/Grafana scrapes can read the same lifetime savings counters already visible in `/stats` and `/stats-history`. The existing runtime counters remain process-local: `headroom_tokens_saved_total` still resets with the proxy process. New `headroom_persistent_savings_*` counters are sourced from the `SavingsTracker` lifetime block. ## Type of Change - [x] 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) - [x] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Export durable lifetime savings counters from `PrometheusMetrics.export()`: - `headroom_persistent_savings_requests_total` - `headroom_persistent_savings_tokens_saved_total` - `headroom_persistent_savings_input_tokens_total` - `headroom_persistent_savings_input_cost_usd_total` - `headroom_persistent_savings_compression_savings_usd_total` - Add a restart regression proving runtime counters reset while persistent savings counters remain available from the same savings file. - Extend the existing `/stats-history` restart test with `/metrics` endpoint assertions. - Update metrics docs to distinguish runtime `headroom_tokens_saved_total` from lifetime `headroom_persistent_savings_tokens_saved_total`. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text Local focused checks: $ rtk /usr/bin/env HEADROOM_REQUIRE_RUST_CORE=false PYTHONPATH=. /tmp/headroom-1616-testenv/bin/python -m pytest tests/test_proxy_cache_ttl_metrics.py::test_prometheus_metrics_export_includes_extended_fields tests/test_proxy_cache_ttl_metrics.py::test_prometheus_export_includes_persistent_savings_after_restart 2 passed, 1 warning in 0.19s $ rtk /tmp/headroom-1616-testenv/bin/python -m ruff check headroom/proxy/prometheus_metrics.py tests/test_proxy_cache_ttl_metrics.py tests/test_proxy_savings_history.py All checks passed! $ rtk /tmp/headroom-1616-testenv/bin/python -m ruff format --check headroom/proxy/prometheus_metrics.py tests/test_proxy_cache_ttl_metrics.py tests/test_proxy_savings_history.py 3 files already formatted $ rtk git diff --check # no output GitHub Actions: All non-skipped checks passed on PR #1647, including lint, build, build-wheel, test (1-4), test-agno, test-extras, test-dashboard-ui, docker-native-e2e, docker-init-e2e, docker-wrap-e2e, security checks, merge-conflicts, and PR governance. ``` ## Real Behavior Proof - Environment: local macOS worktree, throwaway Python env at `/tmp/headroom-1616-testenv`, `PYTHONPATH=.`. - Exact command / steps: recorded a compressed request through `PrometheusMetrics.record_request()`, re-created `PrometheusMetrics` with the same `SavingsTracker` path, then exported `/metrics` text. - Observed result: runtime counters are zero after re-creating the metrics object, while `headroom_persistent_savings_tokens_saved_total` and related persistent counters still expose the durable lifetime values. - Not tested: full server-level pytest locally, because the local build is blocked by the known native `headroom._core`/`esaxx-rs` build issue (`fatal error: 'cstdint' file not found`). The app-level `/metrics` assertions passed in GitHub Actions. ## 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 - [ ] 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 - [ ] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A ## Additional Notes This intentionally does not rename or hydrate the existing runtime `headroom_tokens_saved_total` counter. That preserves the current process-local semantics and gives external dashboards a dedicated lifetime series that maps directly to `/stats.persistent_savings`. `mypy headroom` was not run as a standalone local command. CHANGELOG is N/A for this narrow proxy metrics fix unless maintainers prefer an entry.
1 parent c600e31 commit 5fe4e7b

5 files changed

Lines changed: 102 additions & 4 deletions

File tree

docs/content/docs/metrics.mdx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,9 @@ headroom_requests_total{mode="optimize"} 1234
7272
# HELP headroom_tokens_saved_total Total tokens saved
7373
headroom_tokens_saved_total 5678900
7474
75+
# HELP headroom_persistent_savings_tokens_saved_total Durable lifetime input tokens saved by proxy compression
76+
headroom_persistent_savings_tokens_saved_total 5678900
77+
7578
# HELP headroom_compression_ratio Compression ratio histogram
7679
headroom_compression_ratio_bucket{le="0.5"} 890
7780
headroom_compression_ratio_bucket{le="0.7"} 1100
@@ -253,7 +256,8 @@ When the budget is exceeded, requests return a budget exceeded error, the `/stat
253256

254257
| Metric | What It Tells You | Target |
255258
|--------|-------------------|--------|
256-
| `tokens_saved_total` | Total cost savings | Higher is better |
259+
| `headroom_tokens_saved_total` | Runtime tokens saved since this proxy process started | Higher is better |
260+
| `headroom_persistent_savings_tokens_saved_total` | Durable lifetime tokens saved from `/stats.persistent_savings` | Higher is better |
257261
| `compression_ratio_avg` | Efficiency | 0.7--0.9 typical |
258262
| `cache_hit_rate` | Cache effectiveness | >20% is good |
259263
| `latency_p99` | Performance impact | <10ms |
@@ -265,7 +269,8 @@ Example Prometheus queries for a Grafana dashboard:
265269

266270
| Panel | PromQL |
267271
|-------|--------|
268-
| Tokens Saved | `headroom_tokens_saved_total` |
272+
| Runtime Tokens Saved | `headroom_tokens_saved_total` |
273+
| Lifetime Tokens Saved | `headroom_persistent_savings_tokens_saved_total` |
269274
| Compression Ratio (median) | `histogram_quantile(0.5, headroom_compression_ratio_bucket)` |
270275
| Request Latency (p99) | `histogram_quantile(0.99, headroom_latency_seconds_bucket)` |
271276
| Cache Hit Rate | `headroom_cache_hits_total / (headroom_cache_hits_total + headroom_cache_misses_total)` |

docs/content/docs/proxy.mdx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,11 +151,14 @@ curl http://localhost:8787/metrics
151151
```
152152
headroom_requests_total{mode="optimize"} 1234
153153
headroom_tokens_saved_total 5678900
154+
headroom_persistent_savings_tokens_saved_total 5678900
154155
headroom_compression_ratio_bucket{le="0.5"} 890
155156
headroom_latency_seconds_bucket{le="0.01"} 800
156157
headroom_cache_hits_total 456
157158
```
158159

160+
`headroom_tokens_saved_total` is the runtime counter for the current proxy process. Use `headroom_persistent_savings_tokens_saved_total` for durable lifetime savings that match `/stats.persistent_savings`.
161+
159162
### `POST /v1/messages`
160163

161164
Anthropic API format. The proxy compresses messages, forwards to Anthropic, and returns the response.

headroom/proxy/prometheus_metrics.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -825,6 +825,7 @@ async def export(self) -> str:
825825
stage_timing_count_snapshot = dict(self.stage_timing_count)
826826
stage_timing_max_snapshot = dict(self.stage_timing_max)
827827
async with self._lock:
828+
lifetime_savings = self.savings_tracker.snapshot()["lifetime"]
828829
lines: list[str] = []
829830
_append_metric(
830831
lines,
@@ -896,6 +897,44 @@ async def export(self) -> str:
896897
help_text="Tokens saved by optimization",
897898
value=self.tokens_saved_total,
898899
)
900+
_append_metric(
901+
lines,
902+
name="headroom_persistent_savings_requests_total",
903+
metric_type="counter",
904+
help_text="Durable lifetime requests recorded by the proxy savings tracker",
905+
value=lifetime_savings["requests"],
906+
)
907+
_append_metric(
908+
lines,
909+
name="headroom_persistent_savings_tokens_saved_total",
910+
metric_type="counter",
911+
help_text="Durable lifetime input tokens saved by proxy compression",
912+
value=lifetime_savings["tokens_saved"],
913+
)
914+
_append_metric(
915+
lines,
916+
name="headroom_persistent_savings_input_tokens_total",
917+
metric_type="counter",
918+
help_text="Durable lifetime input tokens recorded by the proxy savings tracker",
919+
value=lifetime_savings["total_input_tokens"],
920+
)
921+
_append_metric(
922+
lines,
923+
name="headroom_persistent_savings_input_cost_usd_total",
924+
metric_type="counter",
925+
help_text="Durable lifetime input spend in USD estimated by the proxy savings tracker",
926+
value=lifetime_savings["total_input_cost_usd"],
927+
)
928+
_append_metric(
929+
lines,
930+
name="headroom_persistent_savings_compression_savings_usd_total",
931+
metric_type="counter",
932+
help_text=(
933+
"Durable lifetime compression savings in USD estimated by the "
934+
"proxy savings tracker"
935+
),
936+
value=lifetime_savings["compression_savings_usd"],
937+
)
899938
# NOTE: per-strategy compression breakdown is tracked
900939
# internally on `self.compressions_by_strategy` and
901940
# `self.tokens_saved_by_strategy` (populated by

tests/test_proxy_cache_ttl_metrics.py

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from headroom.observability import reset_headroom_tracing, reset_otel_metrics
1010
from headroom.proxy.cost import CostTracker, build_prefix_cache_stats
1111
from headroom.proxy.prometheus_metrics import PrometheusMetrics
12+
from headroom.proxy.savings_tracker import SavingsTracker
1213

1314

1415
def test_prometheus_metrics_tracks_observed_ttl_buckets() -> None:
@@ -79,8 +80,10 @@ def test_prefix_cache_stats_include_observed_ttl_mix() -> None:
7980
assert stats["totals"]["observed_ttl_buckets"]["1h"]["tokens"] == 45
8081

8182

82-
def test_prometheus_metrics_export_includes_extended_fields() -> None:
83-
metrics = PrometheusMetrics()
83+
def test_prometheus_metrics_export_includes_extended_fields(tmp_path) -> None:
84+
metrics = PrometheusMetrics(
85+
savings_tracker=SavingsTracker(path=str(tmp_path / "proxy_savings.json"))
86+
)
8487

8588
asyncio.run(
8689
metrics.record_request(
@@ -105,6 +108,11 @@ def test_prometheus_metrics_export_includes_extended_fields() -> None:
105108

106109
exported = asyncio.run(metrics.export())
107110

111+
assert "headroom_requests_total 1" in exported
112+
assert "headroom_tokens_saved_total 5" in exported
113+
assert "headroom_persistent_savings_requests_total 1" in exported
114+
assert "headroom_persistent_savings_tokens_saved_total 5" in exported
115+
assert "headroom_persistent_savings_input_tokens_total 100" in exported
108116
assert "headroom_latency_ms_count 1" in exported
109117
assert 'headroom_transform_timing_ms_sum{transform="router"} 4.5' in exported
110118
assert 'headroom_waste_signal_tokens_total{signal="json_bloat"} 7' in exported
@@ -113,6 +121,31 @@ def test_prometheus_metrics_export_includes_extended_fields() -> None:
113121
assert "headroom_cache_bust_tokens_lost_total 11" in exported
114122

115123

124+
def test_prometheus_export_includes_persistent_savings_after_restart(tmp_path) -> None:
125+
savings_path = tmp_path / "proxy_savings.json"
126+
metrics = PrometheusMetrics(savings_tracker=SavingsTracker(path=str(savings_path)))
127+
128+
asyncio.run(
129+
metrics.record_request(
130+
provider="openai",
131+
model="gpt-4o",
132+
input_tokens=120,
133+
output_tokens=20,
134+
tokens_saved=40,
135+
latency_ms=12.5,
136+
)
137+
)
138+
139+
reloaded = PrometheusMetrics(savings_tracker=SavingsTracker(path=str(savings_path)))
140+
exported = asyncio.run(reloaded.export())
141+
142+
assert "headroom_tokens_saved_total 0" in exported
143+
assert "headroom_requests_total 0" in exported
144+
assert "headroom_persistent_savings_requests_total 1" in exported
145+
assert "headroom_persistent_savings_tokens_saved_total 40" in exported
146+
assert "headroom_persistent_savings_input_tokens_total 120" in exported
147+
148+
116149
def test_streaming_parser_extracts_anthropic_ttl_bucket_usage() -> None:
117150
from headroom.proxy.server import HeadroomProxy, ProxyConfig
118151

tests/test_proxy_savings_history.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -866,6 +866,12 @@ def test_stats_history_persists_across_restarts_and_stats_stays_compatible(tmp_p
866866
assert stats_data["persistent_savings"]["lifetime"]["tokens_saved"] == 40
867867
assert stats_data["persistent_savings"]["storage_path"] == str(savings_path)
868868

869+
metrics = client.get("/metrics")
870+
assert metrics.status_code == 200
871+
assert "headroom_tokens_saved_total 40" in metrics.text
872+
assert "headroom_persistent_savings_tokens_saved_total 40" in metrics.text
873+
assert "headroom_persistent_savings_requests_total 1" in metrics.text
874+
869875
history = client.get("/stats-history")
870876
assert history.status_code == 200
871877
history_data = history.json()
@@ -907,6 +913,12 @@ def test_stats_history_persists_across_restarts_and_stats_stays_compatible(tmp_p
907913
assert history.json()["lifetime"]["tokens_saved"] == 40
908914
assert history.json()["display_session"]["requests"] == 1
909915

916+
metrics = client.get("/metrics")
917+
assert metrics.status_code == 200
918+
assert "headroom_tokens_saved_total 0" in metrics.text
919+
assert "headroom_persistent_savings_tokens_saved_total 40" in metrics.text
920+
assert "headroom_persistent_savings_requests_total 1" in metrics.text
921+
910922
_record_request(client, model="gpt-4o", tokens_saved=15)
911923

912924
updated = client.get("/stats-history").json()
@@ -922,6 +934,12 @@ def test_stats_history_persists_across_restarts_and_stats_stays_compatible(tmp_p
922934
assert updated["series"]["daily"][0]["total_input_tokens_delta"] == 240
923935
assert updated["series"]["daily"][0]["total_input_cost_usd_delta"] == pytest.approx(0.48)
924936

937+
metrics = client.get("/metrics")
938+
assert metrics.status_code == 200
939+
assert "headroom_tokens_saved_total 15" in metrics.text
940+
assert "headroom_persistent_savings_tokens_saved_total 55" in metrics.text
941+
assert "headroom_persistent_savings_requests_total 2" in metrics.text
942+
925943
full = client.get("/stats-history?history_mode=full").json()
926944
assert full["history_summary"]["mode"] == "full"
927945
assert full["history_summary"]["stored_points"] == 2

0 commit comments

Comments
 (0)