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) of QueuedEvent records with URL, payload, and enqueue timestamp.
  • Flush scheduler: a single pending setTimeout (_flushTimer, _flushScheduled) coalescing enqueues into one FLUSH_INTERVAL_MS (2000ms) flush.
  • Telemetry session lifecycle: currentSessionId (returned by startTelemetrySession) and currentPlayerId (set by setTelemetryPlayerId).
  • 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_queue key) populated by queueLocally and drained by flushTelemetryQueue.
  • buildRunPayload — the canonical mapping from RunTelemetry to the telemetry_runs Supabase row, including the perf_flags jsonb wrapper that nests longTasks and rich device summary.

READS FROM

  • ./collectortelemetry collector singleton (collector.onHeartbeat, collector.active, collector.finalizePartial) and the RunTelemetry type for run payloads.
  • ../core/configBUILD_VERSION (stamped on every session and run) and PERF_FLAGS (isMobile, dprOverride nested into perf_flags).
  • ./perf-observerdrainLongTasks() for the per-run long-task list and getRichDeviceSummary() for GPU/refresh/cores/memory tier, both spread into perf_flags.
  • import.meta.env.VITE_SUPABASE_URL and VITE_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, and flushTelemetryQueue.
  • POST {SUPABASE_URL}/rest/v1/telemetry_sessions with Prefer: return=representation — one row per app boot from startTelemetrySession, returns the session id.
  • PATCH {SUPABASE_URL}/rest/v1/telemetry_sessions?id=eq.{currentSessionId} — sets ended_at, sent with keepalive: true from endTelemetrySession.
  • POST {SUPABASE_URL}/rest/v1/rpc/increment_session_runs with { sid } — fired from sendRunTelemetry to bump the session run counter.
  • localStorage["telemetry_queue"] — JSON array, written by queueLocally on any send failure and drained per-row by flushTelemetryQueue.

DOES NOT

  • Does not gather telemetry — ./collector produces RunTelemetry, this file only ships it.
  • Does not block gameplay or await sends on the hot path; sendRunTelemetry resolves after enqueue, not after the network call.
  • Does not use navigator.sendBeacon — Supabase PostgREST needs apikey as a custom header, which sendBeacon cannot set; unload paths use fetch with keepalive: true instead.
  • 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 beforeunload handler itself; endTelemetrySession only documents that it must not await for that caller.
  • Does not authenticate as the player — uses the anon apikey for all requests; currentPlayerId is informational metadata only.
  • Does nothing when SUPABASE_URL or SUPABASE_KEY is empty, and clears the in-memory queue in that case.

Signals

  • collector.onHeartbeat(run => ...) — subscribed at module load; each heartbeat builds a run payload, overrides cause_of_death to 'heartbeat', and enqueues to telemetry_runs.
  • document.addEventListener('visibilitychange', ...) — on document.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 window and document exist (browser-only guard).

Entry points

  • setTelemetryPlayerId(playerId: string | null): void — set after auth resolves so subsequent payloads carry player_id.
  • startTelemetrySession(): Promise<string | null> — POST a telemetry_sessions row at app boot; stores the returned id in currentSessionId.
  • endTelemetrySession(): void — PATCH the session row’s ended_at with keepalive: true. Safe from beforeunload (no await).
  • sendRunTelemetry(run: RunTelemetry): Promise<void> — enqueue a finalized run to telemetry_runs and fire the increment_session_runs RPC. Resolves immediately after enqueue.
  • sendRunTelemetrySync(run: RunTelemetry): void — page-unload finalized-run path. Drains the queue and POSTs the run directly with keepalive: true.
  • sendPartialRunOnUnload(): void — if collector.active, calls collector.finalizePartial() and ships a partial-run row with keepalive: 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 Map before 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 to queueLocally. Comment on damage_chain_log calls this out as the intended graceful path when migration 045 has not been applied.
  • Unload path uses fetch ... keepalive: true rather than sendBeacon because PostgREST requires the apikey header, which sendBeacon cannot set.
  • perf_flags is built as a jsonb wrapper that nests longTasks and the spread of getRichDeviceSummary(), avoiding a Supabase schema migration per new device-signal field.
  • _flush early-returns on empty queue and clears the queue (no send) when Supabase env vars are absent, preventing unbounded growth in headless/dev contexts.
  • queueLocally catches storage-full/unavailable errors silently — the fallback is best-effort, not a guarantee.
  • flushTelemetryQueue POSTs each queued payload individually (not as a batch), and persists only the still-failing rows back to localStorage.