feat(host): web-console performance capture — record stream stats, graph them
apple / swift (push) Successful in 1m1s
android / android (push) Successful in 4m13s
ci / rust (push) Successful in 4m42s
ci / web (push) Successful in 50s
ci / docs-site (push) Successful in 53s
windows-host / package (push) Successful in 5m51s
apple / screenshots (push) Successful in 5m1s
deb / build-publish (push) Successful in 2m29s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 33s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
ci / bench (push) Successful in 4m35s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m9s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m10s

Arm streaming-perf-stats capture from the web console, play, stop, and review the
run as graphs; finished captures are saved to disk as browsable/exportable
recordings. Covers both the native punktfunk/1 path and GameStream.

- stats_recorder.rs: one shared Arc<StatsRecorder> ring (created in gamestream::serve,
  shared with the mgmt API + both streaming loops, mirroring NativePairing). The
  hot-path gate is a runtime AtomicBool that replaces the startup-only PUNKTFUNK_PERF
  for *recording* (PERF stdout logging unchanged); bounded ring (~3 h); atomic
  temp+rename writes to ~/.config/punktfunk/captures/*.json; path-traversal-safe ids;
  poison-resilient locks.
- native (punktfunk1.rs) + GameStream (stream.rs) emit a StatsSample at their existing
  ~2 s / ~1 s aggregation boundary — per-stage latency p50/p99, fps new/repeat, goodput,
  loss/FEC deltas — with no new per-frame work beyond the cheap atomic check.
  FrameMsg.was_measured keeps pre-arm in-flight frames out of the first window's
  percentiles (without zeroing the Windows-relay path's fps/encode).
- mgmt.rs: 7 bearer-only /api/v1/stats/* endpoints (capture start/stop/status/live;
  recordings list/get/delete); api/openapi.json regenerated, in sync.
- web: new "Performance" page (recharts, rendered SSR-safe) — capture control, live
  graphs while armed, recordings table (view / download-JSON / delete), and a detail
  view with the latency stacked-area bottleneck breakdown (p50/p99 toggle) + throughput
  + health. Charts adapt to either path's stage set.

Design: design/stats-capture-plan.md. Built and adversarially reviewed via a multi-agent
workflow; workspace build/clippy(-D warnings)/fmt/tests green, OpenAPI no-drift. Not yet
on-glass validated against a live session.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-26 13:59:39 +00:00
parent 0a6c9d8852
commit 5bf787eb2b
20 changed files with 2907 additions and 53 deletions
+528
View File
@@ -978,6 +978,309 @@
}
}
},
"/api/v1/stats/capture/live": {
"get": {
"tags": [
"stats"
],
"summary": "Live in-progress capture",
"description": "The full sample time-series of the capture currently recording, for live graphing. `404` when\nnothing is armed.",
"operationId": "statsCaptureLive",
"responses": {
"200": {
"description": "The in-progress capture (meta + samples so far)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Capture"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"404": {
"description": "No capture is currently recording",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/stats/capture/start": {
"post": {
"tags": [
"stats"
],
"summary": "Start a stats capture",
"description": "Arms a new performance-stats capture. Idempotent: if a capture is already running this returns\nthe current status unchanged. While armed, the streaming loops emit aggregated samples (~ every\n12 s) into the in-progress capture, readable live via `GET /stats/capture/live`.",
"operationId": "statsCaptureStart",
"responses": {
"200": {
"description": "Capture armed (or already running)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/StatsStatus"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/stats/capture/status": {
"get": {
"tags": [
"stats"
],
"summary": "Stats capture status",
"description": "Whether a capture is armed, its sample count, and start time. Poll this (e.g. every 2 s) to\ndrive the capture-control UI.",
"operationId": "statsCaptureStatus",
"responses": {
"200": {
"description": "In-progress capture status (idle when not armed)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/StatsStatus"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/stats/capture/stop": {
"post": {
"tags": [
"stats"
],
"summary": "Stop the stats capture",
"description": "Disarms the in-progress capture and writes it to disk atomically, returning its summary. If\nnothing was recording, returns `204 No Content`.",
"operationId": "statsCaptureStop",
"responses": {
"200": {
"description": "Capture stopped and saved",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CaptureMeta"
}
}
}
},
"204": {
"description": "Nothing was recording"
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"500": {
"description": "Could not write the recording to disk",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/stats/recordings": {
"get": {
"tags": [
"stats"
],
"summary": "List saved recordings",
"description": "Every saved capture's summary (the `meta` head only — not the sample body), newest first.",
"operationId": "statsRecordingsList",
"responses": {
"200": {
"description": "Saved capture summaries, newest first",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/CaptureMeta"
}
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/stats/recordings/{id}": {
"get": {
"tags": [
"stats"
],
"summary": "Get a saved recording",
"description": "The full capture (meta + samples) for `id`, for graphing or download.",
"operationId": "statsRecordingGet",
"parameters": [
{
"name": "id",
"in": "path",
"description": "The recording id (its filename stem)",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "The full capture",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Capture"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"404": {
"description": "No recording with that id",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"500": {
"description": "The recording file is unreadable",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
},
"delete": {
"tags": [
"stats"
],
"summary": "Delete a saved recording",
"description": "Removes the recording `id` from disk. `404` if there is no such recording.",
"operationId": "statsRecordingDelete",
"parameters": [
{
"name": "id",
"in": "path",
"description": "The recording id (its filename stem)",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"204": {
"description": "Recording deleted"
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"404": {
"description": "No recording with that id",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"500": {
"description": "Could not delete the recording",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/status": {
"get": {
"tags": [
@@ -1125,6 +1428,89 @@
}
}
},
"Capture": {
"type": "object",
"description": "A full capture: summary + the sample time-series. The wire + on-disk shape.",
"required": [
"meta",
"samples"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/CaptureMeta"
},
"samples": {
"type": "array",
"items": {
"$ref": "#/components/schemas/StatsSample"
}
}
}
},
"CaptureMeta": {
"type": "object",
"description": "Capture summary — the filename stem plus the negotiated mode/codec/client. Stored at the head\nof each on-disk recording and listed standalone (without the sample body) by\n[`StatsRecorder::list`].",
"required": [
"id",
"started_unix_ms",
"duration_ms",
"kind",
"width",
"height",
"fps",
"codec",
"client",
"sample_count"
],
"properties": {
"client": {
"type": "string",
"description": "Short label / fingerprint prefix, or `\"\"` if unknown."
},
"codec": {
"type": "string",
"description": "`\"h264\" | \"hevc\" | \"av1\"`."
},
"duration_ms": {
"type": "integer",
"format": "int64",
"minimum": 0
},
"fps": {
"type": "integer",
"format": "int32",
"minimum": 0
},
"height": {
"type": "integer",
"format": "int32",
"minimum": 0
},
"id": {
"type": "string",
"description": "e.g. `\"2026-06-26T20-14-03Z_5120x1440\"` — also the filename stem."
},
"kind": {
"type": "string",
"description": "`\"native\" | \"gamestream\"`."
},
"sample_count": {
"type": "integer",
"format": "int32",
"minimum": 0
},
"started_unix_ms": {
"type": "integer",
"format": "int64",
"minimum": 0
},
"width": {
"type": "integer",
"format": "int32",
"minimum": 0
}
}
},
"CustomEntry": {
"type": "object",
"description": "A user-added title, persisted in `~/.config/punktfunk/library.json`. Same shape the API\nreturns and the web console edits.",
@@ -1595,6 +1981,144 @@
}
}
},
"StageTiming": {
"type": "object",
"description": "One pipeline stage's latency in an aggregation window (microseconds).",
"required": [
"name",
"p50_us",
"p99_us"
],
"properties": {
"name": {
"type": "string",
"description": "`\"capture\" | \"submit\" | \"encode\" | \"packetize\" | \"send\"` (path-dependent)."
},
"p50_us": {
"type": "number",
"format": "float"
},
"p99_us": {
"type": "number",
"format": "float"
}
}
},
"StatsSample": {
"type": "object",
"description": "One aggregated sample (~ every 2 s native, ~ every 1 s GameStream).",
"required": [
"t_ms",
"session_id",
"stages",
"fps",
"repeat_fps",
"mbps",
"bitrate_kbps",
"frames_dropped",
"packets_dropped",
"send_dropped",
"fec_recovered"
],
"properties": {
"bitrate_kbps": {
"type": "integer",
"format": "int32",
"description": "Configured target bitrate.",
"minimum": 0
},
"fec_recovered": {
"type": "integer",
"format": "int32",
"description": "FEC shards recovered this window (delta).",
"minimum": 0
},
"fps": {
"type": "number",
"format": "float",
"description": "Genuine NEW frames/s from the source."
},
"frames_dropped": {
"type": "integer",
"format": "int32",
"description": "Frames dropped this window (delta).",
"minimum": 0
},
"mbps": {
"type": "number",
"format": "float",
"description": "Transmit goodput (Mb/s)."
},
"packets_dropped": {
"type": "integer",
"format": "int32",
"description": "Packets dropped this window (receiver-side / reassembler, where known).",
"minimum": 0
},
"repeat_fps": {
"type": "number",
"format": "float",
"description": "Re-encoded holds/s (source-starvation indicator)."
},
"send_dropped": {
"type": "integer",
"format": "int32",
"description": "Host send-buffer overflow / EAGAIN this window (delta).",
"minimum": 0
},
"session_id": {
"type": "integer",
"format": "int32",
"description": "Disambiguates concurrent sessions (usually constant).",
"minimum": 0
},
"stages": {
"type": "array",
"items": {
"$ref": "#/components/schemas/StageTiming"
},
"description": "Ordered pipeline stages for this path."
},
"t_ms": {
"type": "integer",
"format": "int64",
"description": "Milliseconds since capture start (monotonic; stamped by [`StatsRecorder::push_sample`]).",
"minimum": 0
}
}
},
"StatsStatus": {
"type": "object",
"description": "Snapshot of the in-progress capture for the management API.",
"required": [
"armed",
"sample_count",
"started_unix_ms",
"kind"
],
"properties": {
"armed": {
"type": "boolean",
"description": "Capture currently running."
},
"kind": {
"type": "string",
"description": "Path of the in-progress capture (`\"\"` if idle)."
},
"sample_count": {
"type": "integer",
"format": "int32",
"description": "Samples in the in-progress capture.",
"minimum": 0
},
"started_unix_ms": {
"type": "integer",
"format": "int64",
"description": "Unix start time of the in-progress capture (`0` if idle).",
"minimum": 0
}
}
},
"StreamInfo": {
"type": "object",
"description": "RTSP-negotiated stream parameters.",
@@ -1696,6 +2220,10 @@
{
"name": "library",
"description": "Game library: installed-store titles (Steam) plus user-curated custom entries"
},
{
"name": "stats",
"description": "Streaming performance-stats capture: arm/stop a recording, read the live + saved time-series for graphing"
}
]
}