Skip to content

Commit aa30aff

Browse files
Zawwarsami16claude
andcommitted
phase 10.0: per-AI latency percentiles (p50/p95/p99) in /metrics + dashboard
new on Hub: _latency_rings — per-AI deque(maxlen=200) of recent latencies (ms) record_latency now also pushes to the ring buffer in addition to bumping count + total + max counters. compute_percentile(ai, p) — nearest-rank percentile from the ring buffer. p in [0, 100]. returns 0 for empty buffer or unknown AI. surface: /metrics by_ai[name] now includes p50_latency_ms, p95_latency_ms, p99_latency_ms alongside the existing avg/max/total fields. /api/dashboard same. dashboard.html publishers table gains a "p95 / p99 / max" column (small font) — operators see tail latency at a glance, separate from the avg. ring buffer is bounded to 200 samples by design — recent behavior matters for tail-latency triage; lifetime history doesn't. matches the same "best-effort, restart-resets" stance as the rest of /metrics. nearest-rank algorithm is exact for the 200 samples we hold; no approximation needed at this scale. tests: - percentile() empty buffer returns 0 - p50/p95/p99 against a known 1..100ms distribution land where expected (±1 nearest-rank tolerance) - ring buffer evicts older samples (100 small + 200 large → p50 near 1000ms, not near 1ms) - /metrics surfaces all three percentiles per AI - /api/dashboard same 154/154 pytest now (one historical reconnect test is flaky in long suite runs, passes in isolation; not related to this change). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8ce9a4e commit aa30aff

5 files changed

Lines changed: 189 additions & 8 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,7 @@ CI runs the Python suite on 3.10 / 3.11 / 3.12 plus the JS module test on every
355355
| **More brains** | Cohere, Mistral, Together, Bedrock, Vertex, vLLM-direct |
356356
| ~~**MCP resources + prompts**~~| Phase 9.0: publishers declare `resources=` and `prompts=` in `publish()`; the MCP bridge surfaces them as resources/list, resources/read, prompts/list, prompts/get |
357357
| ~~**Hub UI dashboard**~~| Live view of connected publishers, recent requests, latency, exposed devices — at `/` (Phase 8.0) |
358+
| ~~**Latency percentiles**~~| Phase 10.0: p50/p95/p99 per AI in `/metrics` + dashboard, from a 200-sample ring buffer |
358359
| **Multi-tier API keys** | Read / full / admin tiers per AI |
359360
| **Federation v2** | Signed peer relationships, shared identity registry across federated hubs |
360361

tests/test_metrics_percentiles.py

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
"""Phase 10.0 — per-AI latency percentiles in /metrics.
2+
3+
Hub maintains a small ring buffer (last 200 latencies) per AI; /metrics
4+
exposes p50, p95, p99 computed on demand. Tail-latency visibility for
5+
operators — avg + max alone hides the actual pain.
6+
"""
7+
8+
import asyncio
9+
import socket
10+
import threading
11+
import time
12+
13+
import pytest
14+
15+
try:
16+
import fastapi # noqa
17+
import uvicorn # noqa
18+
import httpx # noqa
19+
DEPS_AVAILABLE = True
20+
except ImportError:
21+
DEPS_AVAILABLE = False
22+
23+
if DEPS_AVAILABLE:
24+
from zhub.server import create_app, Hub
25+
from zhub import publish
26+
27+
28+
def _free_port() -> int:
29+
with socket.socket() as s:
30+
s.bind(("", 0))
31+
return s.getsockname()[1]
32+
33+
34+
# --- pure unit tests on Hub.percentile() helper ---------------------------
35+
36+
def test_percentile_empty_returns_zero():
37+
h = Hub()
38+
assert h.compute_percentile("nope", 50) == 0
39+
40+
41+
def test_percentile_p50_p95_p99_against_known_distribution():
42+
h = Hub()
43+
# Inject 100 sorted-ish samples 1..100ms
44+
for ms in range(1, 101):
45+
h.record_latency("ai", float(ms))
46+
p50 = h.compute_percentile("ai", 50)
47+
p95 = h.compute_percentile("ai", 95)
48+
p99 = h.compute_percentile("ai", 99)
49+
# Standard nearest-rank: p50 ≈ 50, p95 ≈ 95, p99 ≈ 99 (give ±1 leeway)
50+
assert 49 <= p50 <= 51, f"p50={p50}"
51+
assert 94 <= p95 <= 96, f"p95={p95}"
52+
assert 98 <= p99 <= 100, f"p99={p99}"
53+
54+
55+
def test_latency_ring_buffer_bounded():
56+
"""Only the last 200 latencies are kept — older ones get pushed out
57+
so the percentile reflects recent behavior, not lifetime history."""
58+
h = Hub()
59+
# 100 small samples (1ms), then 200 large samples (1000ms)
60+
for _ in range(100):
61+
h.record_latency("ai", 1.0)
62+
for _ in range(200):
63+
h.record_latency("ai", 1000.0)
64+
# Older 1ms samples should be evicted; p50 should be near 1000
65+
p50 = h.compute_percentile("ai", 50)
66+
assert 900 <= p50 <= 1100, f"p50={p50} (ring buffer should have evicted small samples)"
67+
68+
69+
# --- end-to-end: percentiles surface in /metrics + /api/dashboard ---------
70+
71+
@pytest.fixture
72+
def hub():
73+
if not DEPS_AVAILABLE:
74+
pytest.skip("fastapi/uvicorn/httpx not installed")
75+
port = _free_port()
76+
app = create_app()
77+
78+
def run():
79+
config = uvicorn.Config(app, host="127.0.0.1", port=port,
80+
log_level="warning")
81+
asyncio.run(uvicorn.Server(config).serve())
82+
83+
threading.Thread(target=run, daemon=True).start()
84+
for _ in range(30):
85+
try:
86+
with socket.create_connection(("127.0.0.1", port), timeout=0.1):
87+
break
88+
except OSError:
89+
time.sleep(0.1)
90+
yield port
91+
92+
93+
@pytest.mark.asyncio
94+
async def test_metrics_includes_percentiles_per_ai(hub):
95+
pub = publish(
96+
name="pct-bot", description="x",
97+
chat_handler=lambda m, o: "ok",
98+
hub_url=f"ws://127.0.0.1:{hub}",
99+
)
100+
for _ in range(50):
101+
if pub.api_key:
102+
break
103+
await asyncio.sleep(0.1)
104+
105+
async with httpx.AsyncClient(timeout=5.0) as c:
106+
for _ in range(5):
107+
await c.post(
108+
f"http://127.0.0.1:{hub}/{pub.name}/v1/chat/completions",
109+
json={"messages": [{"role": "user", "content": "x"}]},
110+
headers={"Authorization": f"Bearer {pub.api_key}"},
111+
)
112+
m = (await c.get(f"http://127.0.0.1:{hub}/metrics")).json()
113+
114+
by_ai = m["by_ai"]
115+
assert pub.name in by_ai
116+
e = by_ai[pub.name]
117+
for k in ("p50_latency_ms", "p95_latency_ms", "p99_latency_ms"):
118+
assert k in e, f"missing {k!r}: {e!r}"
119+
assert isinstance(e[k], int)
120+
121+
122+
@pytest.mark.asyncio
123+
async def test_dashboard_includes_percentiles(hub):
124+
pub = publish(
125+
name="dash-pct-bot", description="x",
126+
chat_handler=lambda m, o: "ok",
127+
hub_url=f"ws://127.0.0.1:{hub}",
128+
)
129+
for _ in range(50):
130+
if pub.api_key:
131+
break
132+
await asyncio.sleep(0.1)
133+
134+
async with httpx.AsyncClient(timeout=5.0) as c:
135+
for _ in range(3):
136+
await c.post(
137+
f"http://127.0.0.1:{hub}/{pub.name}/v1/chat/completions",
138+
json={"messages": [{"role": "user", "content": "x"}]},
139+
headers={"Authorization": f"Bearer {pub.api_key}"},
140+
)
141+
d = (await c.get(f"http://127.0.0.1:{hub}/api/dashboard")).json()
142+
143+
by_ai = d["by_ai"]
144+
assert pub.name in by_ai
145+
e = by_ai[pub.name]
146+
for k in ("p50_latency_ms", "p95_latency_ms", "p99_latency_ms"):
147+
assert k in e

zhub/dashboard.html

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -124,8 +124,8 @@ <h1><span class="live-dot"></span>zhub <span class="tag">operator dashboard</spa
124124
<section>
125125
<h2>publishers <span class="count" id="pub-count">0</span></h2>
126126
<table>
127-
<thead><tr><th>name</th><th>brain · description</th><th class="r">chats</th><th class="r">avg/max ms</th><th class="r">conns</th><th>uptime</th></tr></thead>
128-
<tbody id="pub-table"><tr><td colspan="6" class="empty">no publishers registered</td></tr></tbody>
127+
<thead><tr><th>name</th><th>brain · description</th><th class="r">chats</th><th class="r">avg ms</th><th class="r">p95 / p99 / max</th><th class="r">conns</th><th>uptime</th></tr></thead>
128+
<tbody id="pub-table"><tr><td colspan="7" class="empty">no publishers registered</td></tr></tbody>
129129
</table>
130130
</section>
131131

@@ -204,7 +204,7 @@ <h2>federation <span class="count" id="peer-count">0</span></h2>
204204
// publishers table
205205
if (d.publishers.length === 0) {
206206
$("pub-table").innerHTML =
207-
'<tr><td colspan="6" class="empty">no publishers registered</td></tr>';
207+
'<tr><td colspan="7" class="empty">no publishers registered</td></tr>';
208208
} else {
209209
$("pub-table").innerHTML = d.publishers.map(p => {
210210
const m = d.by_ai[p.name] || {};
@@ -216,7 +216,8 @@ <h2>federation <span class="count" id="peer-count">0</span></h2>
216216
<td><strong>${p.name}</strong> ${visPill}</td>
217217
<td><small>${desc.replace(/</g, "&lt;")}</small></td>
218218
<td class="r">${m.chat_requests || 0}</td>
219-
<td class="r">${m.avg_latency_ms || 0} / ${m.max_latency_ms || 0}</td>
219+
<td class="r">${m.avg_latency_ms || 0}</td>
220+
<td class="r"><small>${m.p95_latency_ms || 0} / ${m.p99_latency_ms || 0} / ${m.max_latency_ms || 0}</small></td>
220221
<td class="r">${p.connections}</td>
221222
<td><small>${fmtUptime(p.uptime_seconds)}</small></td>
222223
</tr>`;

zhub/entity.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,10 @@ Liveness probe. Returns `{status, publishers}`. No auth.
9494
### `GET /metrics`
9595
Hub-wide counters (per-AI: chat_requests, rate_limited, peer_proxied,
9696
tool_calls_resolved, http_invoke, request_count, total_latency_ms,
97-
max_latency_ms, avg_latency_ms). Use to track who's hot, who's failing.
98-
No auth (snapshot only, no secrets).
97+
max_latency_ms, avg_latency_ms, **p50/p95/p99_latency_ms**). The
98+
percentiles come from a per-AI ring buffer of the last 200 latencies —
99+
recent behavior, not lifetime history. Use to track who's hot, who's
100+
failing, and where the tail is. No auth (snapshot only, no secrets).
99101

100102
### `GET /` and `GET /api/dashboard`
101103
`/` serves an HTML operator dashboard (auto-refresh 3s). `/api/dashboard`

zhub/server.py

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,19 +126,43 @@ def __init__(self, storage: Optional[Storage] = None) -> None:
126126
# zero unbounded growth.
127127
from collections import deque as _deque
128128
self.recent_requests: _deque = _deque(maxlen=50)
129+
# Phase 10.0: per-AI rolling latency ring buffer (last 200 samples)
130+
# for nearest-rank percentile computation in /metrics. Bounded so
131+
# /metrics stays cheap even at high request volume.
132+
self._latency_rings: dict[str, _deque] = {}
129133

130134
def bump(self, ai_name: str, key: str, delta: int = 1) -> None:
131135
self.metrics[ai_name][key] += delta
132136

133137
def record_latency(self, ai_name: str, latency_ms: float) -> None:
134-
"""Track per-AI request latency. Stores total_ms + count for avg
135-
and max for the obvious upper bound. Called by the access-log
138+
"""Track per-AI request latency. Stores total_ms + count for avg,
139+
max for the obvious upper bound, and the last N samples in a
140+
ring buffer for percentile computation. Called by the access-log
136141
middleware after every request whose path identifies an AI."""
137142
bucket = self.metrics[ai_name]
138143
bucket["request_count"] += 1
139144
bucket["total_latency_ms"] += int(latency_ms)
140145
if int(latency_ms) > bucket.get("max_latency_ms", 0):
141146
bucket["max_latency_ms"] = int(latency_ms)
147+
from collections import deque as _deque
148+
rb = self._latency_rings.get(ai_name)
149+
if rb is None:
150+
rb = _deque(maxlen=200)
151+
self._latency_rings[ai_name] = rb
152+
rb.append(int(latency_ms))
153+
154+
def compute_percentile(self, ai_name: str, p: int) -> int:
155+
"""Nearest-rank percentile from the per-AI ring buffer. p in
156+
[0, 100]. Returns 0 when the buffer is empty."""
157+
rb = self._latency_rings.get(ai_name)
158+
if not rb:
159+
return 0
160+
sorted_samples = sorted(rb)
161+
n = len(sorted_samples)
162+
# nearest-rank: index = ceil(p/100 * n) - 1, clamped to [0, n-1]
163+
import math as _math
164+
idx = max(0, min(n - 1, _math.ceil(p / 100.0 * n) - 1))
165+
return int(sorted_samples[idx])
142166

143167
def check_rate_limit(self, ai_name: str, api_key: str) -> tuple[bool, Optional[float]]:
144168
"""Returns (allowed, retry_after_seconds_or_None)."""
@@ -793,6 +817,9 @@ async def metrics() -> JSONResponse:
793817
count = entry.get("request_count", 0)
794818
total = entry.get("total_latency_ms", 0)
795819
entry["avg_latency_ms"] = (total // count) if count > 0 else 0
820+
entry["p50_latency_ms"] = hub.compute_percentile(name, 50)
821+
entry["p95_latency_ms"] = hub.compute_percentile(name, 95)
822+
entry["p99_latency_ms"] = hub.compute_percentile(name, 99)
796823
by_ai[name] = entry
797824
for name, p in hub.publishers.items():
798825
entry = by_ai.setdefault(name, {})
@@ -839,6 +866,9 @@ async def api_dashboard() -> JSONResponse:
839866
count = entry.get("request_count", 0)
840867
total = entry.get("total_latency_ms", 0)
841868
entry["avg_latency_ms"] = (total // count) if count > 0 else 0
869+
entry["p50_latency_ms"] = hub.compute_percentile(name, 50)
870+
entry["p95_latency_ms"] = hub.compute_percentile(name, 95)
871+
entry["p99_latency_ms"] = hub.compute_percentile(name, 99)
842872
by_ai[name] = entry
843873
peers_env = os.environ.get("ZHUB_PEERS", "")
844874
peers = [p.strip() for p in peers_env.split(",") if p.strip()]

0 commit comments

Comments
 (0)