Skip to content

Log stats-for-nerds stream statistics through the data lake - #2998

Open
cal-oriley wants to merge 12 commits into
bluerobotics:masterfrom
cal-oriley:issue-2947-log-stats-for-nerds
Open

Log stats-for-nerds stream statistics through the data lake#2998
cal-oriley wants to merge 12 commits into
bluerobotics:masterfrom
cal-oriley:issue-2947-log-stats-for-nerds

Conversation

@cal-oriley

@cal-oriley cal-oriley commented Sep 1, 2026

Copy link
Copy Markdown

Summary

  • The metrics shown in each stream's stats-for-nerds panel are now recordable for offline analysis of transients near the 10 Hz sampling frequency. WebRTC stats were already published to the data lake and are now keyed by internal stream name (with frameHeight and the library-computed bitrate newly added); go2rtc/RTSP ingest stats are published for the first time: raw cumulative bytes/packets plus the sample epoch, bitrate/packet rates derived over the sampler's own window, and codec/width/height/fps/protocol metadata, keyed stream-<internalName>-rtsp-<key>.
  • Recording reuses the existing data lake logger in raw mode - no dedicated recorder. Arming happens per variable in the Data Lake table, exactly like any other variable; sessions, CSV/JSON/ZIP export and deletion in the Data Logs view are reused as-is.
  • One WebRTC stats collector per stream (new useStreamStats composable) fans snapshots out to the panel and the data lake, so an open panel no longer doubles the 10 Hz sampling work. The go2rtc sampler lives there too, running only while an RTSP stream is active - at 10 Hz while a stats panel is open or an armed stream is being recorded, and at a 5 s idle cadence otherwise - and differencing the raw counters over its own fixed window.
  • Prerequisite fixes: go2rtc rates were computed against a module-global previous-sample map, so the integration window depended on which caller polled last - the API now exposes raw counters + sample epoch and each consumer differences its own window. The data lake logger now flushes buffered points on window unload (like system logging), and a session's point count is rolled back when a batch fails to store, so the metadata never overstates what is on disk.
  • The stats-for-nerds plots now scroll at the same cadence for both stream types: the WebRTC plot pushes one point per 10 Hz stats sample instead of re-plotting duplicate values every 20 ms, so both panels show the same 10-second window.
  • Review follow-up: the go2rtc sampler can no longer fork a second poll chain when a panel opens mid-tick; rename handover survives whole-array correspondency replacement (discovery and BlueOS sync) and updates the recorded set in one write; a failed or not-yet-ready go2rtc query skips the tick instead of publishing a fake zero-bitrate stall; unmapped streams are not published under the credential-bearing external id.

Notes for reviewers:

  • Stream stat variable ids moved from stream-<externalId>-* to stream-<internalName>-*. Anyone who armed stream variables before this change keeps dead stream-<externalId>-* entries in their machine-local recorded-variables set until they re-arm - no migration (the correspondency is vehicle-synced and not yet loaded at migration time, so an automatic migration could miss mappings and still mark itself run). The affected population is users who renamed a stream and had individually armed its variables in the Data Lake table. Renames after this change carry the recording over to the new ids automatically (local and BlueOS-synced renames alike).
  • One panel metric is intentionally not recorded as a variable: the RTSP Stalls count, derivable as the zero-bitrate samples in the rtsp-bitrateKbps series. The panel's smoothed WebRTC bitrate is likewise just an EMA over the now-published bitrate series.
  • RTSP stats are published only in Standalone (RTSP streams can't be activated in Lite).
  • Unit tests are waived by the developer for this change - verification is manual per the test plan below.

Test plan

  • yarn lint:fix passes with no errors or warnings
  • yarn build passes
  • Open a WebRTC stream's stats-for-nerds panel and confirm it still renders and updates (shared collector fan-out)
  • Open an RTSP stream's stats-for-nerds panel in Standalone and confirm it still renders and updates (shared go2rtc sampler fan-out)
  • Open stats-for-nerds on a WebRTC stream and an RTSP stream side by side and confirm both plots scroll at the same rate (10 s window each)
  • In the Data Lake table, arm a WebRTC stream's stream-<name>-* variables, set logging to Raw, record ~1 minute, export the session as JSON, and confirm the points land at ~10 Hz with changing values (this also sets the real churn figures)
  • Arm an RTSP stream's stream-<name>-rtsp-* variables in Standalone and confirm the raw counters, sampleEpoch, derived rates and metadata are recorded
  • With an RTSP stream active but no panel open and nothing armed, confirm the sampler idles at the 5 s background cadence, and switches to 10 Hz when a panel opens or a stream is armed
  • Rename an armed stream and confirm the recording follows: its stat variables stay armed under the new name's ids and the old-named variables disappear from the Data Lake table
  • Rename a stream while its RTSP stats-for-nerds panel is open and confirm the panel shows '...' during the brief stream reload and resumes with fresh stats (no frozen stale values)
  • Drop and reconnect a camera and confirm the recorded/plotted bitrate reads 0 through the outage, not negative
  • In Lite, confirm no rtsp-* variables appear and WebRTC arming still works
  • Reload with recording active and confirm the last buffered points survive (beforeunload flush)
  • Open the RTSP stats overlay while a poll is already in flight and confirm bitrate stays sane (no doubled sampling or understated rates)
  • After WebRTC/RTSP auto-discovery or a BlueOS sync, rename an armed stream and confirm recording follows the new ids
  • Confirm a transient go2rtc query failure does not publish a zero-bitrate stall or increment the overlay stall count
  • Switch a video widget between RTSP and WebRTC with the overlay open: fast polling starts for RTSP and stops for WebRTC
  • Confirm an unmapped stream does not create data-lake variables whose names contain the RTSP URL or password

Closes #2947

@CLAassistant

CLAassistant commented Sep 1, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
⚠️ IMPORTANT FIXES REQUIRED (Automated PR Review — round 1)

14 open findings: 5 major, 5 minor and 4 nit.

This PR makes the numbers shown in a video stream's "stats for nerds" overlay recordable, so they can be replayed and analysed after a dive. It moves the two things that were sampling those numbers — a per-panel collector and a background one — into a single shared place, adds the RTSP ingest counters (bytes, packets, bitrate, packet rate, codec, resolution) to the list of values Cockpit can record, and renames every recorded stream value to use the name the user gave the camera instead of its internal address. It also makes the recorder write out whatever it still has buffered when the window closes, and stop claiming it saved data when a save failed.

What still needs attention

# Problem What it means Severity Status
1.1 Poll loop can fork into several copies Opening the stats overlay at the wrong moment permanently doubles how often Cockpit interrogates the video service, and it keeps doubling every time it happens. major
1.2 Rename handover is wired to a stale list After the user renames a camera, an active recording of that camera's stats silently stops recording and leaves dead duplicate entries behind. major
1.3 A hiccup talking to the video service is read as "all cameras gone" A momentary blip makes the overlay report a video freeze that never happened, and writes a fake zero-bitrate reading into the recorded data. major
1.4 Fast/slow sampling is decided only when the overlay opens and closes Switching which camera a video widget shows either leaves the fast polling running forever or leaves the overlay updating once every five seconds. major
4.1 Camera password can still end up in a recorded value's name If Cockpit briefly loses track of a camera's friendly name, the camera's username and password appear as a row in the Data Lake list and in exported files. major
2.1 Old recording selections left behind with no cleanup and no notice Users who had armed stream statistics keep silently-dead entries that show up as permanently empty columns in every export. minor
5.1 Camera-name lookup repeated 66 times per sample Cockpit does hundreds of redundant list searches per second per camera, for a value that does not change between them. minor
5.2 Renaming an armed camera rewrites the saved selection dozens of times Renaming a camera with many statistics armed briefly freezes the interface while the recorder tears itself down and rebuilds once per value. minor
5.3 Idle polling duplicates a poll that already exists With nothing looking at the numbers, Cockpit asks the video service for the same information twice as often as the change description claims. minor
8.1 A commit fixes a slowdown that an earlier commit in the same branch created A reviewer reads a performance problem being introduced and then repaired, and the commit message blames a state that never shipped. minor
7.1 A comment rewritten over unchanged code Small deviation from the project's rule that existing comments stay put unless their code changes. nit
7.2 New value filed under the wrong heading in a list that has no headings Cosmetic mis-grouping in a distinction nothing actually uses. nit
11.1 One of the two stats caches is never cleaned A camera that goes away leaves its last WebRTC numbers cached for the rest of the session. nit
11.2 Recorder touches the browser window at import time without a guard Loading this module outside a browser page would fail, unlike the guarded pattern used elsewhere. nit
Change map — what was established before judging

Claims

Claim (PR body / commit bodies) Verdict
go2rtc rates were derived against a module-global previous-sample map, so the integration window depended on which caller polled last Verifiedsrc/electron/services/go2rtc.ts:342-343,369-381 is exactly that; both the panel's 100 ms poll and the video store's 5 s poll (src/stores/video.ts:178-181) feed the same map.
The API now exposes raw counters + sample epoch so each consumer differences its own window Verifiedsrc/types/video.ts:69-83 gains bytes/packets/sampleEpoch, and differenceGo2rtcSamples (src/libs/video/stream-stats.ts:34) is the only place rates are derived. No other consumer read bitrateKbps/packetsPerSec; getStreamDisplayInfo (src/stores/video.ts:239-247) only reads codec/width/height, so the type change is safe.
An open panel doubled the 100 ms sampling work; one collector per stream now fans out Verified — base src/stores/omniscientLogger.ts:182-208 and base src/components/VideoPlayerStatsForNerds.vue:186,206-213 each built their own WebRTCStats({ getStatsInterval: 100 }) for the same stream.
WebRTC stat ids are now keyed by internal stream name Verified for the go2rtc leg, partially for the WebRTC leginternalStreamName() (src/composables/useStreamStats.ts:76) falls back to the external id; see 4.1.
The idle sampler "falls back to the video store's 5 s background cadence" Contradicted — the composable runs its own go2rtcIdleSampleIntervalMs = 5000 timer (src/composables/useStreamStats.ts:64) in addition to src/stores/video.ts:178-181, which still polls the same IPC every 5 s. See 5.3.
"Renames after this change carry the recording over to the new ids automatically (local and BlueOS-synced renames alike)" Contradicted — see 1.2. The watcher is detached by the first whole-array write, and a BlueOS sync-in is exactly a whole-array write (src/composables/settingsSyncer.ts:142).
"Up to 250 ms of buffered points was lost on crash or reload" Half-verified — the 250 ms figure matches flushIntervalMs (src/libs/data-lake-logging.ts:21). beforeunload does not fire on a crash, so only the reload half holds.
The sampler "ran at 10 Hz from boot for every Standalone user with an active RTSP stream" (commit d45d0cc) Contradicted as a description of master — on master the 10 Hz poll only ran while a panel was mounted (base src/components/VideoPlayerStatsForNerds.vue:219-222). That state was created two commits earlier in this same branch. See 8.1.
"RTSP stats are published only in Standalone" VerifiedisElectron() guard at src/composables/useStreamStats.ts:99.

Failure sites

  • The shared-window rate bug lives at src/electron/services/go2rtc.ts:342-343 (prevStreamStats/streamRates module globals). In the diff, removed.
  • The doubled collector lives at base src/stores/omniscientLogger.ts:182 and base src/components/VideoPlayerStatsForNerds.vue:186. In the diff, both removed.
  • The 20 ms plot timer lives at base src/components/VideoPlayerStatsForNerds.vue:246. In the diff, removed.
  • The missing unload flush and the unconditional session advance live at base src/libs/data-lake-logging.ts:422-426. In the diff, both addressed.
  • Not in the diff: getStreamsInfo's catch { return {} } (src/electron/services/go2rtc.ts:394-396) conflates "the query failed" with "there are no streams". Master tolerated it because rates were cached across it; the new consumer acts on it. See 1.3.

Entry points

Function Reached from Frequency
useStreamStats / initialize (useStreamStats.ts:72,299) src/main.ts:90omniscientLogger.ts setup; also each panel's <script setup> one-shot (init), per user action (panel)
runGo2rtcSampler / sampleGo2rtcStreams / publishGo2rtcSample / isAnyActiveRtspStreamArmed (useStreamStats.ts:187,99,82,177) setTimeout chain started by the hasActiveRtspStreams watch per incoming message (10 Hz per active RTSP stream, 0.2 Hz idle)
differenceGo2rtcSamples / go2rtcStreamStatVariableId (stream-stats.ts:34,125) sampleGo2rtcStreams per incoming message (10 Hz × 10 keys)
pokeGo2rtcSampler / acquireGo2rtcSampling / releaseGo2rtcSampling (useStreamStats.ts:195,285,290) panel onMounted/onUnmounted per user action
carryOverStreamStatRecording (useStreamStats.ts:138) watch(videoStore.streamsCorrespondency) never in practice — see 1.2
ensureWebRtcStatVariables / internalStreamName / streamStatVariableId (useStreamStats.ts:158,76, stream-stats.ts:115) activeStreams watch and the on('stats') handler per incoming message (10 Hz × 33 keys × 2 call sites per stream)
on('stats') handler (useStreamStats.ts:226) @peermetrics/webrtc-stats internal 100 ms timer per incoming message (10 Hz per active stream)
getStreamsInfo (go2rtc.ts:349) IPC go2rtcGetStreamsInfo, from both the sampler and src/stores/video.ts:178-181 per incoming message
DataLakeLogger.constructor (data-lake-logging.ts:159) module import (main.ts chain, src/utils/migrations.ts) one-shot
flushPendingPoints (changed) / rollbackSessionPointCount (data-lake-logging.ts:412,447) 250 ms flush timer, teardownLogging, beforeunload / rejected setItem promise 4 Hz while recording / rare
panel update(), WebRTC-snapshot watch, rtspSample watch (VideoPlayerStatsForNerds.vue ~236-259) the two shared reactive maps per incoming message (10 Hz)
panel draw() requestAnimationFrame per frame

Invariants

  1. "Persisted artifacts use the internal stream name; the credential-bearing external id never appears in a variable id or label." Two producers write stream stat variables. sampleGo2rtcStreams enforces it by skipping unmapped streams (useStreamStats.ts:116-117). ensureWebRtcStatVariables / the on('stats') handler do not — they use ?? streamName (useStreamStats.ts:76). One producer covered, one not. The chokepoint fix is to make internalStreamName return undefined and have both callers skip, mirroring the go2rtc path. → 4.1
  2. "go2rtcSamplerTimer !== null means a sampler timeout is pending." Broken by runGo2rtcSampler itself, which never nulls the handle when its own timeout fires. → 1.1
  3. "videoStore.streamsCorrespondency is a stable reactive array." Broken by src/stores/video.ts:331,373,412,1539 and src/composables/settingsSyncer.ts:142, all of which replace .value wholesale. Every other in-tree watcher (VideoPlayer.vue:259-260, MiniVideoRecorder.vue:195-198,233-234) already uses the getter + deep: true form that survives this; the new one does not. → 1.2
  4. "An empty getStreamsInfo() result means no streams are registered." Broken by go2rtc.ts:394-396, which also returns {} on any HTTP/parse failure and when the port is not yet known. → 1.3
  5. "go2rtcPanelConsumers counts currently-open RTSP panels." Broken whenever a mounted panel's streamName prop changes protocol, which VideoPlayer.vue:8,244 allows. → 1.4
1. Correctness & Implementation Bugs — 4 findings

1.1 — major — the sampler timer handle is never cleared on fire, so the poll loop can fork

src/composables/useStreamStats.ts:187-199

const runGo2rtcSampler = async (): Promise<void> => {
  await sampleGo2rtcStreams()
  if (go2rtcSamplerTimer === null) return
  ...
  go2rtcSamplerTimer = setTimeout(() => void runGo2rtcSampler(), intervalMs)
}

pokeGo2rtcSampler = (): void => {
  if (go2rtcSamplerTimer === null) return
  clearTimeout(go2rtcSamplerTimer)
  go2rtcSamplerTimer = setTimeout(() => void runGo2rtcSampler(), 0)
}

go2rtcSamplerTimer is only ever nulled by the hasActiveRtspStreams watch (:270-275). When a scheduled timeout fires, the variable keeps the now-dead handle, so for the whole duration of the await on line 188 it is non-null while nothing is pending. Anything that runs during that window and reads the handle as "a timer exists" forks a second chain:

  • acquireGo2rtcSamplingpokeGo2rtcSampler (:285-288) lands mid-poll: clearTimeout on the already-fired handle is a no-op, and it schedules chain B at 0 ms. The in-flight chain A then resumes at line 189, sees non-null, and schedules itself too. Two chains, one tracked handle.
  • The same happens when hasActiveRtspStreams flips false→true mid-poll: the watch starts a chain because the handle is null, and the resuming run starts another because it is not.

Every fork permanently doubles the IPC + HTTP rate against go2rtc for the rest of the session (a fork only dies at the next stream teardown, and only the untracked chains die). At the 10 Hz cadence the window is the IPC round trip against a 100 ms period, so this is not a rare interleaving.

Fix: track intent separately from the handle — null go2rtcSamplerTimer at the top of runGo2rtcSampler and keep a samplerEnabled boolean that the hasActiveRtspStreams watch sets, then reschedule only while samplerEnabled is true. pokeGo2rtcSampler then clears the handle only when one is genuinely pending.

Consequence: opening the stats overlay while a poll is in flight permanently doubles how often Cockpit interrogates the video service, and it compounds each time it happens.

1.2 — major — the correspondency watcher binds to one array object, so the rename handover never fires

src/composables/useStreamStats.ts:249-258

videoStore.streamsCorrespondency.forEach((corr) => { lastInternalNames[corr.externalId] = corr.name })
watch(videoStore.streamsCorrespondency, (corrs) => { ... carryOverStreamStatRecording(lastName, corr.name) ... })

streamsCorrespondency is a useBlueOsStorage ref (src/stores/video.ts:71); reading it off the Pinia store unwraps it to the array object present at that moment. watch() on a reactive object tracks that object, not the ref, so the watcher dies the first time .value is replaced wholesale — which happens at src/stores/video.ts:331 (4K-cam remap), :373 and :412 (WebRTC/RTSP auto-discovery, which run within seconds of boot), :1539 (add RTSP stream), and src/composables/settingsSyncer.ts:142 (every BlueOS sync-in). Since initialize() runs from src/main.ts:90 before discovery, the watcher is detached almost immediately in a normal session, and a BlueOS-synced rename — the case the PR body explicitly claims works — can never fire it at all, because a sync-in is a replacement.

The consequence chain is concrete: ensureWebRtcStatVariables and publishGo2rtcSample immediately start writing under stream-<newName>-*, carryOverStreamStatRecording never runs, so the old ids stay in cockpit-data-lake-recorded-variables (armed, never emitting again), the old variables are never deleted from the Data Lake table, and lastInternalNames stays stale so no later rename recovers either.

Fix: use the getter form every other in-tree watcher on this ref already uses —

watch(() => videoStore.streamsCorrespondency, (corrs) => { ... }, { deep: true })

as at src/components/widgets/VideoPlayer.vue:259-280 and src/components/mini-widgets/MiniVideoRecorder.vue:195-198. Seed lastInternalNames from inside the callback rather than once at init, so a correspondency that arrives after boot is seeded rather than treated as a rename. (watch(videoStore.activeStreams, …) at :203 is fine as written — activeStreams at src/stores/video.ts:77 is only ever mutated in place, never reassigned.)

Consequence: after a user renames a camera, an armed recording of that camera's statistics silently stops producing data and dead duplicate rows accumulate in the Data Lake table.

1.3 — major — a failed go2rtc query is indistinguishable from "no streams", and now fabricates a stall

src/composables/useStreamStats.ts:99-111 prunes on absence from allInfo:

const allInfo = await window.electronAPI.go2rtcGetStreamsInfo()
Object.keys(prevGo2rtcCounters).forEach((name) => { if (!(name in allInfo)) delete prevGo2rtcCounters[name] })
Object.keys(go2rtcStreamSamples).forEach((name) => { if (!(name in allInfo)) delete go2rtcStreamSamples[name] })

But getStreamsInfo returns {} for three different situations (src/electron/services/go2rtc.ts:350 when the port is not yet known, and :394-396 for any HTTP or JSON failure) as well as for the legitimate "no streams registered". So one transient failure to reach the local go2rtc API:

  1. wipes prevGo2rtcCounters for every stream, so the next successful sample has no previous counters and differenceGo2rtcSamples is skipped — bitrateKbps and packetsPerSec are published as the hard-coded 0 at :125-126;
  2. that zero is then read by the panel as a stall (VideoPlayerStatsForNerds.vue, const isStalled = !warmUp && sample.bitrateKbps === 0 ? 1 : 0), incrementing the Stalls counter and drawing a red spike;
  3. and it is written to stream-<name>-rtsp-bitrateKbps, which the PR body nominates as the series from which stalls are reconstructed offline. A false zero there becomes a false recorded freeze.

Master was insulated because streamRates (go2rtc.ts:343) carried the last rates across an empty response; that caching is what the PR correctly removed, which is why the hole is only reachable now.

Fix at the root: have getStreamsInfo distinguish failure from emptiness — return undefined/throw on the catch path and on the missing-port path — and have sampleGo2rtcStreams skip the whole tick (no pruning, no publishing) rather than treating it as a fleet-wide disappearance. Failing that, publish undefined rather than 0 when no previous counters exist, so a missing rate is not recorded as a real zero.

Consequence: a momentary failure to reach the local video service makes the stats overlay report a stream freeze that never happened and writes a false zero-bitrate reading into the user's recorded data.

1.4 — major — the fast-cadence acquire/release is decided by a protocol check at mount and unmount only

src/components/VideoPlayerStatsForNerds.vue (head ~261-271):

onMounted(() => { draw(); if (isRtspStream()) acquireGo2rtcSampling() })
onUnmounted(() => { cancelAnimationFrame(animationFrameId); if (isRtspStream()) releaseGo2rtcSampling() })

isRtspStream() reads videoStore.getStreamProtocol(props.streamName), and props.streamName is externalStreamId, a computed bound at src/components/widgets/VideoPlayer.vue:8 over nameSelectedStream (:244-246). The panel is not keyed, so the prop changes in place whenever the user picks a different stream in that widget. Three unbalanced paths follow:

  • RTSP at mount, WebRTC at unmountreleaseGo2rtcSampling is skipped, go2rtcPanelConsumers stays incremented, and the 10 Hz poll runs for the rest of the session with no panel open. That is precisely the cost commit d45d0cc set out to remove.
  • WebRTC at mount, RTSP later — no acquire ever happens, so with nothing armed the panel is fed by the 5 s idle cadence: the RTSP plot advances one point every 5 s and bitrateStr/ppsStr sit at ... for seconds at a time.
  • Cold bootexternalStreamId is undefined until streamConnectionRoutine (VideoPlayer.vue:282-289) assigns a stream, so a widget that starts with the overlay already enabled can mount before the id resolves and never acquire.

Fix: hold a local let acquired = false and drive it from a watcher on isRtspStream() (or on props.streamName), acquiring on the false→true edge and releasing on the true→false edge; onUnmounted then releases iff acquired.

Consequence: changing which camera a video widget shows either leaves the fast polling running for the rest of the session or leaves the overlay updating once every five seconds.

2. Persistence & User Data — inventory, 1 finding

Inventory

Key / store Backend What happened
cockpit-data-lake-recorded-variables (src/libs/data-lake-logging.ts:13) machine-local (settingsManager) Not reshaped, but the ids it holds change meaning: stream-<externalId>-*stream-<internalName>-*, plus 2 new WebRTC keys and 10 new rtsp- keys. Newly written by carryOverStreamStatRecording (useStreamStats.ts:145-149). No migration — decision stated in the PR body.
cockpit-streams-correspondency (src/stores/video.ts:71) vehicle-synced (useBlueOsStorage) Read and watched only. Not written by this PR.
Cockpit - Data Lake Logs IndexedDB store machine-local (IndexedDB) Unchanged shape and key format (boot=…;epoch=…;seq=…).
Data Lake sessions store (DataLakeSessionRecord) machine-local (IndexedDB) Unchanged shape. dataPointCount semantics change: advanced synchronously before the write (data-lake-logging.ts:430-432) and decremented when the write rejects (:436-437, :447-454).
New data lake variables (stream-<internalName>-*, stream-<internalName>-rtsp-*) not persisted createDataLakeVariable is called without persistent/persistValue, so nothing lands in cockpit-persistent-*. Correct.

The backend choices are right: the recorded-variable selection is a per-operator preference and stays machine-local; nothing machine-specific is pushed to useBlueOsStorage. All keys are cockpit--prefixed. No automatic migration is added, which matches the AGENTS.md preference for the non-destructive route. rollbackSessionPointCount (:449) can only decrement points it itself added, so the count cannot go negative.

2.1 — minor — dead recorded ids are left in the persisted selection with no prune and no user-facing note

src/composables/useStreamStats.ts:115-117,162 and the "Notes for reviewers" section of the PR body.

The id change is a deliberate, documented decision, and re-arming is a reasonable ask. What is missing is the second half of the AGENTS.md rule ("when you leave them, tell the user what changed", AGENTS.md:132): nothing in the UI says the entries went dead, so a user with an armed selection discovers it by exporting a session and finding the columns empty. Two knock-ons the note does not mention:

  • In interval mode, snapshotRecordedValues (src/libs/data-lake-logging.ts:460-467) iterates recordedVariableIds and writes data[id] = undefined for ids that no longer exist, and toCsv/toJson derive their columns from the stored points (:670-671, :691-692). So every dead id becomes a permanently empty column in every future export, not merely a no-op.
  • The dead ids are the old format, which for an RTSP stream is the credential-bearing URL. Those column headers therefore carry the camera username and password into every exported CSV/JSON until the user re-arms — a pre-existing leak (master keyed all stream variables this way) that this PR is well placed to sweep up and does not.

Both are cheap to close: prune ids matching ^stream-.*-(<known stat key>)$ that no longer resolve via getDataLakeVariableInfo when the logger next starts, or at minimum surface a one-time snackbar naming the affected stream(s).

Consequence: users who had armed stream statistics keep silently-dead entries that show up as permanently empty columns in every export, and for RTSP cameras those column names contain the camera's password.

4. Security — 1 finding

Checked and clean: no obfuscated code, no encoded blobs, no hidden Unicode or homoglyphs in the added identifiers, no new network destinations (the only new I/O is the existing go2rtcGetStreamsInfo IPC to 127.0.0.1), no new dependencies, no build-script/CI/Dockerfile changes, no eval/Function()/v-html, no environment variables or tokens. The Electron main-process change (src/electron/services/go2rtc.ts) only removes rate derivation and widens the returned record; it adds no new privileges or surface.

4.1 — major — the credential-bearing external id can still reach a variable name on the WebRTC leg

src/composables/useStreamStats.ts:76

const internalStreamName = (streamName: string): string =>
  videoStore.internalStreamNameFromExternal(streamName) ?? streamName

The invariant the PR sets for itself, in its own comment at :114-115, is that skipping unmapped streams "keeps the external id (an RTSP URL, credentials included) out of variable names". sampleGo2rtcStreams enforces it by returning early when internalName === undefined (:116-117). The WebRTC leg does not: ensureWebRtcStatVariables (:158-168) and the on('stats') handler (:238) both go through the ?? streamName fallback.

RTSP streams played through go2rtc do have a peer connection (src/stores/video.ts:767-780 returns go2rtcManager.peerConnection), so they are monitored by the WebRTC collector like any other stream. Whenever the correspondency entry for a live, monitored stream is missing — deleteStreamCorrespondency splices the entry at src/stores/video.ts:1460 while the collector is still monitoring the peer, and a BlueOS sync-in (src/composables/settingsSyncer.ts:142) can transiently hand over a list that does not yet contain a locally-discovered camera — the fallback creates 33 data lake variables with id: stream-rtsp://user:pass@host/path-<key> and name: Stream 'rtsp://user:pass@host/path' - <key>, both of which are rendered in the Data Lake table and exportable.

This is strictly better than master, which used the external id unconditionally for every stream (base src/stores/omniscientLogger.ts:189-197) — but it is an incomplete fix that leaves the reported problem reachable. Close it at the chokepoint rather than at N producers: make internalStreamName return string | undefined and have both ensureWebRtcStatVariables and the stats handler bail when it is undefined, exactly as the go2rtc path does.

Consequence: if Cockpit briefly loses track of a camera's friendly name, that camera's username and password appear as rows in the Data Lake list and in any exported log.

5. Performance — 3 findings

5.1 — minor — the internal-name lookup is recomputed inside both 33-key loops

src/composables/useStreamStats.ts:158-168 puts the lookup inside the loop, where it does not depend on key:

webRtcStreamStatKeys.forEach((key) => {
  const internalName = internalStreamName(streamName)   // ← invariant across the loop
  const variableId = streamStatVariableId(internalName, key)

and :238 repeats it once per key again in the stats handler:

setDataLakeVariableData(streamStatVariableId(internalStreamName(streamName), key), videoData[key])

internalStreamNameinternalStreamNameFromExternal (src/stores/video.ts:205-208) is an Array.find over the reactive streamsCorrespondency proxy. ensureWebRtcStatVariables is called on every stats event (:230), not only on activation, so per active stream that is 66 array scans per 100 ms sample — ~660 scans/second/stream, ~2 600/second on a four-camera vehicle — for a value that changes only on a rename. This sits directly on the dataLake:setVariable hot path the guidelines call out, and it runs unprompted from a library timer rather than from anything the user did.

Fix: hoist const internalName = internalStreamName(streamName) to the top of ensureWebRtcStatVariables and to the top of the stats handler, and pass it into the loop.

Consequence: Cockpit performs hundreds of redundant list searches per second per camera for a value that does not change between them.

5.2 — minor — the rename handover rewrites the persisted selection and rebuilds every listener twice per armed id

src/composables/useStreamStats.ts:145-149

oldIds.forEach((oldId, index) => {
  if (!dataLakeLogger.recordedVariableIds.includes(oldId)) return
  dataLakeLogger.setVariableRecorded(oldId, false)
  dataLakeLogger.setVariableRecorded(newIds[index], true)
})

Each setVariableRecorded (src/libs/data-lake-logging.ts:363-377) writes the whole id list back through settingsManager and, in raw mode while logging, calls applyLoggingMode()teardownLogging() + a full re-registration of every raw listener. With a stream's 43 stat variables armed that is 86 settings writes and 86 complete listener teardown/rebuild cycles in one synchronous loop — on the order of a few thousand listenDataLakeVariable/unlistenDataLakeVariable calls — all on the main thread in response to a rename.

Fix: compute the new list once and assign it through the recordedVariableIds setter (:303-306), then let a single applyLoggingMode() run — or add a batched setVariablesRecorded(changes) to the logger, which is where the knowledge of "one settings write, one rebuild" belongs.

Consequence: renaming a camera that has many statistics armed briefly freezes the interface while the recorder tears itself down and rebuilds once per value.

5.3 — minor — the idle cadence is a second poll, not a fallback onto the existing one

src/composables/useStreamStats.ts:60-64 defines go2rtcIdleSampleIntervalMs = 5000 and the comment above it, along with commit d45d0cc's body, describes this as falling back to "the video store's 5 s background cadence". It is not a fallback: src/stores/video.ts:178-181 still runs its own unconditional setInterval(… fetchGo2rtcStreamInfo(), 5000), so an idle Standalone session with an active RTSP stream now makes two go2rtcGetStreamsInfo IPC round trips (each an HTTP request to go2rtc's /api/streams plus SDP parsing per stream, go2rtc.ts:349-397) per 5 s instead of one.

That is small in absolute terms, but it is the opposite of what the commit claims, and it is avoidable: the video store already holds the result in go2rtcStreamInfo (src/stores/video.ts:158). Either have the composable's idle path read that ref instead of polling, or drop the store's interval and let the store consume the composable's samples — one owner for the poll, which is what the PR's own framing promises.

Consequence: with nothing looking at the numbers, Cockpit asks the video service for the same information twice as often as the change description says it does.

7. Code Quality & Style — 2 findings

complexity-report.json (head 5157965, base 0eb78bd) reports 172 functions measured across 7 changed files with triggeredCount: 0 and truncated: false, so nothing in this PR crossed the complexity-12 or depth-4 thresholds. No complexity finding.

Otherwise the added code reads well and follows the project's structure: pure logic in src/libs/video/stream-stats.ts with no vue import, reactive orchestration in src/composables/, typed @param/@returns on every added block, no any, no scoped CSS duplicating a Tailwind utility, no long inline-expression strings, and omniscientLogger shrinks by 99 lines.

7.1 — nitupdate()'s JSDoc is rewritten while its body is unchanged

src/components/VideoPlayerStatsForNerds.vue — the diff replaces "Draws the lines and updates the stats" with a three-line block, and every line of the function body below it is diff context. AGENTS.md:34 makes existing comments immutable "unless the code lines they document also change in the same diff". The mitigating case is AGENTS.md:35's exception — the old summary said the function draws, which it never did — and the calling contract genuinely moved from a 20 ms timer to the stats watch, so the rewrite is an improvement rather than churn. Worth noting only because the rule keys on the documented lines, not on the caller. AGENTS.md:40 also asks for one sentence as the target; this one is three.

Consequence: small deviation from the project's rule that existing comments stay put unless their code changes.

7.2 — nitframeHeight and bitrate are filed under webRtcAverageStatKeys, in a split nothing consumes

src/libs/video/stream-stats.ts:51-90. webRtcCumulativeStatKeys and webRtcAverageStatKeys exist only to be concatenated on the next line into webRtcStreamStatKeys; neither is exported and no consumer distinguishes them, so the "cumulative"/"average" trailing comments now document nothing. The split was inherited from omniscientLogger, but the PR moved it into a new module and added frameHeight — a per-sample snapshot value, not an average — to the average bucket. Either collapse to one list while the module is being written, or keep the split and put frameHeight where it belongs.

Consequence: cosmetic mis-grouping in a distinction nothing actually uses.

8. Commit Hygiene — 1 finding

The 11 commits are individually well-scoped, each carries a real explanatory body, prefixes fit their changes (fix:/feat:/refactor:/perf: with an area), no wip/fixup!/squash! noise, no oversized commit, no AI-authorship trailers, and Closes #2947 correctly lives in the PR body only — no issue reference in any commit message. The sequencing (prerequisite fixes → shared collector → shared sampler → publish → on-demand cadence) is genuinely reviewable step by step.

8.1 — minord45d0cc repairs a regression 78a7d84 introduced two commits earlier, and misdescribes the baseline

d45d0cc ("perf: video: poll go2rtc stats at 10 Hz only on demand") opens with "The sampler ran at 10 Hz from boot for every Standalone user with an active RTSP stream, whether or not anything consumed the result." That was never true of master: on master the 100 ms poll lived inside the panel and ran only while it was mounted (base src/components/VideoPlayerStatsForNerds.vue:219-222), with the video store's 5 s interval as the only background cost. The state the commit describes was created by 78a7d84 in this same branch, which moved the sampler to a lifetime keyed on hasActiveRtspStreams.

AGENTS.md:191 asks for a commit that fixes an earlier commit on the same branch to be squashed into its target. The whole acquire/release mechanism (acquireGo2rtcSampling/releaseGo2rtcSampling and the demand-based interval) is intrinsic to the sampler 78a7d84 wrote, so folding d45d0cc into it — and rewording the body to describe the design rather than a regression that never shipped — leaves a branch where no commit introduces a slowdown for a later one to remove. As it stands a reviewer bisecting this branch sees a real 10 Hz-from-boot regression at 78a7d84, and a reader of d45d0cc is told master had a problem it did not have.

Consequence: a reviewer reads a performance problem being introduced and then repaired, and the commit message attributes it to a state that never reached users.

11. Nitpicks / Optional — 2 findings

11.1 — nit — only one of the two shared stats maps is pruned

src/composables/useStreamStats.ts:204-206 clears go2rtcStreamSamples for departed streams, with the comment "so consumers don't see stale data". webRtcStreamStatsSnapshots (:43) gets no equivalent: an entry written at :232 survives the stream's teardown for the rest of the session. The visible effect is limited (a newly mounted panel does not read the map until the next sample arrives), but the two maps are siblings with one stated rule between them, and the asymmetry will read as an oversight the next time someone adds a consumer. Add the matching delete in the same activeStreams watch.

Consequence: a camera that goes away leaves its last WebRTC numbers cached for the rest of the session.

11.2 — nit — the DataLakeLogger constructor reaches for window unguarded

src/libs/data-lake-logging.ts:159-161. dataLakeLogger is instantiated at module scope (:727) and the module is imported by src/utils/migrations.ts, so the constructor now runs window.addEventListener as an import side effect. That is fine in the renderer, which is the only place it currently loads, but the in-tree pattern for module-scope window access guards it — src/composables/useBaseStation.ts:206 uses typeof window !== 'undefined'. A one-line guard keeps the module importable from a non-DOM context (a node-environment unit test, or anything that ever pulls a type or a key constant out of it) without changing behaviour.

Related, on the same change: commit 226d8642's body says the flush recovers points lost "on crash or reload". beforeunload does not fire on a crash, and the listener is added at construction time, which for this singleton is import time rather than when recording starts. The reload half is real and matches the system-logging.ts:312-319 precedent; the crash half is not. Worth correcting in the commit body rather than in code.

Consequence: loading this module outside a browser page would throw at import, unlike the guarded pattern used elsewhere in the tree.

Sections with nothing to report (4)

3. AGENTS.md Adherence — ✅ (checked scope discipline against the diff — no unrelated renames, hook reorders or const/let swaps, and the streamRateVariableIdstreamStatVariableId move is required by the two-consumer split; no new dependency in package.json; separation of concerns followed exactly, with src/libs/video/stream-stats.ts importing only types from @/types/video and no vue; isElectron() guard present at useStreamStats.ts:99; every added JSDoc has a non-empty summary and typed @param/@returns, including the property signatures jsdoc/require-jsdoc's TSPropertySignature context demands on the useStreamStats return type; grepped the added exports of stream-stats.ts and useStreamStats.ts and every one has a call site in this PR, so no groundwork)

6. UI / UX — ✅ (the only user-visible surface touched is the stats-for-nerds canvas overlay, whose template, layout, colours and copy are unchanged; no dialog, no overlay-teleporting Vuetify control, no button, no icon control and no new user interaction is added, so nothing for the dialog-anatomy, theme="dark", button-token, padding, glass, stacking, sentence-case or logUserAction clauses to bite on — traced VideoPlayer.vue:4-9 as the only mount site)

9. Tests — ✅ (no test file is added, removed or weakened — git-tracked specs under src/** are untouched by all seven changed files, and the guidelines do not ask for new coverage on added logic)

10. Documentation — ✅ (JSDoc added on every new exported symbol and on the changed getStreamsInfo; grepped README.md for the Lite/Standalone feature table AGENTS.md:118 refers to and found none in-tree — RTSP itself is already Standalone-only and undocumented there, so this PR does not widen an existing gap)

Generated by Claude. This is advisory; a human reviewer must still approve.

@cal-oriley
cal-oriley force-pushed the issue-2947-log-stats-for-nerds branch from b1740e9 to d402407 Compare September 2, 2026 01:09
@cal-oriley

Copy link
Copy Markdown
Author

round 1 findings addressed in the rewritten branch:

  • 1.1: updateCurrentSession is synchronous with the flush again - its key range has to cover the batch or export/deletion can't see it, and the beforeunload path's promise callbacks may never run. a rejected write now rolls the point count back instead.
  • 1.2: the sampler skips go2rtc streams with no correspondency, so the credential-bearing external id never reaches a variable name.
  • 1.3: stat variables are registered on every publish, so a rename (local or synced from another topside) re-registers under the new ids. the stream reads unarmed in the panel until re-armed - that's the visible signal.
  • 1.4: counter resets return undefined and counters of departed streams are pruned, so a reconnect reads as 0 and trips stall detection instead of going negative.
  • 2.1: not migrating. the recorded set is machine-local while the correspondency is vehicle-synced and not loaded yet at migration time, so a boot-time migration could miss mappings and still mark itself run. affected population is renamers who had individually armed stream variables; the notice is in the PR body and flows to the release notes.
  • 3.1: restored the optional chain.
  • 3.2: dropped the two exports.
  • 5.1: the sampler now idles at the store's 5 s background cadence and runs at 10 Hz only while a stats panel is open or an armed stream is active.
  • 6.1: the description no longer names the sidecar.
  • 8.1: the durability work is now two commits.

also corrected the PR body's claim about spec files - src/tests exists; tests for this change remain waived per the plan.

@rafaellehmkuhl

rafaellehmkuhl commented Sep 2, 2026

Copy link
Copy Markdown
Member

@cal-oriley nice PR!

One tip: after each follow up round you need to call /review for the next one to start.

@cal-oriley
cal-oriley force-pushed the issue-2947-log-stats-for-nerds branch from d402407 to 9051f19 Compare September 2, 2026 18:07
@cal-oriley

Copy link
Copy Markdown
Author

update: the Stream Statistics arming panel in the Data Logs view is removed - arming happens by ticking the stream's stat variables directly in the Data Lake table, same as any other variable. the readme's RTSP row now points there. no functional change to the publishing, sampling, or recording paths.

@cal-oriley
cal-oriley marked this pull request as draft September 2, 2026 18:11
@cal-oriley

Copy link
Copy Markdown
Author

@rafaellehmkuhl thank you so much! I'm still working on it a bit, just realized I should convert it to a draft. I'll call /review once it's all ready!

@cal-oriley
cal-oriley force-pushed the issue-2947-log-stats-for-nerds branch 3 times, most recently from 3b409dd to 5157965 Compare September 2, 2026 22:03
@cal-oriley

Copy link
Copy Markdown
Author

/review

@cal-oriley
cal-oriley marked this pull request as ready for review September 2, 2026 22:31
@rafaellehmkuhl

Copy link
Copy Markdown
Member

/review

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
⚠️ IMPORTANT FIXES REQUIRED (Automated PR Review — round 2)

14 open findings (5 major, 5 minor, 4 nit) and 0 closed — unchanged from round 1.

No new commits since the last review. The head is still 515796575d44311eb723595a1d05cd12d73d2733, the same commit round 1 reviewed, the diff of changes since then is empty, and no /resolve command and no vote on a disputed finding is waiting to be applied. There is nothing to re-judge, so this round carries round 1's findings forward untouched and stops here rather than reprinting the same review. Round 1's comment remains the current review: its findings, file paths and suggested fixes stand as written there.

One note on the discussion since then, as a matter of record and not as a status change: a comment on the thread describes round 1's findings as addressed in a rewritten branch, and lists finding numbers that do not correspond to the ones round 1 raised. Whatever that work is, it is not at this head — the branch has not moved since round 1 — so nothing in it has been reviewed and no finding has been closed on it. Push the rewritten branch and call /review again, and the next round will judge every open finding against the new code.

Generated by Claude. This is advisory; a human reviewer must still approve.

@cal-oriley

Copy link
Copy Markdown
Author

/review

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown

/review requires write access to this repository. @cal-oriley has read.
A maintainer can comment /review to run it.

getStreamsInfo computed bitrate/packet rates against a module-global previous-sample map, so the integration window was the gap since any caller last polled - about 100 ms with a stats panel open, 5 s without, interleaved when both ran. Expose the raw cumulative bytes/packets and the sample epoch on Go2RTCStreamInfo instead, so each consumer differences over its own known window.
The recorded-variables set persists the variable ids, and persisted artifacts use the internal stream name, not the external id. Ids recorded before this switch never emit again and are re-armed under the new ids; no migration.
The stats-for-nerds panel's Size row reads frameHeight but it was not in the published WebRTC stat set, so it could not be recorded.
omniscientLogger and the stats-for-nerds panel each constructed a WebRTCStats instance per stream, so an open panel doubled the 100 ms sampling work. A new useStreamStats composable owns the single collector per stream and fans each snapshot out to the panel and to the data lake publisher, which moves out of omniscientLogger with it.
The stats-for-nerds panel polled go2rtc itself while open. The sampler now lives in useStreamStats with its own lifetime (running only while an RTSP stream is active), differencing the raw counters over its own fixed window via the new pure src/libs/video/stream-stats.ts helper and fanning the samples out to the panel.
RTSP stream stats never reached the data lake - the stats-for-nerds panel was their only consumer. The shared sampler now publishes them (Standalone only): raw cumulative bytes/packets plus the sample epoch, so any rate over any window is derivable offline, bitrate/packet rates derived over the sampler's own window for live use, and the codec/width/height/fps/protocol metadata. Ids are keyed stream-<internalName>-rtsp-<key> so the ingest leg stays unmistakable from the WebRTC leg of the same stream. The stat key lists and variable id builders move into src/libs/video/stream-stats.ts, now that two consumers share them. Registration of a stream's stat variables now happens on every publish, so a rename re-registers under the new ids, and go2rtc streams with no correspondency are skipped, keeping the credential-bearing external id out of variable names.
Up to 250 ms of buffered points was lost on crash or reload for lack of a beforeunload flush like system-logging's.
A rejected write dropped the batch while the session point count still advanced, so the metadata overstated what was stored. The session still advances synchronously with the flush - its key range must cover the batch for export and deletion to find it, and the beforeunload path's promise callbacks may never run - so a rejected write rolls the count back instead.
The sampler ran at 10 Hz from boot for every Standalone user with an active RTSP stream, whether or not anything consumed the result. It now falls back to the video store's 5 s background cadence when idle, and runs at 10 Hz only while a stats-for-nerds panel is open (via acquire/release) or an active stream has recorded rtsp-* variables.
The WebRTC plot pushed a point every 20 ms from a timer while the underlying stats only update at 10 Hz, so it scrolled five times faster than the RTSP plot by re-plotting duplicate values and showed a 2 s window against the RTSP plot's 10 s. The plot now pushes one point per stats sample, so both panels show the same 10-second window; the updateInterval prop and its timer are gone with it.
The stats library already computes a per-sample bitrate from the bytesReceived deltas, and reconstructing it offline from the recorded counters is needlessly annoying, so the value is now published directly as stream-<internalName>-bitrate. The panel's smoothed display value stays unrecorded - it is just an EMA over this series.
@cal-oriley
cal-oriley force-pushed the issue-2947-log-stats-for-nerds branch from c946777 to f826512 Compare September 4, 2026 16:27
@rafaellehmkuhl

Copy link
Copy Markdown
Member

/allow-extra-reviews

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown

Granted 3 extra. This PR has 4 left.

@rafaellehmkuhl

Copy link
Copy Markdown
Member

Granted 3 extra. This PR has 4 left.

@cal-oriley you can ask for reviews now.

@cal-oriley

Copy link
Copy Markdown
Author

/review

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown

This PR has 3 left. A maintainer can comment /allow-extra-reviews to grant more (default 3).

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
⚠️ IMPORTANT FIXES REQUIRED (Automated PR Review — round 3)

16 open findings (5 major, 6 minor, 5 nit) and 1 closed.

This PR moves the numbers behind the video "stats for nerds" panel out of the panel itself and into a shared composable, so that they can also be written into Cockpit's data lake and recorded like any other telemetry. Two sources are involved: per-stream WebRTC statistics collected by the browser, and ingest counters polled from the go2rtc server that Standalone runs for RTSP streams. The go2rtc polling now runs centrally, speeding up to ten times a second only while somebody is watching the panel or recording those values, and falling back to a slow cadence otherwise. Along the way the panel's own plotting cadence is fixed, and the log recorder gains a flush when the window closes.

What still needs attention

# Problem What it means Severity Status
1.1 Duplicate polling loops can fork Opening the stats panel at the wrong moment starts a second copy of the same background polling loop, and from then on the bandwidth figures shown and recorded are wrong. major
1.2 Rename handover never runs Renaming a video stream — or having another computer rename it — silently stops the recording of that stream's statistics, without telling anyone. major
1.3 A server hiccup is recorded as a real zero If the video server briefly fails to answer, the recording shows the stream dropping to zero bandwidth, which is indistinguishable from the stream actually stalling. major
1.4 Panel accounting breaks on stream switch Switching which camera a video widget shows leaves the fast polling either stuck on forever or switched off while the panel is still open. major
4.1 Camera passwords can end up in variable names When a stream has not been mapped yet, the full camera address — username and password included — becomes part of the recorded variable names and the exported file. major
1.5 Closing the window can overstate the log Closing Cockpit while recording can leave the session's saved point count higher than what was actually written, so an export silently misses the tail. minor
2.1 Stale recording entries are never cleaned up Old recording selections for renamed streams stay switched on forever and show up as permanently empty columns in every export. minor
5.1 Name lookup repeated on the fast path A list search runs several hundred times a second per stream to produce a value that never changes between searches. minor
5.2 Recording list saved twice per variable Handing recording over to a renamed stream rewrites the saved settings and rebuilds every listener twice for each recorded value. minor
5.3 Two polls of the same server The idle fallback is described as reusing an existing poll but is in fact a second one, so the video server is queried twice as often for the same data. minor
8.1 A commit repairs its own branch mate One commit presents a slowdown as a pre-existing problem when an earlier commit in this same PR introduced it, which misleads anyone reading the history. minor
7.1 Comment rewritten with unchanged code A comment was reworded although the code it describes was not touched, against the repository's comment rule. nit
7.2 Two values in the wrong group Two statistics are grouped as averages although they are not, which will mislead the next person adding one. nit
7.3 Two bandwidth values, two different units Two recorded bandwidth values use different units with nothing saying so, so plotting them together compares numbers a thousand times apart. nit
8.2 One change split across two distant commits The same one-line change is made twice, eight commits apart, forcing a reviewer to read the same thing in two places. nit
11.1 One cache is pruned, its twin is not Statistics for streams that no longer exist are kept in memory for as long as Cockpit runs. nit
Since round 2 — 1 closed, comparing 5157965f826512

The comparison range is not usable this round. incremental.diff was generated for 515796575d44311eb723595a1d05cd12d73d2733...f8265123af56533289fbb34e5760f7484c91b7b0, but its contents are not an increment: it carries master-only files that this PR does not touch (.github/scripts/review-*.sh, .github/workflows/*, src/electron/main.ts, src/electron/services/config-store.ts, src/libs/joystick/protocols/predefined-resources.ts, src/utils/migrations.ts) and presents this PR's own files as freshly added. That is the signature of a rebase or history rewrite between the two heads, so the file cannot say what the author changed. Every status below is judged against pr.diff and the base checkout instead. Round 2 was also a short-circuit (no new commits at the time), so it carried the ledger without any finding bodies — the bodies below have all been re-derived from the current code, and the commit SHAs cited in finding 8.1 are the current ones, not the pre-rebase SHAs the round-1 ledger title recorded.

Closed this round (1)

  • 11.2 — ⚪ No longer applicable. This finding was wrong when it was raised, and I am retracting it rather than reporting that the code changed. It claimed DataLakeLogger's constructor should follow an in-tree typeof window guard before calling window.addEventListener at module-import time. There is no such norm: src/libs/system-logging.ts:312 registers a beforeunload listener at module-import time with no guard at all, and vite.config.ts:72 sets the test environment to jsdom, so window exists in every context this module is imported from. The premise was mine and it was mistaken.

Still open (13 carried, 3 new)

  • 1.1, 1.2, 1.3, 1.4, 2.1, 4.1, 5.1, 5.2, 5.3, 7.1, 7.2, 8.1, 11.1 — ❌ Not addressed. Each still reproduces at the head revision; all thirteen are reprinted in full in their section blocks below.
  • 1.5, 7.3, 8.2 — new this round. They are not regressions from a push (there was none to measure); they come from re-running the sections over the whole of pr.diff, which the contract requires every round.

Commands and discussion. resolutions.json and decisions.json are both empty: no finding on this PR has been resolved by a maintainer or put to a vote, so nothing was closed on that basis and there is no open vote to report. new-comments.json holds four comments since round 2 — two /review invocations from cal-oriley, an /allow-extra-reviews from rafaellehmkuhl and a short maintainer note granting the extra rounds. None of them makes a claim about the code or disputes a finding, so none of them changed a status. No injected instructions were found in any of the untrusted inputs.

Change map — what was established before judging

Line numbers for added and changed lines are given at the head revision (computed from the diff hunks); quoted code from files this PR does not change is cited at the base checkout.

Claims (from the PR body and commit bodies, each checked against the code)

  • "Stream statistics are published to the data lake so they can be recorded."Verified. src/composables/useStreamStats.ts:158-173 arms the WebRTC variables and :226-243 writes them; src/libs/video/stream-stats.ts:115 and :125 mint the ids.
  • "Variables are keyed by the internal stream name."Contradicted in part. useStreamStats.ts:76 falls back to the external name whenever the correspondency has no entry, which is exactly the RTSP URL case (finding 4.1).
  • "Recording is carried over when a stream is renamed, including BlueOS-synced renames."Contradicted. The watch that would do it (useStreamStats.ts:252) is bound to the array object and is detached by the whole-array replacement every synced update performs (finding 1.2).
  • "go2rtc is polled at 10 Hz only on demand, falling back to the video store's 5 s background cadence when idle."Contradicted in part. The 10 Hz gating is real (useStreamStats.ts:187-193), but the idle path is a second independent 5 s poll, not the store's (finding 5.3).
  • "The sampler ran at 10 Hz from boot" (commit ac3688fe) — Contradicted as history. True of the tree only after c46692e2, four commits earlier in this same PR (finding 8.1).

Failure site — this PR carries three fixes alongside the feature.

  • Panel plotted at the wrong cadence (c051ab55): the misbehaving code is the panel's own update() timer in src/components/VideoPlayerStatsForNerds.vue; it is in the diff and the snapshot watch at head :201-208 replaces it correctly.
  • Session point count counted unstored batches (949f2ed3): the misbehaving code is updateCurrentSession in src/libs/data-lake-logging.ts:436-454 (base); it is in the diff, and the rollback at head :436-437/:441-455 addresses the batch-failure case but not the unload case (finding 1.5).
  • Pending points lost on unload (0d60cb0d): addressed by the constructor listener at head :155-161, with the caveat above.

Entry points

Function Reached from Frequency
useStreamStats.ts stats handler (:226-243) @peermetrics/webrtc-stats getStatsInterval: 100 timer per incoming message (10 Hz per stream)
runGo2rtcSampler (:187) self-rescheduling setTimeout, seeded by the hasActiveRtspStreams watch (:270-281) per incoming message (10 Hz active / 0.2 Hz idle)
pokeGo2rtcSampler (:195) acquireGo2rtcSampling (:285) and setVariableRecorded side effects per user action
internalStreamName (:76) called inside both 33-key loops on the 10 Hz path per incoming message
ensureWebRtcStatVariables (:158-173) the same 10 Hz stats handler per incoming message
carryOverStreamStatRecording (:138) the streamsCorrespondency watch (:252) never — the watch is detached before it can fire (finding 1.2)
differenceGo2rtcSamples (stream-stats.ts:35-49) sampleGo2rtcStreams per incoming message
acquire/releaseGo2rtcSampling (:285, :290) onMounted/onUnmounted in VideoPlayerStatsForNerds.vue:261-271 per user action
rollbackSessionPointCount (data-lake-logging.ts:441-455) the .catch on the batch write per incoming message (on failure only)
DataLakeLogger beforeunload handler (:155-161) window unload one-shot

Invariants

  • "A recorded stat variable id always contains the internal stream name, never the external one." Violated at useStreamStats.ts:76 (the ?? streamName fallback) and therefore at every consumer of it — the arming loop :162, the write path :238, and both id builders in stream-stats.ts:115/:125. The chokepoint is that single helper; the PR guards nothing. (Findings 4.1, 2.1.)
  • "There is at most one live go2rtc poll chain." Violated because go2rtcSamplerTimer is not cleared while the poll is in flight (:187-193), so pokeGo2rtcSampler can schedule a second chain against the same shared prevGo2rtcCounters window. (Finding 1.1.)
  • "A stream's data-lake ids follow it across renames." Relies on the streamsCorrespondency watch firing on replacement; src/composables/settingsSyncer.ts:142 (refedValue.value = newValue as T) replaces the whole array on every BlueOS sync, and src/stores/video.ts:331, :373, :412 and :1539 do the same locally. None is covered. (Finding 1.2.)
  • "The persisted session point count never exceeds what was written." Relies on every increment having a matching rollback; the unload path has none, and the epoch guard at :449 skips the rollback once a new session has started. (Finding 1.5.)
1. Correctness & Implementation Bugs — 5 findings

1.1 — go2rtcSamplerTimer is not cleared while a poll is in flight, so a poke can fork a second poll chain (major, carried from round 1)

src/composables/useStreamStats.ts:187-199:

const runGo2rtcSampler = async (): Promise<void> => {
  await sampleGo2rtcStreams()
  if (go2rtcSamplerTimer === null) return
  const intervalMs = go2rtcPanelConsumers > 0 || isAnyActiveRtspStreamArmed() ? 100 : 5000
  go2rtcSamplerTimer = setTimeout(() => void runGo2rtcSampler(), intervalMs)
}

pokeGo2rtcSampler = (): void => {
  if (go2rtcSamplerTimer === null) return
  clearTimeout(go2rtcSamplerTimer)
  go2rtcSamplerTimer = setTimeout(() => void runGo2rtcSampler(), 0)
}

While await sampleGo2rtcStreams() is pending, go2rtcSamplerTimer still holds the handle of the timer that already fired. A pokeGo2rtcSampler in that window (mounting a stats panel, or arming an rtsp-* variable) clears a dead handle and schedules a fresh chain; when the in-flight poll resumes it sees a non-null timer and schedules its own successor as well. Two chains now interleave against the shared prevGo2rtcCounters window in stream-stats.ts:35-49, so each difference is taken over a fraction of the interval it assumes and every published rate is understated by roughly the split. The orphan chain does terminate when the last RTSP stream goes away (the === null check), so this is corrupted data while a stream is live rather than a permanent leak — but the corruption is silent and is what gets recorded.

Fix: set go2rtcSamplerTimer to a sentinel meaning "running" before the await (or track an inFlight flag and have pokeGo2rtcSampler set a pokeRequested flag instead of scheduling), so exactly one chain can exist.

1.2 — the streamsCorrespondency watch is bound to one array object, so the rename handover never fires (major, carried from round 1)

src/composables/useStreamStats.ts:252:

watch(videoStore.streamsCorrespondency, (corrs) => {  })

Passing a reactive array (rather than a getter) binds the watcher to that specific array object. src/composables/settingsSyncer.ts:142 does refedValue.value = newValue as T on every BlueOS settings update, which replaces the array wholesale — and streamsCorrespondency is a useBlueOsStorage key (src/stores/video.ts:71), so this is the normal path, not an edge case. src/stores/video.ts:331, :373, :412 and :1539 replace it locally too. After the first replacement the watcher observes an orphaned array and never fires again.

The consequence is the opposite of what the PR advertises: carryOverStreamStatRecording (:138) never runs, so after a rename the old stream-<oldName>-* ids stay armed but stop receiving data, and the new ids are never armed. The user sees recording silently stop, with the settings still showing it as on. Note the local rename path at src/stores/video.ts:1437 mutates in place (streamCorr.name = newInternalName), so the local case survives until the next whole-array replacement — the synced case does not survive at all.

Fix: watch a getter, watch(() => videoStore.streamsCorrespondency, cb, { deep: true }), so the source is re-evaluated and survives replacement.

1.3 — getStreamsInfo returns {} for failure as well as for empty, so a transient error is recorded as a real stall (major, carried from round 1)

src/electron/services/go2rtc.ts (base :349-396, head ≈ :329-359) returns {} both when go2rtcPort is unset and from its catch. sampleGo2rtcStreams cannot distinguish that from "the server answered, no streams", so on a single failed poll the prune loop drops every entry and the zero-rate fallback at useStreamStats.ts:125-126 publishes zeros. Recorded, that is a genuine-looking bandwidth stall — the exact signature a user opens this data to investigate — produced by a hiccup in a status query. The next successful poll then differences against a missing previous sample.

Fix: have getStreamsInfo signal failure distinctly (null/undefined, or a { ok, streams } result) and have the sampler skip the cycle on failure, keeping the previous counters and publishing nothing, rather than treating it as data.

1.4 — acquire/releaseGo2rtcSampling are gated at mount and unmount only, so a stream switch mis-counts the consumer (major, carried from round 1)

src/components/VideoPlayerStatsForNerds.vue:261-271 calls acquireGo2rtcSampling() in onMounted and releaseGo2rtcSampling() in onUnmounted, each behind an isRtspStream() test on the stream that is current at that moment. src/components/widgets/VideoPlayer.vue:8 renders <statsForNerds :stream-name="externalStreamId" /> with no :key, and externalStreamId is a computed (:244), so changing the widget's stream re-renders the same component instance — no unmount, no remount. Switching from an RTSP stream to a WebRTC one therefore never releases (10 Hz polling stays on for the rest of the session), and switching the other way never acquires (the panel a user just opened shows 5 s-stale ingest numbers).

Fix: replace the lifecycle-hook pair with a watch on isRtspStream(streamName) that releases the old value and acquires the new one, keeping the onUnmounted release for the final teardown. Adding a :key on the component would also work but is the blunter of the two.

1.5 — the unload flush cannot uphold the point-count invariant it was added to protect (minor, new this round)

src/libs/data-lake-logging.ts:155-161 registers a beforeunload listener that flushes pending points, and :430-437 increments the session point count synchronously before the write and rolls it back in the write's .catch. On unload that pairing cannot hold: updateCurrentSession (base :436-454) persists through await sessionsDB.setItem(...) at :451, and neither that write nor the .catch that would undo the increment can complete after the document is gone. The count is raised, the write is abandoned, and the persisted session claims more points than sessionKeyRange (base :183-187) can actually export — silently truncating the tail of a recording at exactly the moment a user closes Cockpit expecting their data to be saved. rollbackSessionPointCount (:441-455) also refuses to act once the epoch has moved on (:449), so a rollback that is late for any reason is dropped rather than applied.

Fix: on the unload path, increment only after the batch write has resolved, or write the count and the batch in one transaction so a partial result is impossible. If neither is practical inside beforeunload, treat the persisted count as a ceiling and have the exporter derive the real count from the key range rather than trusting the field.

2. Persistence & User Data — inventory, 1 finding

Inventory

Key Backend What happened
cockpit-data-lake-recorded-variables (src/libs/data-lake-logging.ts:13) machine-local (settings-management.ts) Not reshaped, but the PR makes it accumulate a new family of ids (stream-<name>-*, stream-<name>-rtsp-*) whose names are derived from mutable stream names.
Data-lake session records / point keys (sessionsDB, sessionKeyRange at :183-187) machine-local (IndexedDB) Shape unchanged; the point-count field gains a rollback path (:430-455).
cockpit-streams-correspondency (src/stores/video.ts:71) vehicle-synced (useBlueOsStorage) Read only. The PR adds a dependency on it, it does not write it.

No key is added, no persisted shape is reshaped, no automatic migration is introduced, and no machine-specific value is put on a vehicle-synced key. The one issue is what the recorded-variables list accumulates:

2.1 — dead stream-<name>-* ids accumulate in the recorded-variables list with no prune and no user-facing note (minor, carried from round 1)

Variable ids are minted from the stream name (src/libs/video/stream-stats.ts:115, :125). Rename a stream — or let the handover fail as in finding 1.2 — and the old ids stay in cockpit-data-lake-recorded-variables forever: nothing removes an id whose variable no longer exists, applyLoggingMode (base :481-517) keeps rebuilding a listener for each one, and every subsequent export carries a permanently empty column per dead id. Combined with finding 4.1 those column headers can contain the RTSP URL, credentials included, so a stale entry is not merely noise in a file the user may share.

Fix: prune ids for streams that no longer appear in the correspondency when the list is loaded or when the handover runs, and/or surface the armed stat variables in the UI so a user can see and clear them. At minimum, the rename path must remove what it replaces.

4. Security — 1 finding

4.1 — the ?? streamName fallback lets a credential-bearing RTSP URL become a variable id and an export column header (major, carried from round 1)

src/composables/useStreamStats.ts:76:

const internalStreamName = (streamName: string): string =>
  videoStore.internalStreamNameFromExternal(streamName) ?? streamName

The fallback is to the external name whenever the correspondency has no entry. For an RTSP stream the external name is the URL, which in Cockpit routinely carries rtsp://user:password@host/path. That value then flows into ensureWebRtcStatVariables (:158-173), where it becomes part of 33 data-lake variable ids and their human-readable labels, into the write path at :238, and from there into cockpit-data-lake-recorded-variables and every CSV the user exports and shares. The unmapped case is not hypothetical — it is precisely the window between a stream appearing and the store mapping it, which is when a panel opened early will sample.

The invariant belongs at this single chokepoint, so the fix is local: when internalStreamNameFromExternal returns nothing, do not publish. Skip the sample (the sampler already skips unmapped go2rtc streams at :116-117 — apply the same rule here) rather than falling back to a value that can contain secrets. If a fallback really is needed, derive an opaque stable token from the URL rather than embedding it.

Every other sub-check in this section came back clean and is not written out.

5. Performance — 3 findings

5.1 — internalStreamName is recomputed inside both 33-key loops on the 10 Hz path (minor, carried from round 1)

src/composables/useStreamStats.ts:76 calls videoStore.internalStreamNameFromExternal, which is a linear find over the correspondency array (src/stores/video.ts:205-208). It is called inside the arming loop at :162 and again on the write path at :238, both of which iterate the full stat-key set. At the collector's 10 Hz cadence that is on the order of 660 array scans per second per stream, for a value that changes only when a stream is renamed.

Fix: hoist the lookup out of the loops — resolve it once per sample (or memoise it per external name, invalidated by the correspondency watch of finding 1.2) and pass the result down.

5.2 — carryOverStreamStatRecording calls setVariableRecorded twice per armed id (minor, carried from round 1)

src/composables/useStreamStats.ts:147-148 disarms the old id and arms the new one with two separate setVariableRecorded calls. Each one persists the whole recorded-variables list (src/libs/data-lake-logging.ts:292-306, :363-377) and, in raw mode, calls applyLoggingMode(), which tears down and rebuilds a listener for every recorded variable (base :481-517, :522+). With 33 keys per stream that is 66 full teardown/rebuild cycles and 66 writes for a single rename.

Fix: give the logger a batch operation that takes the whole new id list, persists once and reconciles listeners once, and call it from the handover.

5.3 — the idle cadence is a second poll of go2rtc, not the video store's (minor, carried from round 1)

useStreamStats.ts:61-62 and commit ac3688fe's body both describe the idle path as falling back to "the video store's 5 s background cadence". It does not: src/stores/video.ts:178-183 runs its own unconditional setInterval(..., 5000) calling fetchGo2rtcStreamInfo (:169-176), and runGo2rtcSampler schedules an independent 5 s chain of its own (:190). Standalone therefore queries go2rtc twice every five seconds, on two unsynchronised phases, for the same data.

Fix: either subscribe to the store's existing poll result for the idle case (making the composable's own timer exist only for the 10 Hz mode), or move the store's interval into the composable so there is one poll with two cadences. Whichever is chosen, correct the comment and the commit body, which currently describe code that does not exist.

7. Code Quality & Style — 3 findings

7.1 — update()'s JSDoc is rewritten while its body lines are unchanged (nit, carried from round 1)

src/components/VideoPlayerStatsForNerds.vue:185-189 reword the JSDoc block above update(), but the function body at :190-199 is untouched by the diff. AGENTS.md (comment policy, line 34) treats a comment whose code has not changed as immutable — the old wording was written by someone with context this PR does not have.

Fix: restore the original wording. If it is genuinely wrong now, that is a separate observation worth stating in the PR body.

7.2 — frameHeight and bitrate are placed in webRtcAverageStatKeys (nit, carried from round 1)

src/libs/video/stream-stats.ts:76-87 puts 'bitrate' (:77) and 'frameHeight' (:79) in the "average" set. Neither is an average of anything: one is a per-sample rate and the other is a frame dimension. Since both sets are concatenated at :88 and no consumer distinguishes them, the split is currently decorative — which is what makes it a trap for whoever adds the next key and reads the name as a specification.

Fix: rename the two sets for what they actually are (cumulative counters vs. instantaneous values), or drop the split and keep one list.

7.3 — two bandwidth variables published in different units, with nothing declaring either (nit, new this round)

f8265123 publishes stream-<internalName>-bitrate from the WebRTC stats library, which reports bits per second, while the go2rtc side publishes stream-<internalName>-rtsp-bitrateKbps in kilobits per second (stream-stats.ts:91-100, :125). Only one of the two names carries its unit, and the panel divides by 1000 at VideoPlayerStatsForNerds.vue:206-208 to reconcile them for display. In the data lake they sit side by side as bare numbers, so plotting both puts two series a factor of 1000 apart on one axis with no indication that this is expected.

Fix: name the WebRTC one for its unit too (-bitrateBps), or publish both in the same unit. The variable's display name is the only place a user will look for this.

8. Commit Hygiene — 2 findings

8.1 — ac3688fe repairs a regression introduced by c46692e2 in this same branch and describes it as pre-existing (minor, carried from round 1; SHAs restated for the current history)

c46692e2 ("refactor: video: share the go2rtc ingest sampler through useStreamStats") moves the sampler out of the panel and gives it a lifetime tied to any active RTSP stream — which is what makes it run at 10 Hz from boot for every Standalone user with such a stream, whether or not anything consumes it. Four commits later, ac3688fe ("perf: video: poll go2rtc stats at 10 Hz only on demand") fixes exactly that, and its body states it as a standing condition: "The sampler ran at 10 Hz from boot for every Standalone user with an active RTSP stream." On master before this PR it did not — the panel polled only while open. This is the self-correcting commit AGENTS.md (lines 188-199) rules out: a reviewer bisecting or backporting c46692e2 alone gets the regression, and the history attributes it to code that never had it.

Fix: squash ac3688fe into c46692e2 so the sampler lands already gated. If it must stay separate, the body has to say the previous commit in this branch introduced the behaviour.

8.2 — the same one-line change is split across two commits eight apart (nit, new this round)

aea166be ("feat: video: publish frameHeight to the data lake") and f8265123 ("feat: video: publish WebRTC bitrate to the data lake") each add a single string to webRtcAverageStatKeys in src/libs/video/stream-stats.ts:76-87, with eight commits between them. They are the same logical change to the same list, and reviewing the second means going back to the first. AGENTS.md (lines 188-199) calls out over-splitting alongside bundling.

Fix: squash the two into one commit that establishes the published key set.

11. Nitpicks / Optional — 1 finding

11.1 — webRtcStreamStatsSnapshots is never pruned while its sibling is (nit, carried from round 1)

src/composables/useStreamStats.ts:43 holds a per-stream snapshot map. The activeStreams watch at :203-207 prunes go2rtcStreamSamples for departed streams but leaves the snapshot map untouched, so entries for streams that no longer exist stay for the process lifetime. The amount is small and bounded by how many distinct streams a session sees, which is why this is a nit and not a leak worth blocking on — but the asymmetry between the two maps, three lines apart, reads as an oversight rather than a decision.

Fix: prune both maps in the same loop.

Sections with nothing to report (4)

3. AGENTS.md Adherence — ✅ (checked the added files against .eslintrc.cjs's jsdoc/require-jsdoc, jsdoc/require-returns, explicit-function-return-type and max-len: 180 — the added exports and interfaces in useStreamStats.ts and stream-stats.ts all carry typed, non-filler JSDoc; no dependency was added; src/electron/services/go2rtc.ts is a net deletion of 35 lines; every added export has a call site in this PR. The two AGENTS.md breaches found — the comment rewrite and the commit history — are raised in sections 7 and 8 where they belong.)

6. UI / UX — ✅ (the only component touched is VideoPlayerStatsForNerds.vue; its template is unchanged, no dialog, overlay-teleporting control, footer or icon-only control is added, and the change is a data-source swap behind an existing panel — the plotting cadence fix at :201-208 makes the displayed values track the 10 Hz samples rather than a slower timer, which is the user-visible improvement, not a new surface.)

9. Tests — ✅ (no file under src/**/*.test.ts or tests/ appears in pr.diff; no existing test is removed, weakened or made brittle. The new src/libs/video/stream-stats.ts is deliberately framework-agnostic and pure, which makes the extracted logic more testable than what it replaced.)

10. Documentation — ✅ (checked README.md for a Lite/Standalone feature table entry that would need updating: the go2rtc ingest statistics are Standalone-only, but that limitation is inherited from RTSP stream support itself, which the README already scopes — this PR adds no capability that differs between the builds beyond that. In-code JSDoc on the added public functions is present and typed.)

Generated by Claude. This is advisory; a human reviewer must still approve.

… and unmapped streams

The sampler could fork when a panel opened mid-poll, rename handover detached on correspondency replacement, a failed go2rtc query published a fake zero-bitrate stall, and unmapped WebRTC stats fell back to the credential-bearing external id.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Log "Stats for nerds" panel data

3 participants