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
SamplerAPI 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.__samplerdebug 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_VERSIONfrom../core/config— stamped onto every row asapp_version.CanvasGuardStatsfrom../diagnostics/canvas-guards—totalHitsandreportedgo into the heartbeatcanvasblock.import.meta.env.VITE_SUPABASE_URLandVITE_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).memoryfor timing and heap stats.window.innerWidth,window.innerHeight,window.devicePixelRatiofor viewport metadata.
PUSHES TO
- Supabase
telemetry_samplestable viaPOST {SUPABASE_URL}/rest/v1/telemetry_sampleswith anon key auth, batched payloads of all queued rows. - Row
kindvalues 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: trueso 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 everyHEARTBEAT_INTERVAL_S(5s) while a session is active.kind='event:<snake_case_name>'— one row per call toSampler.event(name, ...). Examples per the file header:level_start,boss_spawn,boss_death,reward,player_death,recover_from_stuck.kind='crash:<reason>'— one row perSampler.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 whencrash()fires. Picked up by thecrash_context_vmaterialized view bysession_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 frombridge.ts. Feeds the FPS window and longest-frame tracker.Sampler.tick(game, ship, camera, world)— call once per loop frame frombridge.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 aflush()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
_safeNumwhich 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 ?? nullso 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=60andMAX_CRASH_ROWS_PER_SESSION=5prevent 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.__samplerdebug surface. Read-only stats with a manualflush(). Mentionedwindow.__samplerHeartbeatSecin the header doc as a tuning hook (not currently wired in this file).