From ea0f024b67f4fb3a9f5a4db35ea18732ed23e49d Mon Sep 17 00:00:00 2001 From: Garm Date: Tue, 21 Apr 2026 18:08:33 +0200 Subject: [PATCH 1/2] Compact /stats-history responses by default --- CHANGELOG.md | 9 +++ headroom/dashboard/templates/dashboard.html | 3 + headroom/proxy/savings_tracker.py | 76 +++++++++++++++++++-- headroom/proxy/server.py | 3 +- tests/test_proxy_savings_history.py | 63 +++++++++++++++++ wiki/metrics.md | 14 +++- wiki/proxy.md | 5 +- 7 files changed, 164 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 05c9e288b..00b11bf4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 savings, logs, and telemetry resolve to the bind-mounted `.headroom` path. See [`wiki/filesystem-contract.md`](wiki/filesystem-contract.md). +### Changed +- **`/stats-history` now returns compact checkpoint history by default** — the + JSON response keeps recent checkpoints dense while evenly sampling older + checkpoints so long-running installs do not return ever-growing payloads. + Add `history_mode=full` to fetch the full retained checkpoint list, or + `history_mode=none` to skip it entirely while still receiving the derived + hourly/daily/weekly/monthly rollups. Responses now include a + `history_summary` block describing stored versus returned points. + ## [0.5.22] - 2026-04-11 ### Added diff --git a/headroom/dashboard/templates/dashboard.html b/headroom/dashboard/templates/dashboard.html index d758bf0bc..984c842ff 100644 --- a/headroom/dashboard/templates/dashboard.html +++ b/headroom/dashboard/templates/dashboard.html @@ -1420,6 +1420,9 @@ async downloadHistory(format = 'json', series = null) { const selectedSeries = series || this.historySelectedSeriesKey; const params = new URLSearchParams({ format, series: selectedSeries }); + if (format === 'json' && selectedSeries === 'history') { + params.set('history_mode', 'full'); + } const response = await fetch('/stats-history?' + params.toString()); if (!response.ok) throw new Error('Failed to export history'); diff --git a/headroom/proxy/savings_tracker.py b/headroom/proxy/savings_tracker.py index 1d115b53a..600b86bbc 100644 --- a/headroom/proxy/savings_tracker.py +++ b/headroom/proxy/savings_tracker.py @@ -29,6 +29,7 @@ DEFAULT_SAVINGS_FILE = "proxy_savings.json" SCHEMA_VERSION = 2 DEFAULT_MAX_HISTORY_POINTS = 5000 DEFAULT_MAX_HISTORY_AGE_DAYS = 365 +DEFAULT_MAX_RESPONSE_HISTORY_POINTS = 500 DEFAULT_DISPLAY_SESSION_INACTIVITY_MINUTES = 60 LITELLM_AVAILABLE = importlib.util.find_spec("litellm") is not None @@ -311,11 +312,19 @@ class SavingsTracker: path: str | None = None, max_history_points: int = DEFAULT_MAX_HISTORY_POINTS, max_history_age_days: int = DEFAULT_MAX_HISTORY_AGE_DAYS, + max_response_history_points: int = DEFAULT_MAX_RESPONSE_HISTORY_POINTS, display_session_inactivity_minutes: int = (DEFAULT_DISPLAY_SESSION_INACTIVITY_MINUTES), ) -> None: self._path = Path(path or get_default_savings_storage_path()) self._max_history_points = max_history_points self._max_history_age_days = max_history_age_days + self._max_response_history_points = max( + _coerce_int( + max_response_history_points, + DEFAULT_MAX_RESPONSE_HISTORY_POINTS, + ), + 1, + ) self._display_session_inactivity_minutes = max( _coerce_int( display_session_inactivity_minutes, @@ -524,16 +533,17 @@ class SavingsTracker: "retention": snapshot["retention"], } - def history_response(self) -> dict[str, Any]: + def history_response(self, history_mode: str = "compact") -> dict[str, Any]: """Return frontend-friendly historical data for `/stats-history`.""" snapshot = self.snapshot() - history = snapshot["history"] + raw_history = snapshot["history"] series = { - "hourly": self._build_rollup(history, bucket="hour"), - "daily": self._build_rollup(history, bucket="day"), - "weekly": self._build_rollup(history, bucket="week"), - "monthly": self._build_rollup(history, bucket="month"), + "hourly": self._build_rollup(raw_history, bucket="hour"), + "daily": self._build_rollup(raw_history, bucket="day"), + "weekly": self._build_rollup(raw_history, bucket="week"), + "monthly": self._build_rollup(raw_history, bucket="month"), } + history = self._history_for_response(raw_history, mode=history_mode) return { "schema_version": snapshot["schema_version"], "generated_at": _to_utc_iso(_utc_now()), @@ -549,6 +559,12 @@ class SavingsTracker: "available_series": ["history", *series.keys()], }, "retention": snapshot["retention"], + "history_summary": { + "mode": history_mode, + "stored_points": len(raw_history), + "returned_points": len(history), + "compacted": len(history) < len(raw_history), + }, } def export_rows(self, series: str = "history") -> list[dict[str, Any]]: @@ -604,6 +620,7 @@ class SavingsTracker: "retention": { "max_history_points": self._max_history_points, "max_history_age_days": self._max_history_age_days, + "max_response_history_points": self._max_response_history_points, }, } @@ -727,6 +744,53 @@ class SavingsTracker: self._state["history"] = history + def _history_for_response( + self, + history: list[dict[str, Any]], + *, + mode: str, + ) -> list[dict[str, Any]]: + if mode == "none": + return [] + if mode == "full": + return [dict(item) for item in history] + return self._compact_history(history) + + def _compact_history(self, history: list[dict[str, Any]]) -> list[dict[str, Any]]: + if len(history) <= self._max_response_history_points: + return [dict(item) for item in history] + + # Keep the recent tail dense for charts while evenly sampling older + # checkpoints so long-running installs don't return unbounded payloads. + recent_points = min( + max(self._max_response_history_points // 3, 50), + self._max_response_history_points - 1, + ) + recent = history[-recent_points:] + older = history[:-recent_points] + older_slots = self._max_response_history_points - len(recent) + if older_slots <= 0 or not older: + return [dict(item) for item in recent[-self._max_response_history_points :]] + + if older_slots == 1: + sampled_older = [older[0]] + else: + sampled_older = [ + older[((len(older) - 1) * index) // (older_slots - 1)] + for index in range(older_slots) + ] + + compacted: list[dict[str, Any]] = [] + seen_timestamps: set[str] = set() + for point in [*sampled_older, *recent]: + timestamp = point.get("timestamp") + if not isinstance(timestamp, str) or timestamp in seen_timestamps: + continue + seen_timestamps.add(timestamp) + compacted.append(dict(point)) + + return compacted + def _save_locked(self) -> None: try: self._path.parent.mkdir(parents=True, exist_ok=True) diff --git a/headroom/proxy/server.py b/headroom/proxy/server.py index cfdc225bb..2146d8059 100644 --- a/headroom/proxy/server.py +++ b/headroom/proxy/server.py @@ -1651,6 +1651,7 @@ def create_app(config: ProxyConfig | None = None) -> FastAPI: async def stats_history( format: Literal["json", "csv"] = "json", series: Literal["history", "hourly", "daily", "weekly", "monthly"] = "history", + history_mode: Literal["compact", "full", "none"] = "compact", ): """Get durable proxy compression history plus display-session state.""" if format == "csv": @@ -1661,7 +1662,7 @@ def create_app(config: ProxyConfig | None = None) -> FastAPI: headers={"Content-Disposition": f'attachment; filename="{filename}"'}, ) - return proxy.metrics.savings_tracker.history_response() + return proxy.metrics.savings_tracker.history_response(history_mode=history_mode) @app.get("/subscription-window") async def subscription_window(): diff --git a/tests/test_proxy_savings_history.py b/tests/test_proxy_savings_history.py index c5b559111..81927c465 100644 --- a/tests/test_proxy_savings_history.py +++ b/tests/test_proxy_savings_history.py @@ -130,6 +130,7 @@ def test_savings_tracker_sanitizes_legacy_state_and_applies_retention(tmp_path): assert snapshot["retention"] == { "max_history_points": 1, "max_history_age_days": 2, + "max_response_history_points": 500, } @@ -483,6 +484,57 @@ def test_savings_tracker_rollups_preserve_spend_and_input_history(tmp_path, monk "monthly", ] +def test_stats_history_defaults_to_compact_history_but_can_return_full_history(tmp_path, monkeypatch): + path = tmp_path / "proxy_savings.json" + tracker = SavingsTracker( + path=str(path), + max_history_points=100, + max_history_age_days=30, + max_response_history_points=5, + ) + monkeypatch.setattr( + "headroom.proxy.savings_tracker._estimate_compression_savings_usd", + lambda model, tokens_saved: tokens_saved / 1000.0, + ) + + for i in range(8): + tracker.record_compression_savings( + model="gpt-4o", + tokens_saved=10, + total_input_tokens=(i + 1) * 100, + total_input_cost_usd=(i + 1) * 0.1, + timestamp=f"2026-03-27T09:{i:02d}:00Z", + ) + + compact = tracker.history_response() + assert compact["history_summary"] == { + "mode": "compact", + "stored_points": 8, + "returned_points": 5, + "compacted": True, + } + assert len(compact["history"]) == 5 + assert compact["history"][0]["timestamp"] == "2026-03-27T09:00:00Z" + assert compact["history"][-1]["timestamp"] == "2026-03-27T09:07:00Z" + + full = tracker.history_response(history_mode="full") + assert full["history_summary"] == { + "mode": "full", + "stored_points": 8, + "returned_points": 8, + "compacted": False, + } + assert len(full["history"]) == 8 + + none = tracker.history_response(history_mode="none") + assert none["history"] == [] + assert none["history_summary"] == { + "mode": "none", + "stored_points": 8, + "returned_points": 0, + "compacted": True, + } + def test_stats_history_persists_across_restarts_and_stats_stays_compatible(tmp_path, monkeypatch): savings_path = tmp_path / "proxy_savings.json" @@ -533,6 +585,12 @@ def test_stats_history_persists_across_restarts_and_stats_stays_compatible(tmp_p assert history_data["series"]["hourly"][0]["total_input_cost_usd_delta"] == pytest.approx( 0.24 ) + assert history_data["history_summary"] == { + "mode": "compact", + "stored_points": 1, + "returned_points": 1, + "compacted": False, + } assert stats_data["display_session"] == history_data["display_session"] assert ( @@ -560,6 +618,11 @@ def test_stats_history_persists_across_restarts_and_stats_stays_compatible(tmp_p assert updated["series"]["daily"][0]["total_input_tokens_delta"] == 240 assert updated["series"]["daily"][0]["total_input_cost_usd_delta"] == pytest.approx(0.48) + full = client.get("/stats-history?history_mode=full").json() + assert full["history_summary"]["mode"] == "full" + assert full["history_summary"]["stored_points"] == 2 + assert full["history_summary"]["returned_points"] == 2 + persisted = json.loads(savings_path.read_text()) assert persisted["lifetime"]["tokens_saved"] == 55 assert persisted["lifetime"]["total_input_tokens"] == 240 diff --git a/wiki/metrics.md b/wiki/metrics.md index 608ee9036..e56514c80 100644 --- a/wiki/metrics.md +++ b/wiki/metrics.md @@ -100,7 +100,7 @@ curl http://localhost:8787/stats-history ```json { - "schema_version": 1, + "schema_version": 2, "generated_at": "2026-03-27T09:10:00Z", "lifetime": { "tokens_saved": 12500, @@ -123,6 +123,12 @@ curl http://localhost:8787/stats-history "default_format": "json", "available_formats": ["json", "csv"], "available_series": ["history", "hourly", "daily", "weekly", "monthly"] + }, + "history_summary": { + "mode": "compact", + "stored_points": 2048, + "returned_points": 500, + "compacted": true } } ``` @@ -132,11 +138,17 @@ compression history. It survives proxy restarts, tolerates missing or malformed state files, and powers the historical view in `/dashboard`. It now includes hourly, daily, weekly, and monthly chart-ready rollups. +By default, the `history` array is compacted for transport efficiency. Use +`history_mode=full` when you explicitly need the full retained checkpoint list, +or `history_mode=none` when you only need the aggregate rollups and lifetime +totals. + For export-friendly downloads: ```bash curl "http://localhost:8787/stats-history?format=csv&series=daily" curl "http://localhost:8787/stats-history?format=csv&series=monthly" +curl "http://localhost:8787/stats-history?history_mode=full" ``` CSV exports are available for `history`, `hourly`, `daily`, `weekly`, and diff --git a/wiki/proxy.md b/wiki/proxy.md index 3f6464d36..c52e02ff9 100644 --- a/wiki/proxy.md +++ b/wiki/proxy.md @@ -241,8 +241,10 @@ curl http://localhost:8787/stats-history other Headroom frontends. It returns: - lifetime proxy compression totals -- bounded persisted checkpoint history +- compact checkpoint history by default, with `history_mode=full` available for + export/debug flows - derived hourly, daily, weekly, and monthly rollups for charts +- a `history_summary` block describing stored versus returned checkpoint counts - UTC timestamps throughout By default the proxy stores this history at @@ -258,6 +260,7 @@ daily/weekly/monthly rollups and built-in JSON / CSV export buttons. ```bash curl "http://localhost:8787/stats-history?format=csv&series=weekly" curl "http://localhost:8787/stats-history?format=csv&series=monthly" +curl "http://localhost:8787/stats-history?history_mode=full" ``` ### Prometheus Metrics From a858a0fd4b2ddee0c1f8f88848c3f413ef6f9938 Mon Sep 17 00:00:00 2001 From: Garm Date: Tue, 21 Apr 2026 18:59:24 +0200 Subject: [PATCH 2/2] style: format test_proxy_savings_history.py for CI Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_proxy_savings_history.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_proxy_savings_history.py b/tests/test_proxy_savings_history.py index 81927c465..c7387136e 100644 --- a/tests/test_proxy_savings_history.py +++ b/tests/test_proxy_savings_history.py @@ -484,7 +484,10 @@ def test_savings_tracker_rollups_preserve_spend_and_input_history(tmp_path, monk "monthly", ] -def test_stats_history_defaults_to_compact_history_but_can_return_full_history(tmp_path, monkeypatch): + +def test_stats_history_defaults_to_compact_history_but_can_return_full_history( + tmp_path, monkeypatch +): path = tmp_path / "proxy_savings.json" tracker = SavingsTracker( path=str(path),