engine/telemetry
PURPOSE — Captures per-run gameplay, performance, damage-pipeline, and device-context data during a run; samples a forensic flight-recorder ring buffer at heartbeat cadence with event and crash triggers; ships finalized run payloads, periodic heartbeats, sampler rows, and ad-hoc diagnostic events to Supabase (and Sentry for diag) via batched fire-and-forget HTTP with keepalive-on-unload and a local retry queue.
OWNS
- The singleton run collector and its per-run state: frame counter, FPS min/max/sum and rolling frame-time array, fixed-bucket FPS histogram, kill log, sample-gated damage-chain log and its pending-slot / last-flush gate, pickup log, heat log and overheat-edge latch, reward log, director-phase log, weapon stats map, 5-second state snapshots, render-perf timeline, spike log drained from render-diag, artifact event log and aggregated per-artifact stats, cached last-known game stats for partial finalize, run config (hull / level / seed / cause of death / start ISO), the heartbeat callback registration and accumulator, and the burst-capture window state.
- Sample-gating constants (damage-chain interval, snapshot interval, burst interval, burst frame count, heartbeat interval) and FPS histogram bucket definitions.
- The shape of the run payload and damage-chain entry as exported types.
- The flight-recorder sampler’s session/run/player identifiers, ring buffer with cutoff trimming, rolling FPS window and longest-frame tracker with rolling reset, in-memory sample queue with its flush timer, per-session crash-row cap, the snapshot builders for ring and heartbeat shapes, and the
window.__samplerlive-tuning probe. - The sender’s in-memory batched event queue, scheduled flush timer, stale-event warn threshold, session id and authenticated player id, and the localStorage retry queue under a stable key.
- Passive observers’ state: long-task ring with cap and run-relative clock, refresh-rate sample buffer and median lock, lazily captured GPU info and device context, and the async battery refresh latch.
- Dual-channel diag’s current authenticated player id and dual-write payload assembly.
READS FROM
engine/coreforGameState,ShipState,WorldStatetypes andBUILD_VERSION/PERF_FLAGS.engine/core/render-diagfor the per-pass render-perf snapshot, the spike drain queue, and the spike reset.engine/diagnostics/canvas-guardsfor the canvas-guard hit counters surfaced in heartbeat samples.- Build-time environment for the Supabase REST URL and anon key, and (optionally) the Sentry DSN feature flag implied by lazy
@sentry/browserimport. performance.now(),performance.memory,performance.timeOrigin,PerformanceObserver,navigator.userAgent,navigator.hardwareConcurrency,navigator.deviceMemory,navigator.connection,navigator.getBattery,window.screen,window.innerWidth/innerHeight,window.devicePixelRatio, WebGLWEBGL_debug_renderer_info, and rAF gap samples.- Live duck-typed reads of the running game / ship / camera / world objects inside the sampler snapshot builders.
PUSHES TO
- Supabase REST:
telemetry_sessionsinsert and PATCH-on-end,telemetry_runsbulk insert (used for both completed-run sends and periodic heartbeats),rpc/increment_session_runsafter a run send,telemetry_samplesbulk insert for heartbeat / event / ring / crash rows, andclient_diag_eventsinsert for the dual-channel diag path. - Sentry via lazy
@sentry/browserimport for the diag channel (tagged scope, per-key extras, severity-level message capture). localStorageretry queue when a sender batch fails or when the unload paths fall back.- The collector’s registered heartbeat callback (the sender consumes it to enqueue a periodic partial run as a
telemetry_runsrow with a heartbeat cause-of-death marker). window.__samplerfor live read-only stats and a manual flush hook.
DOES NOT
- Decide when to call any of the record entry points or what counts as a kill / hit / pickup / death / heat event / director phase / reward / artifact activation — every recorder is invoked by the upstream gameplay system that already owns that decision.
- Compute or assign damage values, route damage between shield and hull, run the invuln gate, or apply knockback — only records a sample-gated trace of what the damage system computed.
- Detect render spikes, run the per-pass render timers, or maintain canvas health — only drains the diag module’s already-built snapshots and spike entries.
- Compute the final score (currently mirrors kill count and is flagged in source as a placeholder).
- Detect crashes itself — the crash entry point must be called by the bridge frame-error catch, watchdog, or recover button.
- Authenticate the player or manage session identity beyond accepting the set / clear calls.
- Persist anything between runs other than the localStorage retry queue and the in-memory device / GPU / refresh-rate caches that survive within a single page load.
- Block gameplay on send completion — every send path is fire-and-forget, every public sampler / diag method swallows throws, and a failing batch drops to localStorage rather than retrying inline.
- Schedule its own ticks — the collector’s tick, the sampler’s tick / FPS tick, the long-task observer’s clock reset, and the refresh-rate sampler all expect the engine harness to drive them per frame.
- Define the database schema; the unknown-column retry path in the sender accommodates a missing-migration deploy by stripping the offending column and re-posting.
Signals fired / Signals watched — none. The module is plumbed by direct calls from the engine harness, damage path, combat layer, director, weapons, artifacts, and metagame; it emits no engine signals and subscribes to none. The only event-loop subscriptions it owns are the DOM visibilitychange and pagehide listeners installed at module load for unload-time flushes (one set in the sender, one in the sampler), and the browser longtask PerformanceObserver.
Entry points
TelemetryCollector.begin— reset all per-run buffers and stamp the run config; called once at run start.TelemetryCollector.tick— per-frame update: counts frames, advances run time, buckets FPS, drains render spikes, ticks heat-overheat edge detection, samples state and render-perf snapshots at the snapshot interval, fires burst-capture frames, fires the heartbeat callback at the heartbeat interval, and flushes the pending damage-chain slot at the damage-chain interval.TelemetryCollector.recordKill/recordDamageChain/recordPickup/recordHeatEvent/recordReward/recordDirectorPhase/recordWeaponFire/recordWeaponHit/recordDeath/recordArtifactEvent— gameplay-side event recorders.TelemetryCollector.onHeartbeat— register the periodic partial-run callback the sender consumes.TelemetryCollector.finalize— build the complete payload at clean game-over and mark the run inactive.TelemetryCollector.finalizePartial— build a payload from cached last-known stats for unload-time partial sends.TelemetryCollector.active— query whether a run is in progress.telemetry— singleton collector exported for the entire engine to share.startTelemetrySession— insert a new session row and remember its id for later attribution.endTelemetrySession— PATCH the session row’s ended-at via keepalive fetch.setTelemetryPlayerId— set or clear the attribution id consumed by every subsequent send.sendRunTelemetry— enqueue a finalized run for batched send and fire-and-forget the session run-counter increment.sendRunTelemetrySync— bypass the batching queue and post the run plus any pending queued rows with keepalive for unload.sendPartialRunOnUnload— if a run is active, finalize partial and post via keepalive.flushTelemetryQueue— drain the localStorage retry queue, rewriting it with what still fails.flushTelemetryNow— clear any pending timer and flush the in-memory batch immediately.Sampler.setSession/.setPlayer/.setRun— identity setters; clearing the run id also clears the ring buffer and longest-frame tracker.Sampler.tickFps— per-frame FPS feed maintaining the rolling window and longest-frame tracker.Sampler.tick— per-frame ring-buffer push at 1Hz and heartbeat enqueue at the configured cadence.Sampler.event— emit a one-off event row taggedevent:<name>carrying the full heartbeat snapshot plus extra fields.Sampler.crash— flush the ring as individual ring rows and append the crash row, then force a keepalive flush.Sampler.flush— force the sampler queue to drain.Sampler.reset— clear all sampler in-memory state (test seam).logDiag— fire a diagnostic event dual-channel to Sentry and Supabase.setDiagPlayerId— set the attribution id for diag events.installLongTaskObserver— install the browser long-task observer.resetLongTaskRunClock— re-zero the long-task run-relative clock and clear the captured ring.drainLongTasks— pop the captured long-task list (sender calls this at finalize so they ride along inperf_flags).sampleRefreshRate— feed a rAF timestamp into the refresh-rate sampler until it locks.getRefreshHz/getGpuInfo/getDeviceContext/getRichDeviceSummary— lazily-cached device/perf context surfaced into the finalized payload.
Pattern notes
- The collector treats its pre-allocated buffers as the steady-state allocation pool: per-frame recorders push into typed arrays, and the only object construction is the compact event shape itself. Finalize copies the maps / arrays into the payload object once.
- Damage-chain capture uses a single pending-slot overwrite plus a per-tick gate: callers can fire the recorder unconditionally on every damage attempt, and the gate guarantees at most one entry per sample interval, always the most recent.
- Snapshot and burst capture share the same per-frame accumulator pattern: an interval timer crosses the threshold, the accumulator subtracts (rather than resets) the interval to preserve any leftover dt, and a frame counter arms the next N frames for burst-detail emission.
- Heat overheat events are edge-triggered through a
wasOverheatedlatch so a sustained overheat produces one log entry, while stall / burn events are level-recorded by their own counters. - Artifact aggregation runs lazily at finalize: the log is the source of truth and the stats roll-up walks it once, routing each event’s
valto damage / healed / blocked by event-id switch and always raising the recorded tier. - The sender batches events through a shared in-memory queue keyed by destination URL, posts each group as a PostgREST array body, and on a 400 response decodes the error to detect PGRST204 missing-column conditions and retries the same batch with that column stripped. Unrecoverable failures fall to the per-payload localStorage queue.
- The sender registers itself as the collector’s heartbeat consumer at module load and rewrites the run-payload’s cause-of-death to a heartbeat marker before enqueueing — heartbeats and final runs share the
telemetry_runstable and are discriminated by that column. - Heartbeats and final-runs both use the same payload builder, which folds the long-task drain and the rich device summary into the
perf_flagsJSON column so they ship without requiring a schema migration. - The sampler is intentionally decoupled from the run collector: it has its own queue, its own unload listeners, its own snapshot builders, and its own identifiers. The ring is captured every second regardless of run state and only emitted on crash; the heartbeat row is gated on having a session id.
- The sampler’s snapshot builders read game / ship / camera / world via optional-chaining duck-typing rather than typed imports, which keeps the crash path resilient to partially-constructed or torn-down state and lets the same builder serve heartbeat, event, and crash rows.
- All public sampler methods and the diag entry point are wrapped in try/catch and swallow errors silently — telemetry is instrumentation, not gameplay, and is never permitted to throw.
- Visibility-change-hidden and pagehide listeners are installed at module load by both the sender and the sampler, each forcing a keepalive flush of its own queue so the browser honors the request as the page tears down.
- Per-run forensic context (FPS histogram, render-perf timeline, spike log, long-tasks, GPU info, refresh rate, device class) lives on the run payload, while live-system context (ring buffer, rolling FPS window, longest frame, canvas guard hits) lives on the sampler — the two pipelines are intentionally redundant so each survives the other’s failure mode.
- The diag channel is dual-write by design: Sentry for searchable bug-report ergonomics, Supabase for durable backup when Sentry is misconfigured or network-blocked; both writes are independent, fire-and-forget, and immune to each other’s failures.