# Stats capture & graphing — design

> **Status:** SHIPPED (commit `5bf787e`) — host `crates/punktfunk-host/src/stats_recorder.rs`,
> mgmt endpoints `/api/v1/stats/*` (`mgmt.rs`), web console Performance page
> (`web/src/sections/Stats/`). Implemented; not yet on-glass validated. This doc is trimmed to
> design rationale + open items; the shipped code is the source of truth (data models, recorder
> API, endpoint list, and UI layout all live there).

Goal: let an operator **enable performance-stats capture from the web console**, play a session,
**stop**, and **review the captured time-series as graphs**. Captures are **saved to disk**
(browse/compare past sessions; survive host restart) and cover **both** streaming paths: native
punktfunk/1 (`virtual_stream`) and GameStream/Moonlight (`gamestream/stream.rs`).

## Why / design rationale

- **Reuse the existing per-stage instrumentation** that was startup-gated by `PUNKTFUNK_PERF=1`
  (stdout-only, read once at startup). The key behavioral change: make the per-frame
  **measurement** predicate `perf || recorder.is_armed()`, re-evaluated each frame via a cheap
  `Relaxed` atomic. `PUNKTFUNK_PERF=1` still emits its `tracing::info!` log line exactly as
  before; the web toggle additionally builds a `StatsSample` at the aggregation boundary — so
  the web toggle works at runtime with **zero startup flags**.
- **No async on the per-frame path.** `is_armed()` is a `Relaxed` atomic load; sample
  construction happens only at the existing **~2 s native / ~1 s GameStream** aggregation
  boundary, never per frame. One shared `Arc<StatsRecorder>` is created once in the unified host
  entry and threaded into both streaming loops + `MgmtState`, mirroring the existing
  `Arc<NativePairing>` sharing pattern.
- **Stage sets are the per-frame critical path so stacking is meaningful.** native:
  `capture` / `submit` (NVENC enqueue) / `encode` (`lock_bitstream` = NVENC schedule + ASIC, the
  dominant stage under GPU load) / `send` (paced_submit: seal + FEC + pace + sendmmsg).
  gamestream: `capture` / `encode` / `packetize` / `send`. Native source vectors map
  `st_cap`→`capture`, `st_submit`→`submit`, `st_wait`→`encode`, `pace_us`→`send`; `encode_us`
  total ≈ capture+submit+encode and is **not** emitted as its own stage to avoid double-counting.
- **Gotchas / accepted-risk decisions:**
  - **`id` is path-traversal-safe.** `load`/`delete` reject any id not matching
    `^[A-Za-z0-9._-]+$` (no `/`, no `..`, no `:` — keep it a valid Windows filename) and only ever
    join `dir/<id>.json`. Endpoints are bearer-authed, but defend in depth.
  - **Bounded memory, keep the start.** `MAX_SAMPLES` cap (~5400 ≈ 3 h @ 2 s); on overflow stop
    appending and set a `truncated` flag — **do NOT drop oldest**, a saved recording must keep
    its start.
  - **Atomic disk write.** Write `<id>.json.tmp` then rename so a crash mid-write can't leave a
    half file. Captures dir `~/.config/punktfunk/captures/` (0700), next to `cert.pem`.
  - Counters that a path doesn't expose are recorded as `0` — **do NOT fabricate**.
  - mgmt endpoints are **bearer-token only** (operator actions) — deliberately NOT in the mTLS
    `cert_may_access` read-only allowlist.
  - Charts render **client-only** (mounted guard) so SSR doesn't choke on `ResponsiveContainer`'s
    0-width measure.

## Open items

- **On-glass validation.** Implemented but not yet validated on real hardware end-to-end (arm
  from the console, play, stop, review graphs across both native + GameStream paths).
