/** * Sentry client bootstrap for the React Router v7 web app. * * IMPORTANT: `apps/web/app/entry.client.tsx` dynamically loads this module * before hydration when a client DSN is configured so the SDK can install * global handlers before React mounts without forcing Sentry into builds * that do not enable client observability. * * Configuration is read from Vite-injected build-time env vars (prefixed * `VITE_`). When `VITE_SENTRY_DSN_WEB` is empty or undefined, this module * is a no-op — production builds without a configured DSN keep the app * functional and silent. * * @see docs/improvement-plan/02-observability/sentry-wiring-web.md (Step 2) */ import * as Sentry from "@sentry/react-router"; export interface ClientSentryConfig { dsn: string | undefined; environment: string | undefined; release: string | undefined; tracesSampleRate: number; replaysSessionSampleRate: number; replaysOnErrorSampleRate: number; } /** * Parses a string env value as a finite number between 0 and 1. * Falls back to `fallback` when the value is missing, empty or invalid. */ export function parseSampleRate(raw: string | undefined, fallback: number): number { if (raw === undefined || raw === null || raw === "") return fallback; const parsed = Number(raw); if (!Number.isFinite(parsed)) return fallback; if (parsed < 0) return 0; if (parsed > 1) return 1; return parsed; } /** * Reads the public Sentry configuration from Vite env vars + a runtime * window fallback (so the DSN can be hot-swapped via SSR-injected * `` without a rebuild). */ export function readClientSentryConfig(env: Record = (import.meta as unknown as { env?: Record }).env ?? {}): ClientSentryConfig { const windowDsn = typeof globalThis !== "undefined" && typeof (globalThis as { __SENTRY_WEB_DSN__?: unknown }).__SENTRY_WEB_DSN__ === "string" ? ((globalThis as { __SENTRY_WEB_DSN__?: string }).__SENTRY_WEB_DSN__ as string) : undefined; return { dsn: env.VITE_SENTRY_DSN_WEB || windowDsn || undefined, environment: env.VITE_SENTRY_ENVIRONMENT || env.MODE || undefined, release: env.VITE_SENTRY_RELEASE || undefined, tracesSampleRate: parseSampleRate(env.VITE_SENTRY_TRACES_SAMPLE_RATE, 0.1), replaysSessionSampleRate: parseSampleRate(env.VITE_SENTRY_REPLAYS_SESSION_SAMPLE_RATE, 0.1), replaysOnErrorSampleRate: parseSampleRate(env.VITE_SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE, 1.0), }; } let initialized = false; /** * Initializes the Sentry browser SDK. Safe to call multiple times — only * the first call has effect. Returns `true` when initialization actually * happened, `false` when the DSN was missing (no-op). */ export function initSentryClient(config: ClientSentryConfig = readClientSentryConfig()): boolean { if (initialized) return true; if (!config.dsn) return false; Sentry.init({ dsn: config.dsn, environment: config.environment, release: config.release, tracesSampleRate: config.tracesSampleRate, replaysSessionSampleRate: config.replaysSessionSampleRate, replaysOnErrorSampleRate: config.replaysOnErrorSampleRate, // PII strategy: do NOT send default PII; route tracing keeps the // transaction name (e.g. `/app/$tenant/...`) which is desired. sendDefaultPii: false, integrations: [ Sentry.reactRouterTracingIntegration(), Sentry.replayIntegration({ maskAllInputs: true, maskAllText: false, blockAllMedia: false, }), ], // Strip sensitive query-strings (`?token=...`, `?code=...`) from // transaction URLs before they are sent to Sentry. beforeSendTransaction(event) { if (event.request?.url) { event.request.url = scrubSensitiveQueryParams(event.request.url); } return event; }, }); initialized = true; return true; } const SENSITIVE_QUERY_KEYS = new Set([ "token", "code", "secret", "password", "access_token", "id_token", "refresh_token", "api_key", "apikey", ]); /** * Removes sensitive query parameters from a URL while preserving the * surrounding structure. Non-URL inputs are returned untouched. */ export function scrubSensitiveQueryParams(input: string): string { try { const url = new URL(input, "http://placeholder.local"); let modified = false; for (const key of Array.from(url.searchParams.keys())) { if (SENSITIVE_QUERY_KEYS.has(key.toLowerCase())) { url.searchParams.set(key, "[Redacted]"); modified = true; } } if (!modified) return input; // Preserve original-host vs relative-URL shape. if (input.startsWith("http://") || input.startsWith("https://")) { return url.toString(); } return `${url.pathname}${url.search}${url.hash}`; } catch { return input; } } /** * Test-only: reset the initialization guard so unit tests can re-enter * `initSentryClient` with different configurations. */ export function __resetSentryClientForTests(): void { initialized = false; }