mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
fix: cache dashboard stats snapshots
This commit is contained in:
parent
80920ed0e8
commit
2b1ab269ca
4 changed files with 292 additions and 34 deletions
|
|
@ -55,12 +55,12 @@
|
|||
<div class="inline-flex rounded-lg border border-border bg-surface p-1">
|
||||
<button class="px-3 py-1.5 text-sm rounded-md transition-colors"
|
||||
:class="viewMode === 'session' ? 'bg-accent text-black' : 'text-gray-400 hover:text-gray-200'"
|
||||
@click="viewMode = 'session'">
|
||||
@click="setViewMode('session')">
|
||||
Session
|
||||
</button>
|
||||
<button class="px-3 py-1.5 text-sm rounded-md transition-colors"
|
||||
:class="viewMode === 'history' ? 'bg-accent text-black' : 'text-gray-400 hover:text-gray-200'"
|
||||
@click="viewMode = 'history'">
|
||||
@click="setViewMode('history')">
|
||||
Historical
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -89,7 +89,7 @@
|
|||
</div>
|
||||
<button x-show="log_full_messages" id="feed-toggle"
|
||||
class="px-3 py-1.5 text-sm rounded-md border border-border bg-surface text-gray-300 hover:text-white transition-colors"
|
||||
@click="feedOpen = !feedOpen"
|
||||
@click="toggleFeed()"
|
||||
:class="feedOpen ? 'bg-accent text-black' : ''">
|
||||
Live Feed
|
||||
</button>
|
||||
|
|
@ -1256,6 +1256,11 @@
|
|||
savingsHistory: [],
|
||||
expandedRows: {},
|
||||
pollInterval: null,
|
||||
statsPollMs: 5000,
|
||||
historyPollMs: 30000,
|
||||
feedPollMs: 5000,
|
||||
lastHistoryFetchMs: 0,
|
||||
lastFeedFetchMs: 0,
|
||||
feedOpen: false,
|
||||
transformations: [],
|
||||
feedScrolled_: false,
|
||||
|
|
@ -1267,31 +1272,55 @@
|
|||
|
||||
async init() {
|
||||
await this.fetchStats();
|
||||
await this.fetchTransformations();
|
||||
|
||||
this.pollInterval = setInterval(() => {
|
||||
this.fetchStats();
|
||||
this.fetchTransformations();
|
||||
}, 3000);
|
||||
this.pollDashboard();
|
||||
}, this.statsPollMs);
|
||||
|
||||
// Keyboard shortcuts
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'r' || e.key === 'R') {
|
||||
this.fetchStats();
|
||||
this.pollDashboard(true);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
async pollDashboard(force = false) {
|
||||
if (!force && document.hidden) return;
|
||||
|
||||
await this.fetchStats();
|
||||
|
||||
const now = Date.now();
|
||||
if (this.viewMode === 'history' && (force || now - this.lastHistoryFetchMs >= this.historyPollMs)) {
|
||||
await this.fetchHistoryStats();
|
||||
}
|
||||
if (this.feedOpen && (force || now - this.lastFeedFetchMs >= this.feedPollMs)) {
|
||||
await this.fetchTransformations();
|
||||
}
|
||||
},
|
||||
|
||||
async setViewMode(mode) {
|
||||
this.viewMode = mode;
|
||||
if (mode === 'history') {
|
||||
await this.fetchHistoryStats();
|
||||
}
|
||||
},
|
||||
|
||||
async toggleFeed() {
|
||||
this.feedOpen = !this.feedOpen;
|
||||
if (this.feedOpen) {
|
||||
await this.fetchTransformations();
|
||||
}
|
||||
},
|
||||
|
||||
async fetchStats() {
|
||||
try {
|
||||
const [statsRes, historyRes, healthRes] = await Promise.all([
|
||||
fetch('/stats'),
|
||||
fetch('/stats-history'),
|
||||
const [statsRes, healthRes] = await Promise.all([
|
||||
fetch('/stats?cached=1'),
|
||||
fetch('/health')
|
||||
]);
|
||||
|
||||
this.stats = await statsRes.json();
|
||||
this.historyStats = await historyRes.json();
|
||||
const health = await healthRes.json();
|
||||
this.healthy = health.status === 'healthy';
|
||||
this.version = health.version || '0.3.0';
|
||||
|
|
@ -1312,6 +1341,18 @@
|
|||
}
|
||||
},
|
||||
|
||||
async fetchHistoryStats() {
|
||||
try {
|
||||
const response = await fetch('/stats-history');
|
||||
if (response.ok) {
|
||||
this.historyStats = await response.json();
|
||||
this.lastHistoryFetchMs = Date.now();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch history stats:', e);
|
||||
}
|
||||
},
|
||||
|
||||
async fetchTransformations() {
|
||||
try {
|
||||
const prevLen = this.transformations.length;
|
||||
|
|
@ -1324,6 +1365,7 @@
|
|||
}
|
||||
this.transformations = data.transformations || [];
|
||||
this.log_full_messages = data.log_full_messages ?? this.log_full_messages;
|
||||
this.lastFeedFetchMs = Date.now();
|
||||
this.renderTransformations();
|
||||
}
|
||||
} catch (e) {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ from __future__ import annotations
|
|||
import json
|
||||
import logging
|
||||
import random
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
|
|
@ -21,6 +23,14 @@ if TYPE_CHECKING:
|
|||
|
||||
logger = logging.getLogger("headroom.proxy")
|
||||
|
||||
RTK_STATS_CACHE_TTL_SECONDS = 5.0
|
||||
_rtk_stats_cache_lock = threading.Lock()
|
||||
_rtk_stats_cache: dict[str, Any] = {
|
||||
"expires_at": 0.0,
|
||||
"has_value": False,
|
||||
"value": None,
|
||||
}
|
||||
|
||||
# Maximum request body size (100MB - increased to support image-heavy requests)
|
||||
MAX_REQUEST_BODY_SIZE = 100 * 1024 * 1024
|
||||
|
||||
|
|
@ -114,11 +124,18 @@ def _get_rtk_stats() -> dict[str, Any] | None:
|
|||
"""Get rtk (Rust Token Killer) savings stats if rtk is installed.
|
||||
|
||||
Reads from rtk's tracking database via `rtk gain --format json`.
|
||||
Returns None if rtk is not installed.
|
||||
Results are memoized briefly so dashboard polling does not spawn a new
|
||||
subprocess on every refresh.
|
||||
"""
|
||||
import shutil
|
||||
import subprocess as _sp
|
||||
|
||||
now = time.monotonic()
|
||||
with _rtk_stats_cache_lock:
|
||||
if _rtk_stats_cache["has_value"] and now < float(_rtk_stats_cache["expires_at"]):
|
||||
return _rtk_stats_cache["value"]
|
||||
|
||||
payload: dict[str, Any] | None
|
||||
rtk_bin = shutil.which("rtk")
|
||||
if not rtk_bin:
|
||||
# Check headroom-managed install. Preserve the historical Unix-name
|
||||
|
|
@ -128,7 +145,16 @@ def _get_rtk_stats() -> dict[str, Any] | None:
|
|||
if rtk_managed.exists():
|
||||
rtk_bin = str(rtk_managed)
|
||||
else:
|
||||
return None
|
||||
payload = None
|
||||
with _rtk_stats_cache_lock:
|
||||
_rtk_stats_cache.update(
|
||||
{
|
||||
"expires_at": time.monotonic() + RTK_STATS_CACHE_TTL_SECONDS,
|
||||
"has_value": True,
|
||||
"value": payload,
|
||||
}
|
||||
)
|
||||
return payload
|
||||
|
||||
try:
|
||||
result = _sp.run(
|
||||
|
|
@ -140,21 +166,36 @@ def _get_rtk_stats() -> dict[str, Any] | None:
|
|||
if result.returncode == 0 and result.stdout.strip():
|
||||
data = json.loads(result.stdout)
|
||||
summary = data.get("summary", {})
|
||||
return {
|
||||
payload = {
|
||||
"installed": True,
|
||||
"total_commands": summary.get("total_commands", 0),
|
||||
"tokens_saved": summary.get("total_saved", 0),
|
||||
"avg_savings_pct": summary.get("avg_savings_pct", 0.0),
|
||||
}
|
||||
else:
|
||||
payload = {
|
||||
"installed": True,
|
||||
"total_commands": 0,
|
||||
"tokens_saved": 0,
|
||||
"avg_savings_pct": 0.0,
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
payload = {
|
||||
"installed": True,
|
||||
"total_commands": 0,
|
||||
"tokens_saved": 0,
|
||||
"avg_savings_pct": 0.0,
|
||||
}
|
||||
|
||||
return {
|
||||
"installed": True,
|
||||
"total_commands": 0,
|
||||
"tokens_saved": 0,
|
||||
"avg_savings_pct": 0.0,
|
||||
}
|
||||
with _rtk_stats_cache_lock:
|
||||
_rtk_stats_cache.update(
|
||||
{
|
||||
"expires_at": time.monotonic() + RTK_STATS_CACHE_TTL_SECONDS,
|
||||
"has_value": True,
|
||||
"value": payload,
|
||||
}
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def is_anthropic_auth(headers: dict[str, str]) -> bool:
|
||||
|
|
|
|||
|
|
@ -1433,19 +1433,12 @@ def create_app(config: ProxyConfig | None = None) -> FastAPI:
|
|||
"""Serve the Headroom dashboard UI."""
|
||||
return get_dashboard_html()
|
||||
|
||||
@app.get("/stats")
|
||||
async def stats():
|
||||
"""Get comprehensive proxy statistics.
|
||||
DASHBOARD_STATS_CACHE_TTL_SECONDS = 5.0
|
||||
_stats_snapshot_lock = asyncio.Lock()
|
||||
_stats_snapshot: dict[str, Any] = {"expires_at": 0.0, "value": None}
|
||||
|
||||
This is the main stats endpoint - it aggregates data from all subsystems:
|
||||
- Request metrics (total, cached, failed, by model/provider)
|
||||
- Token usage and savings
|
||||
- Cost tracking
|
||||
- Canonical persisted display_session metrics for downstream dashboards
|
||||
- Compression (CCR) statistics
|
||||
- Telemetry/TOIN (data flywheel) statistics
|
||||
- Cache and rate limiter stats
|
||||
"""
|
||||
async def _build_stats_payload() -> dict[str, Any]:
|
||||
"""Build the full `/stats` response payload."""
|
||||
m = proxy.metrics
|
||||
|
||||
# Calculate average latency
|
||||
|
|
@ -1664,6 +1657,44 @@ def create_app(config: ProxyConfig | None = None) -> FastAPI:
|
|||
**get_quota_registry().get_all_stats(),
|
||||
}
|
||||
|
||||
async def _get_cached_stats_payload() -> dict[str, Any]:
|
||||
"""Return a short-TTL cached `/stats` snapshot for dashboard polling."""
|
||||
now = time.monotonic()
|
||||
cached_payload = _stats_snapshot.get("value")
|
||||
if cached_payload is not None and now < float(_stats_snapshot["expires_at"]):
|
||||
return cached_payload
|
||||
|
||||
async with _stats_snapshot_lock:
|
||||
now = time.monotonic()
|
||||
cached_payload = _stats_snapshot.get("value")
|
||||
if cached_payload is not None and now < float(_stats_snapshot["expires_at"]):
|
||||
return cached_payload
|
||||
|
||||
payload = await _build_stats_payload()
|
||||
_stats_snapshot["value"] = payload
|
||||
_stats_snapshot["expires_at"] = time.monotonic() + DASHBOARD_STATS_CACHE_TTL_SECONDS
|
||||
return payload
|
||||
|
||||
@app.get("/stats")
|
||||
async def stats(cached: bool = False):
|
||||
"""Get comprehensive proxy statistics.
|
||||
|
||||
This is the main stats endpoint - it aggregates data from all subsystems:
|
||||
- Request metrics (total, cached, failed, by model/provider)
|
||||
- Token usage and savings
|
||||
- Cost tracking
|
||||
- Canonical persisted display_session metrics for downstream dashboards
|
||||
- Compression (CCR) statistics
|
||||
- Telemetry/TOIN (data flywheel) statistics
|
||||
- Cache and rate limiter stats
|
||||
|
||||
Use ``?cached=1`` for the dashboard fast path. That returns a short-TTL
|
||||
snapshot to avoid rebuilding the full payload on every UI poll.
|
||||
"""
|
||||
if cached:
|
||||
return await _get_cached_stats_payload()
|
||||
return await _build_stats_payload()
|
||||
|
||||
@app.get("/stats-history")
|
||||
async def stats_history(
|
||||
format: Literal["json", "csv"] = "json",
|
||||
|
|
|
|||
144
tests/test_proxy_dashboard_stats_cache.py
Normal file
144
tests/test_proxy_dashboard_stats_cache.py
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from headroom.dashboard import get_dashboard_html
|
||||
from headroom.proxy import helpers as proxy_helpers
|
||||
|
||||
|
||||
class _StatsStub:
|
||||
def __init__(self, calls: dict[str, int], key: str, payload: dict):
|
||||
self._calls = calls
|
||||
self._key = key
|
||||
self._payload = payload
|
||||
|
||||
def get_stats(self) -> dict:
|
||||
self._calls[self._key] += 1
|
||||
return dict(self._payload)
|
||||
|
||||
|
||||
class _ToinStub:
|
||||
def get_stats(self) -> dict:
|
||||
return {"patterns": 0}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_rtk_stats_cache() -> None:
|
||||
proxy_helpers._rtk_stats_cache.update({"expires_at": 0.0, "has_value": False, "value": None})
|
||||
|
||||
|
||||
def test_get_rtk_stats_memoizes_subprocess_calls(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
now = {"value": 100.0}
|
||||
calls = {"run": 0}
|
||||
|
||||
def _fake_run(*args, **kwargs):
|
||||
calls["run"] += 1
|
||||
return SimpleNamespace(
|
||||
returncode=0,
|
||||
stdout=json.dumps({"summary": {"total_commands": 7, "total_saved": 1234}}),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(proxy_helpers.time, "monotonic", lambda: now["value"])
|
||||
monkeypatch.setattr(shutil, "which", lambda name: "/usr/bin/rtk")
|
||||
monkeypatch.setattr(subprocess, "run", _fake_run)
|
||||
|
||||
first = proxy_helpers._get_rtk_stats()
|
||||
second = proxy_helpers._get_rtk_stats()
|
||||
|
||||
assert first == second
|
||||
assert first == {
|
||||
"installed": True,
|
||||
"total_commands": 7,
|
||||
"tokens_saved": 1234,
|
||||
"avg_savings_pct": 0.0,
|
||||
}
|
||||
assert calls["run"] == 1
|
||||
|
||||
now["value"] += proxy_helpers.RTK_STATS_CACHE_TTL_SECONDS + 0.1
|
||||
third = proxy_helpers._get_rtk_stats()
|
||||
|
||||
assert third == first
|
||||
assert calls["run"] == 2
|
||||
|
||||
|
||||
def test_stats_cached_query_reuses_short_ttl_snapshot(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
pytest.importorskip("fastapi")
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import headroom.proxy.server as server
|
||||
from headroom.proxy.server import ProxyConfig, create_app
|
||||
|
||||
calls = {"store": 0, "telemetry": 0, "feedback": 0, "rtk": 0}
|
||||
now = {"value": 100.0}
|
||||
|
||||
monkeypatch.setattr(server.time, "monotonic", lambda: now["value"])
|
||||
monkeypatch.setattr(
|
||||
server,
|
||||
"get_compression_store",
|
||||
lambda: _StatsStub(calls, "store", {"entry_count": 1, "max_entries": 100}),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
server,
|
||||
"get_telemetry_collector",
|
||||
lambda: _StatsStub(calls, "telemetry", {"enabled": True}),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
server,
|
||||
"get_compression_feedback",
|
||||
lambda: _StatsStub(calls, "feedback", {}),
|
||||
)
|
||||
|
||||
def _fake_rtk_stats() -> dict[str, int | bool | float]:
|
||||
calls["rtk"] += 1
|
||||
return {
|
||||
"installed": True,
|
||||
"total_commands": 1,
|
||||
"tokens_saved": 5,
|
||||
"avg_savings_pct": 10.0,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(server, "_get_rtk_stats", _fake_rtk_stats)
|
||||
monkeypatch.setattr(server, "get_toin", lambda: _ToinStub())
|
||||
|
||||
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:
|
||||
first = client.get("/stats?cached=1")
|
||||
second = client.get("/stats?cached=1")
|
||||
now["value"] += 5.1
|
||||
third = client.get("/stats?cached=1")
|
||||
uncached = client.get("/stats")
|
||||
|
||||
assert first.status_code == 200
|
||||
assert second.status_code == 200
|
||||
assert third.status_code == 200
|
||||
assert uncached.status_code == 200
|
||||
|
||||
assert calls == {"store": 3, "telemetry": 3, "feedback": 3, "rtk": 3}
|
||||
assert first.json()["cli_filtering"]["tokens_saved"] == 5
|
||||
|
||||
|
||||
def test_dashboard_uses_cached_stats_and_lazy_history_feed_polling() -> None:
|
||||
html = get_dashboard_html()
|
||||
|
||||
assert "fetch('/stats?cached=1')" in html
|
||||
assert "@click=\"setViewMode('history')\"" in html
|
||||
assert '@click="toggleFeed()"' in html
|
||||
assert "this.viewMode === 'history'" in html
|
||||
assert "this.feedOpen" in html
|
||||
Loading…
Add table
Add a link
Reference in a new issue