PURPOSE

Ad-hoc client diagnostic events fanned out across two channels in parallel: Sentry for searchable/alertable bug reports, and Supabase client_diag_events (migration 043) as a durable backup that survives a misconfigured Sentry DSN or a user whose network blocks sentry.io. Both sends are fire-and-forget and never throw, so callers can fire from render loops, error handlers, or unload paths without guarding.

OWNS

  • Module-level mutable _currentPlayerId (nullable string) used to attribute Supabase rows to the authenticated player.
  • The DiagEvent shape: event_type (stable identifier doubling as Sentry tag and Supabase column), optional level (info | warning | error, defaults to warning), optional message (defaults to event_type), optional payload record of arbitrary structured context.
  • The dual-channel send semantics for a single logical diagnostic event.

READS FROM

  • import.meta.env.VITE_SUPABASE_URL and import.meta.env.VITE_SUPABASE_ANON_KEY — resolved at module load into SUPABASE_URL and SUPABASE_KEY constants (default to empty string when unset).
  • BUILD_VERSION from ../core/config for the app_version column.
  • navigator.userAgent when navigator is defined; otherwise serializes null.
  • Module-private _currentPlayerId.

PUSHES TO

  • Sentry via dynamic import('@sentry/browser'), calling Sentry.captureMessage(message, level) inside a withScope block. The event_type is attached as a bug tag, and every payload entry is attached as a Sentry extra.
  • Supabase REST endpoint ${SUPABASE_URL}/rest/v1/client_diag_events via fetch POST with apikey and Authorization: Bearer headers, JSON-array body, and keepalive: true so the request survives page unload.

DOES NOT

  • Does not throw under any condition. Sentry import failures are swallowed with .catch(() => {}); Supabase fetch rejections are swallowed the same way; the entire Supabase block is wrapped in a defensive try/catch to absorb any import.meta weirdness.
  • Does not retry, queue, batch, or persist failed sends.
  • Does not initialize Sentry. If Sentry.init was skipped (no DSN), captureMessage is a no-op and that is the intended fallback.
  • Does not POST to Supabase when either SUPABASE_URL or SUPABASE_KEY is empty — early-returns silently.
  • Does not validate or sanitize payload contents.
  • Does not await either send; callers cannot observe success or failure.

Signals

  • Sentry tag: bug = event_type.
  • Sentry message: message (or event_type if message omitted).
  • Sentry level: info | warning | error (default warning).
  • Sentry extras: one per payload entry.
  • Supabase row columns posted: player_id, event_type, level, message, payload, app_version, user_agent.

Entry points

  • setDiagPlayerId(playerId: string | null): void — stores the authenticated player ID for subsequent Supabase event attribution.
  • logDiag(evt: DiagEvent): void — fires one diagnostic event to both channels.

Pattern notes

  • Sentry is loaded via dynamic import() so bundles do not pay the Sentry cost when the DSN is disabled at the deploy.
  • Both channels run unconditionally on every logDiag call; there is no preference order or short-circuit between them.
  • keepalive: true on the fetch is what permits calling logDiag from unload handlers and crash paths.
  • The Supabase body is wrapped in an array ([{ ... }]) to match PostgREST’s batch-insert shape.
  • _currentPlayerId is module-private; the only mutator is setDiagPlayerId. The default is null, which is posted as player_id: null for unauthenticated sessions.