PURPOSE
Ships finalized telemetry from the browser to Supabase via PostgREST. Fire-and-forget — never blocks gameplay. Handles the in-memory batching queue, page-unload sync sends with keepalive fetch, and a localStorage fallback queue for failed sends.
OWNS
- In-memory batch queue (
_eventQueue) ofQueuedEventrecords with URL, payload, and enqueue timestamp. - Flush scheduler: a single pending
setTimeout(_flushTimer,_flushScheduled) coalescing enqueues into oneFLUSH_INTERVAL_MS(2000ms) flush. - Telemetry session lifecycle:
currentSessionId(returned bystartTelemetrySession) andcurrentPlayerId(set bysetTelemetryPlayerId). - Bulk-insert grouping: drained events are bucketed by URL and POSTed as PostgREST array bodies.
- PGRST204 unknown-column retry: on a 400 with that code, strips the offending column from every row in the batch and retries once.
- Stale-queue warning: events sitting longer than
STALE_QUEUE_WARN_MS(60s) log a warn on flush but are not dropped. - localStorage fallback queue (
telemetry_queuekey) populated byqueueLocallyand drained byflushTelemetryQueue. buildRunPayload— the canonical mapping fromRunTelemetryto thetelemetry_runsSupabase row, including theperf_flagsjsonb wrapper that nestslongTasksand rich device summary.
READS FROM
./collector—telemetrycollector singleton (collector.onHeartbeat,collector.active,collector.finalizePartial) and theRunTelemetrytype for run payloads.../core/config—BUILD_VERSION(stamped on every session and run) andPERF_FLAGS(isMobile,dprOverridenested intoperf_flags)../perf-observer—drainLongTasks()for the per-run long-task list andgetRichDeviceSummary()for GPU/refresh/cores/memory tier, both spread intoperf_flags.import.meta.env.VITE_SUPABASE_URLandVITE_SUPABASE_ANON_KEY— read at module load. Empty values disable all sends silently.- Browser globals:
navigator.userAgent,window.screen.width/height,window.devicePixelRatio,localStorage,document.visibilityState,document.hidden.
PUSHES TO
POST {SUPABASE_URL}/rest/v1/telemetry_runs— bulk array body of run payloads (heartbeats, finalized runs, partial unload runs). Path used by_postBatch,sendRunTelemetrySync,sendPartialRunOnUnload, andflushTelemetryQueue.POST {SUPABASE_URL}/rest/v1/telemetry_sessionswithPrefer: return=representation— one row per app boot fromstartTelemetrySession, returns the session id.PATCH {SUPABASE_URL}/rest/v1/telemetry_sessions?id=eq.{currentSessionId}— setsended_at, sent withkeepalive: truefromendTelemetrySession.POST {SUPABASE_URL}/rest/v1/rpc/increment_session_runswith{ sid }— fired fromsendRunTelemetryto bump the session run counter.localStorage["telemetry_queue"]— JSON array, written byqueueLocallyon any send failure and drained per-row byflushTelemetryQueue.
DOES NOT
- Does not gather telemetry —
./collectorproducesRunTelemetry, this file only ships it. - Does not block gameplay or await sends on the hot path;
sendRunTelemetryresolves after enqueue, not after the network call. - Does not use
navigator.sendBeacon— Supabase PostgREST needsapikeyas a custom header, which sendBeacon cannot set; unload paths usefetchwithkeepalive: trueinstead. - Does not retry network/5xx failures; only PGRST204 unknown-column 400s are retried (once, after stripping the bad column). Everything else goes straight to the localStorage fallback.
- Does not drop stale events — they log a warn and still flush.
- Does not register a
beforeunloadhandler itself;endTelemetrySessiononly documents that it must not await for that caller. - Does not authenticate as the player — uses the anon
apikeyfor all requests;currentPlayerIdis informational metadata only. - Does nothing when
SUPABASE_URLorSUPABASE_KEYis empty, and clears the in-memory queue in that case.
Signals
collector.onHeartbeat(run => ...)— subscribed at module load; each heartbeat builds a run payload, overridescause_of_deathto'heartbeat', and enqueues totelemetry_runs.document.addEventListener('visibilitychange', ...)— ondocument.hidden, calls_flush(true)so a hidden tab ships its pending batch with keepalive.window.addEventListener('pagehide', ...)— also calls_flush(true)for the unload path.- Visibility/unload listeners are registered once at module load and only when
windowanddocumentexist (browser-only guard).
Entry points
setTelemetryPlayerId(playerId: string | null): void— set after auth resolves so subsequent payloads carryplayer_id.startTelemetrySession(): Promise<string | null>— POST atelemetry_sessionsrow at app boot; stores the returned id incurrentSessionId.endTelemetrySession(): void— PATCH the session row’sended_atwithkeepalive: true. Safe frombeforeunload(no await).sendRunTelemetry(run: RunTelemetry): Promise<void>— enqueue a finalized run totelemetry_runsand fire theincrement_session_runsRPC. Resolves immediately after enqueue.sendRunTelemetrySync(run: RunTelemetry): void— page-unload finalized-run path. Drains the queue and POSTs the run directly withkeepalive: true.sendPartialRunOnUnload(): void— ifcollector.active, callscollector.finalizePartial()and ships a partial-run row withkeepalive: true.flushTelemetryNow(): Promise<void>— explicit flush for tests/integration; clears the pending timer and runs_flush(false).flushTelemetryQueue(): Promise<void>— retry sender for the localStorage fallback queue; rewrites the queue with only the rows that failed again.
Pattern notes
- Single-row sends always go as an array body of length 1, so PostgREST always sees the bulk-insert shape.
- Drain is atomic:
_eventQueue.splice(0, _eventQueue.length)clears in-flight events before the await, so concurrent enqueues start a fresh batch instead of being re-sent. - Events are grouped by URL into a
Mapbefore posting so each destination receives exactly one POST per flush. - PGRST204 retry strips only the column named in the error message (regex
the '(\w+)' column); a second failure spills the trimmed payloads toqueueLocally. Comment ondamage_chain_logcalls this out as the intended graceful path when migration 045 has not been applied. - Unload path uses
fetch ... keepalive: truerather thansendBeaconbecause PostgREST requires theapikeyheader, which sendBeacon cannot set. perf_flagsis built as a jsonb wrapper that nestslongTasksand the spread ofgetRichDeviceSummary(), avoiding a Supabase schema migration per new device-signal field._flushearly-returns on empty queue and clears the queue (no send) when Supabase env vars are absent, preventing unbounded growth in headless/dev contexts.queueLocallycatches storage-full/unavailable errors silently — the fallback is best-effort, not a guarantee.flushTelemetryQueuePOSTs each queued payload individually (not as a batch), and persists only the still-failing rows back to localStorage.