sampler.ts

PURPOSE

Forensic flight recorder, monitoring, and product analytics in one fat-table pipeline. Three trigger modes (heartbeat, event, crash) all feed the telemetry_samples table (migration 046). Heartbeats capture full forensic state every 5s, events mark meaningful transitions, and crashes flush a 60-second pre-crash ring buffer alongside the crash row so post-mortem queries get full context without joins.

OWNS

  • Singleton Sampler API surface (setSession, setPlayer, setRun, tickFps, tick, event, crash, flush, reset).
  • Session / run / player ID state — set externally, cached for all subsequent rows.
  • In-memory pre-crash ring buffer (60s of 1Hz compact snapshots, ~150 bytes each).
  • FPS rolling window (last ~120 frames) and longest-frame tracker (30s window).
  • Local batched sender queue (drains every 2s into a single PostgREST POST).
  • Per-session crash row counter, capped at 5 to prevent quota burn in crash loops.
  • window.__sampler debug surface exposing queue length, ring length, FPS stats, and a manual flush.
  • Visibility-change and pagehide handlers that force-flush the queue with keepalive: true.

READS FROM

  • BUILD_VERSION from ../core/config — stamped onto every row as app_version.
  • CanvasGuardStats from ../diagnostics/canvas-guardstotalHits and reported go into the heartbeat canvas block.
  • import.meta.env.VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY — direct PostgREST target.
  • Live game objects passed by the caller (game, ship, camera, world) — read defensively with optional chaining, never mutated.
  • performance.now(), performance.timeOrigin, and (performance as any).memory for timing and heap stats.
  • window.innerWidth, window.innerHeight, window.devicePixelRatio for viewport metadata.

PUSHES TO

  • Supabase telemetry_samples table via POST {SUPABASE_URL}/rest/v1/telemetry_samples with anon key auth, batched payloads of all queued rows.
  • Row kind values written: heartbeat, event:<name>, crash:<reason>, ring.
  • Each row carries session_id, run_id, player_id, kind, game_time, payload, app_version, created_at.
  • Crash rows force-flush immediately with keepalive: true so they survive page death.

DOES NOT

  • Does not throw. Every public method is wrapped in try/catch; the sampler is instrumentation, not gameplay.
  • Does not retry failed flushes or queue to localStorage — drops on failure and logs a single warning. Avoids conflicting with the run-level sender queue.
  • Does not serialize array contents (enemies, particles, bullets) — only their lengths.
  • Does not include sprite or asset blobs.
  • Does not emit heartbeats unless a session ID is set.
  • Does not carry the ring buffer across runs — setRun(null) clears it.
  • Does not emit ring rows at idle; the ring lives only in memory until a crash flushes it.
  • Does not share the batched-sender module with sender.ts — keeps a local queue to minimize cross-module deps on the crash path.
  • Does not emit more than 5 crash rows per session or more than 60 ring rows per crash.

Signals

  • kind='heartbeat' — emitted every HEARTBEAT_INTERVAL_S (5s) while a session is active.
  • kind='event:<snake_case_name>' — one row per call to Sampler.event(name, ...). Examples per the file header: level_start, boss_spawn, boss_death, reward, player_death, recover_from_stuck.
  • kind='crash:<reason>' — one row per Sampler.crash(reason, ...) call. Example reasons from the doc: frame_error, invuln_stuck, recover_button, rapier_recursive, canvas_non_finite.
  • kind='ring' — pre-crash buffer rows, flushed in bulk only when crash() fires. Picked up by the crash_context_v materialized view by session_id + 60s window.

Entry points

  • Sampler.setSession(id) — set or clear session ID.
  • Sampler.setPlayer(id) — set or clear player ID.
  • Sampler.setRun(id) — set at run start, clear at run end. Clearing also wipes the ring buffer and longest-frame tracker.
  • Sampler.tickFps(rawDt) — call once per RAF tick from bridge.ts. Feeds the FPS window and longest-frame tracker.
  • Sampler.tick(game, ship, camera, world) — call once per loop frame from bridge.ts. Internally rate-limited: ring push at 1Hz, heartbeat at 5s.
  • Sampler.event(name, game, ship, camera, world, extra?) — fire a one-off labeled event row.
  • Sampler.crash(reason, game, ship, camera, world, extra?) — capture a crash, flush ring buffer, force-send.
  • Sampler.flush() — force-flush queued samples (used from page-unload paths).
  • Sampler.reset() — clear all in-memory state. Test seam.
  • window.__sampler — runtime introspection: queueLen, ringLen, fpsAvg, fpsMin, longestFrameMs, crashRows, and a flush() shortcut.

Pattern notes

  • Crash-safe instrumentation. Every public method swallows exceptions silently. Telemetry must never break gameplay.
  • Local batched sender. Mirrors sender.ts’s 2s POST flush pattern but kept self-contained to minimize import surface on the crash path.
  • Bounded payloads. Numeric values pass through _safeNum which rounds to 3 decimals and rejects non-finite numbers. Arrays summarized by .length. Ring snapshots stay ~150 bytes, heartbeats ~1KB.
  • Defensive optional chaining everywhere. Snapshots read game?.x?.y ?? null so missing fields never throw — useful when the game state is mid-construction or mid-teardown.
  • Two-tier sample resolution. Compact ring at 1Hz for survival-only state; full heartbeat at 5s for forensic depth. Crashes glue them together with ring rows timestamped via performance.timeOrigin + ts.
  • Quota caps. MAX_RING_FLUSH_ROWS=60 and MAX_CRASH_ROWS_PER_SESSION=5 prevent a tight crash loop from burning Supabase quota.
  • Visibility / pagehide flushes use keepalive: true. Lets the browser complete the POST even after navigation.
  • Failure drops silently. No localStorage fallback queue — would conflict with sender.ts’s run-level queue and grow unbounded.
  • window.__sampler debug surface. Read-only stats with a manual flush(). Mentioned window.__samplerHeartbeatSec in the header doc as a tuning hook (not currently wired in this file).