Download src/tinyllm/obs.py from CoolDip00/tiny-llm: direct link, hf CLI and curl.
- Browser
- Download file 17.4 kB
-
https://huggingface.co/spaces/CoolDip00/tiny-llm/resolve/main/src/tinyllm/obs.py
- Command line
-
hf download hf://spaces/CoolDip00/tiny-llm/src/tinyllm/obs.py
-
curl -L -o obs.py https://huggingface.co/spaces/CoolDip00/tiny-llm/resolve/main/src/tinyllm/obs.py
17.4 kB
| """Step 4.13 step 2: observability for the server (D-028, docs/SERVING.md). | |
| Standard library only, so the server and the Space need nothing extra. | |
| - RequestRecord: one JSON log line per finished request (timings, token counts, status). | |
| Never the prompt or completion text; the client is a salted hash (hash_client). | |
| - EngineSnapshot: the engine's load, sampled every few seconds. | |
| - Metrics: thread-safe aggregator. Rolling windows (5 min, 1 h) back /stats (JSON); | |
| cumulative counters and histograms back /metrics (Prometheus text format, so a real | |
| Prometheus could scrape it unchanged). history() backs /stats/history for the status page | |
| (6.5b): the last hour per minute, engine snapshots, and the SLOs with their error budgets. | |
| - JsonFormatter / setup_logging: every log line is one JSON object. | |
| """ | |
| from __future__ import annotations | |
| import hashlib | |
| import json | |
| import logging | |
| import math | |
| import sys | |
| import threading | |
| import time | |
| from collections import deque | |
| from dataclasses import asdict, dataclass, field | |
| WINDOWS = {"5m": 300.0, "1h": 3600.0} | |
| TTFT_BUCKETS = (0.05, 0.1, 0.25, 0.5, 1.0, 2.0, 5.0, 10.0, 30.0) | |
| LATENCY_BUCKETS = (0.1, 0.25, 0.5, 1.0, 2.0, 5.0, 10.0, 30.0, 60.0, 120.0) | |
| # SLOs (docs/SERVING.md, 4.13 step 3). Targets are shares of good requests; the TTFT threshold | |
| # is per server (--slo-ttft-s: 1 s local GPU, 2 s on the Space). | |
| SLO_TARGETS = {"ttft": 0.95, "availability": 0.99, "shed": 0.99} | |
| SLO_WINDOW_S = 7 * 24 * 3600.0 # rolling 7 days, but only since the last restart (in memory) | |
| FAST_BURN = 14.4 # Google SRE fast-burn threshold, judged on the last hour | |
| BURN_MIN_REQUESTS = 10 # fewer counted requests in the hour: no flag (too few to judge) | |
| HISTORY_MINUTES = 60 | |
| def slo_counted(status: int) -> bool: | |
| """Requests the SLOs judge: served (200), shed (429) and server failures (>= 500, incl. 504). | |
| Client errors (400) and client disconnects (499) count for none of them.""" | |
| return status == 200 or status == 429 or status >= 500 | |
| class _SloBucket: | |
| """Counters for one hour of counted requests.""" | |
| __slots__ = ("counted", "ok", "slow", "failed", "shed") | |
| def __init__(self): | |
| self.counted = self.ok = self.slow = self.failed = self.shed = 0 | |
| def add(self, rec: "RequestRecord", ttft_s: float) -> None: | |
| self.counted += 1 | |
| if rec.status == 200: | |
| self.ok += 1 | |
| self.slow += rec.ttft_s is None or rec.ttft_s > ttft_s | |
| elif rec.status == 429: | |
| self.shed += 1 | |
| else: | |
| self.failed += 1 | |
| def bad_and_total(self, slo: str) -> tuple[int, int]: | |
| if slo == "ttft": | |
| return self.slow, self.ok | |
| return (self.failed, self.counted) if slo == "availability" else (self.shed, self.counted) | |
| def hash_client(ip: str, salt: str) -> str: | |
| """Stable, salted id for a client. The raw IP is never stored.""" | |
| return hashlib.sha256(f"{salt}:{ip}".encode()).hexdigest()[:16] | |
| def percentile(xs, q: float) -> float | None: | |
| """Linear-interpolated percentile (numpy's default); None for no data. q in [0, 100].""" | |
| xs = sorted(xs) | |
| if not xs: | |
| return None | |
| k = (len(xs) - 1) * q / 100 | |
| lo, hi = math.floor(k), math.ceil(k) | |
| return xs[lo] + (xs[hi] - xs[lo]) * (k - lo) | |
| def itl_stats(token_times: list[float]) -> tuple[float | None, float | None]: | |
| """Inter-token latency p50 / p95 from the times each token was produced.""" | |
| gaps = [b - a for a, b in zip(token_times, token_times[1:])] | |
| return percentile(gaps, 50), percentile(gaps, 95) | |
| class RequestRecord: | |
| """One finished request (any outcome). Times in seconds; None when not reached.""" | |
| request_id: str | |
| status: int # HTTP status: 200, 400, 429, 500, 504, 499 (client gone) | |
| variant: str = "main" | |
| model_version: str = "" | |
| client: str = "" # hash_client(), never the IP | |
| arrival_unix: float = 0.0 | |
| queue_wait_s: float | None = None # arrival → admitted into a slot (first token) | |
| ttft_s: float | None = None | |
| itl_p50_s: float | None = None | |
| itl_p95_s: float | None = None | |
| latency_s: float | None = None | |
| prompt_tokens: int = 0 | |
| completion_tokens: int = 0 | |
| finish_reason: str | None = None # "stop" (EOS), "length", "abort", "timeout", "error" | |
| stream: bool = False | |
| error: str | None = None | |
| queue_depth_at_arrival: int = 0 | |
| busy_slots_at_arrival: int = 0 | |
| def to_dict(self) -> dict: | |
| return asdict(self) | |
| class EngineSnapshot: | |
| t_unix: float | |
| queue_len: int | |
| busy_slots: int | |
| n_slots: int | |
| tokens_per_s: float # generated tokens / s since the previous snapshot | |
| kv_used_frac: float | |
| ram_mb: float | None = None | |
| vram_mb: float | None = None | |
| class _Histogram: | |
| def __init__(self, buckets): | |
| self.buckets = tuple(buckets) | |
| self.counts = [0] * len(self.buckets) # per bucket, not cumulative | |
| self.sum = 0.0 | |
| self.count = 0 | |
| def observe(self, x: float) -> None: | |
| for i, b in enumerate(self.buckets): | |
| if x <= b: | |
| self.counts[i] += 1 | |
| break | |
| self.sum += x | |
| self.count += 1 | |
| def lines(self, name: str, labels: str) -> list[str]: | |
| sep = "," if labels else "" | |
| out, cum = [], 0 | |
| for b, c in zip(self.buckets, self.counts): | |
| cum += c | |
| out.append(f'{name}_bucket{{{labels}{sep}le="{b:g}"}} {cum}') | |
| out.append(f'{name}_bucket{{{labels}{sep}le="+Inf"}} {self.count}') | |
| lab = f"{{{labels}}}" if labels else "" | |
| out.append(f"{name}_sum{lab} {self.sum:.6f}") | |
| out.append(f"{name}_count{lab} {self.count}") | |
| return out | |
| class _Window: | |
| span_s: float | |
| items: deque = field(default_factory=deque) # (t_monotonic, RequestRecord) | |
| def prune(self, now: float) -> None: | |
| while self.items and self.items[0][0] < now - self.span_s: | |
| self.items.popleft() | |
| class Metrics: | |
| """Thread-safe: handlers call observe(), the engine thread calls snapshot().""" | |
| def __init__(self, windows: dict[str, float] = WINDOWS, clock=time.monotonic, slo_ttft_s: float = 1.0, | |
| wall=time.time): | |
| self._lock = threading.Lock() | |
| self._clock, self._wall = clock, wall | |
| self.slo_ttft_s = slo_ttft_s | |
| self._windows = {k: _Window(v) for k, v in windows.items()} | |
| self._hour = _Window(HISTORY_MINUTES * 60.0) # per-minute history and the 1 h burn rate | |
| self._slo: deque = deque() # (hour index since start, _SloBucket) | |
| self._snaps: deque = deque() # (t_monotonic, EngineSnapshot), last hour | |
| self._requests: dict[tuple[str, int], int] = {} # (variant, status) → count | |
| self._tokens: dict[str, int] = {} # variant → completion tokens | |
| self._ttft: dict[str, _Histogram] = {} | |
| self._latency: dict[str, _Histogram] = {} | |
| self.last_snapshot: EngineSnapshot | None = None | |
| self.started = clock() | |
| def observe(self, rec: RequestRecord, now: float | None = None) -> None: | |
| now = self._clock() if now is None else now | |
| with self._lock: | |
| for w in (*self._windows.values(), self._hour): | |
| w.items.append((now, rec)) | |
| w.prune(now) | |
| if slo_counted(rec.status): | |
| hour = int((now - self.started) // 3600) | |
| if not self._slo or self._slo[-1][0] != hour: | |
| self._slo.append((hour, _SloBucket())) | |
| while self._slo and self._slo[0][0] <= hour - SLO_WINDOW_S / 3600: | |
| self._slo.popleft() | |
| self._slo[-1][1].add(rec, self.slo_ttft_s) | |
| v = rec.variant | |
| self._requests[(v, rec.status)] = self._requests.get((v, rec.status), 0) + 1 | |
| self._tokens[v] = self._tokens.get(v, 0) + rec.completion_tokens | |
| if rec.ttft_s is not None: | |
| self._ttft.setdefault(v, _Histogram(TTFT_BUCKETS)).observe(rec.ttft_s) | |
| if rec.status == 200 and rec.latency_s is not None: | |
| self._latency.setdefault(v, _Histogram(LATENCY_BUCKETS)).observe(rec.latency_s) | |
| def snapshot(self, snap: EngineSnapshot, now: float | None = None) -> None: | |
| now = self._clock() if now is None else now | |
| with self._lock: | |
| self.last_snapshot = snap | |
| self._snaps.append((now, snap)) | |
| while self._snaps and self._snaps[0][0] < now - HISTORY_MINUTES * 60: | |
| self._snaps.popleft() | |
| def history(self, now: float | None = None) -> dict: | |
| """/stats/history, for the status page: the last hour per minute, the engine snapshots of the | |
| last hour, and each SLO over the rolling 7-day window (since the last restart).""" | |
| now = self._clock() if now is None else now | |
| wall = self._wall() | |
| with self._lock: | |
| self._hour.prune(now) | |
| recs = list(self._hour.items) | |
| snaps = [(t, s) for t, s in self._snaps if t >= now - HISTORY_MINUTES * 60] | |
| hour_now = int((now - self.started) // 3600) | |
| buckets = [b for h, b in self._slo if h > hour_now - SLO_WINDOW_S / 3600] | |
| uptime = now - self.started | |
| # Wall-clock minutes (12:03:00-12:03:59), not minutes counted back from this call, so a | |
| # request keeps its bucket across refreshes; minutes_ago 0 is the minute in progress and | |
| # the oldest one can be partial (the hour window is pruned by age). | |
| by_min: list[list[RequestRecord]] = [[] for _ in range(HISTORY_MINUTES)] | |
| cur_min, into_min = wall // 60, wall % 60 | |
| for t, r in recs: | |
| ago = int(cur_min - (wall - (now - t)) // 60) | |
| if 0 <= ago < HISTORY_MINUTES: | |
| by_min[ago].append(r) | |
| minutes = [] | |
| for ago in range(HISTORY_MINUTES - 1, -1, -1): # oldest first | |
| rs = by_min[ago] | |
| ok = [r for r in rs if r.status == 200] | |
| ttft = [r.ttft_s for r in ok if r.ttft_s is not None] | |
| lat = [r.latency_s for r in ok if r.latency_s is not None] | |
| minutes.append({ | |
| # covered: the server started before this minute ended | |
| "minutes_ago": ago, "covered": uptime > into_min + (ago - 1) * 60, | |
| "requests": len(rs), "ok": len(ok), | |
| "failed": sum(r.status >= 500 for r in rs), "shed": sum(r.status == 429 for r in rs), | |
| "completion_tokens": sum(r.completion_tokens for r in rs), | |
| "ttft_p50_s": percentile(ttft, 50), "ttft_p95_s": percentile(ttft, 95), | |
| "latency_p95_s": percentile(lat, 95)}) | |
| hour = _SloBucket() | |
| for _, r in recs: | |
| if slo_counted(r.status): | |
| hour.add(r, self.slo_ttft_s) | |
| slos = {} | |
| for name, target in SLO_TARGETS.items(): | |
| bad, total = map(sum, zip(*(b.bad_and_total(name) for b in buckets))) if buckets else (0, 0) | |
| hbad, htotal = hour.bad_and_total(name) | |
| allowed = 1 - target | |
| burn = (hbad / htotal) / allowed if htotal else None | |
| slos[name] = { | |
| "target": target, "good": total - bad, "total": total, | |
| "compliance": round((total - bad) / total, 6) if total else None, | |
| "met": (total - bad) / total >= target if total else None, | |
| "budget_left": round(1 - bad / (allowed * total), 6) if total else None, | |
| "burn_rate_1h": round(burn, 3) if burn is not None else None, | |
| "requests_1h": htotal, | |
| "fast_burn": burn is not None and htotal >= BURN_MIN_REQUESTS and burn > FAST_BURN} | |
| slos["ttft"]["threshold_s"] = self.slo_ttft_s | |
| slos["shed"]["judged"] = False # only at or below the rated load, unknown for the Space yet | |
| return {"now_unix": round(wall, 3), "uptime_s": round(uptime, 1), | |
| "slo_window_s": round(min(SLO_WINDOW_S, uptime), 1), "minutes": minutes, | |
| "snapshots": [{"seconds_ago": round(now - t, 1), "queue_len": s.queue_len, | |
| "busy_slots": s.busy_slots, "n_slots": s.n_slots, | |
| "tokens_per_s": round(s.tokens_per_s, 1)} for t, s in snaps], | |
| "slo": slos, "fast_burn_threshold": FAST_BURN, "burn_min_requests": BURN_MIN_REQUESTS} | |
| def stats(self, now: float | None = None) -> dict: | |
| """/stats: per rolling window, request counts, rates and latency percentiles.""" | |
| now = self._clock() if now is None else now | |
| out: dict = {"uptime_s": round(now - self.started, 1), "windows": {}} | |
| with self._lock: | |
| for name, w in self._windows.items(): | |
| w.prune(now) | |
| recs = [r for _, r in w.items] | |
| span = min(w.span_s, max(now - self.started, 1e-9)) | |
| ok = [r for r in recs if r.status == 200] | |
| ttft = [r.ttft_s for r in ok if r.ttft_s is not None] | |
| lat = [r.latency_s for r in ok if r.latency_s is not None] | |
| qw = [r.queue_wait_s for r in ok if r.queue_wait_s is not None] | |
| n = len(recs) | |
| out["windows"][name] = { | |
| "requests": n, | |
| "requests_per_min": round(60 * n / span, 3), | |
| "ok": len(ok), | |
| "error_rate": round(sum(r.status >= 500 for r in recs) / n, 4) if n else 0.0, | |
| "reject_rate": round(sum(r.status == 429 for r in recs) / n, 4) if n else 0.0, | |
| "clients": len({r.client for r in recs}), | |
| "completion_tokens": sum(r.completion_tokens for r in recs), | |
| "ttft_s": {f"p{q}": percentile(ttft, q) for q in (50, 95, 99)}, | |
| "latency_s": {f"p{q}": percentile(lat, q) for q in (50, 95, 99)}, | |
| "queue_wait_s": {f"p{q}": percentile(qw, q) for q in (50, 95)}, | |
| } | |
| out["engine"] = asdict(self.last_snapshot) if self.last_snapshot else None | |
| return out | |
| def prometheus(self) -> str: | |
| """/metrics in the Prometheus text exposition format (version 0.0.4).""" | |
| L: list[str] = [] | |
| with self._lock: | |
| L += ["# HELP tinyllm_requests_total Finished requests by variant and HTTP status.", | |
| "# TYPE tinyllm_requests_total counter"] | |
| for (v, s), c in sorted(self._requests.items()): | |
| L.append(f'tinyllm_requests_total{{variant="{v}",status="{s}"}} {c}') | |
| L += ["# HELP tinyllm_completion_tokens_total Generated tokens by variant.", | |
| "# TYPE tinyllm_completion_tokens_total counter"] | |
| for v, c in sorted(self._tokens.items()): | |
| L.append(f'tinyllm_completion_tokens_total{{variant="{v}"}} {c}') | |
| for name, hists, help_ in ( | |
| ("tinyllm_ttft_seconds", self._ttft, "Arrival to first token."), | |
| ("tinyllm_request_latency_seconds", self._latency, "Arrival to last token, successful requests.")): | |
| L += [f"# HELP {name} {help_}", f"# TYPE {name} histogram"] | |
| for v, h in sorted(hists.items()): | |
| L += h.lines(name, f'variant="{v}"') | |
| s = self.last_snapshot | |
| for name, help_, val in ( | |
| ("tinyllm_queue_length", "Requests waiting for a slot.", s and s.queue_len), | |
| ("tinyllm_busy_slots", "Slots decoding a request.", s and s.busy_slots), | |
| ("tinyllm_slots", "Total slots.", s and s.n_slots), | |
| ("tinyllm_tokens_per_second", "Generated tokens per second (last snapshot).", s and s.tokens_per_s), | |
| ("tinyllm_kv_used_ratio", "Share of the KV cache holding tokens.", s and s.kv_used_frac)): | |
| if s is not None: | |
| L += [f"# HELP {name} {help_}", f"# TYPE {name} gauge", f"{name} {val:g}"] | |
| return "\n".join(L) + "\n" | |
| class JsonFormatter(logging.Formatter): | |
| """One JSON object per line. Structured fields go in `extra={"fields": {...}}`. | |
| `with_exc=False` leaves tracebacks out (the shipped logs, logship.py).""" | |
| def __init__(self, with_exc: bool = True): | |
| super().__init__() | |
| self.with_exc = with_exc | |
| def format(self, record: logging.LogRecord) -> str: | |
| d = {"ts": round(record.created, 3), "level": record.levelname.lower(), | |
| "logger": record.name, "msg": record.getMessage()} | |
| d.update(getattr(record, "fields", None) or {}) | |
| if record.exc_info and self.with_exc: | |
| d["exc"] = self.formatException(record.exc_info) | |
| return json.dumps(d, default=str) | |
| def setup_logging(level: str = "info", path: str | None = None) -> logging.Logger: | |
| """The `tinyllm` logger: JSON lines to stderr, and to `path` (JSONL) if given.""" | |
| log = logging.getLogger("tinyllm") | |
| log.setLevel(level.upper()) | |
| log.handlers.clear() | |
| log.propagate = False | |
| handlers: list[logging.Handler] = [logging.StreamHandler(sys.stderr)] | |
| if path: | |
| handlers.append(logging.FileHandler(path, encoding="utf-8")) | |
| for h in handlers: | |
| h.setFormatter(JsonFormatter()) | |
| log.addHandler(h) | |
| return log | |