/** * Sentry server-side bootstrap for the React Router v7 SSR runtime. * * MUST be imported as the first statement in `apps/web/app/entry.server.tsx` * so the SDK can hook into the Node runtime (HTTP, async hooks) before * any other module sets up its own listeners. When `SENTRY_DSN_WEB` is * empty or unset this module is a no-op — staging/local-dev environments * without a Sentry account stay functional. * * Also exports {@link webErrorReporter}, an ErrorReporter-shaped facade * over the SDK so route loaders/actions can use the same interface as * the rest of the toolctrl monorepo (`@toolctrl/error-reporter`). * * @see docs/improvement-plan/02-observability/sentry-wiring-web.md (Step 4) */ import * as Sentry from "@sentry/node"; import { createNoopErrorReporter, type ErrorReportEvent, type ErrorReportSeverity, type ErrorReporter } from "@toolctrl/error-reporter"; type SentrySeverityLevel = "fatal" | "error" | "warning" | "log" | "info" | "debug"; const SEVERITY_TO_SENTRY_LEVEL: Record = { debug: "debug", info: "info", warn: "warning", error: "error", fatal: "fatal", }; export interface ServerSentryConfig { dsn: string | undefined; environment: string | undefined; release: string | undefined; tracesSampleRate: number; } export function parseServerSampleRate(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; } export function readServerSentryConfig(env: NodeJS.ProcessEnv = process.env): ServerSentryConfig { return { dsn: env.SENTRY_DSN_WEB || undefined, environment: env.SENTRY_ENVIRONMENT || env.NODE_ENV || undefined, release: env.SENTRY_RELEASE || undefined, tracesSampleRate: parseServerSampleRate(env.SENTRY_TRACES_SAMPLE_RATE, 0.1), }; } let initialized = false; /** * Initializes the Sentry Node SDK for SSR. Idempotent — returns `true` * when initialization happened (or had already happened), `false` when * the DSN was missing and the call is a no-op. */ export function initSentryServer(config: ServerSentryConfig = readServerSentryConfig()): boolean { if (initialized) return true; if (!config.dsn) return false; Sentry.init({ dsn: config.dsn, environment: config.environment, release: config.release, tracesSampleRate: config.tracesSampleRate, // PII strategy: NO IP, NO email — only the user-id is retained when // a downstream loader calls `Sentry.setUser({ id })`. sendDefaultPii: false, integrations: [ Sentry.httpIntegration(), Sentry.requestDataIntegration({ include: { ip: false, // Cookies/data/query may contain sensitive material — keep // them off-by-default and let operators opt-in per-route. cookies: false, data: false, query_string: false, }, }), ], }); initialized = true; return true; } /** * ErrorReporter facade so route loaders/actions can `reportError(webErrorReporter, { ... })` * without importing the Sentry SDK directly. Falls back to the safe noop * reporter when the DSN is unset (or when `initSentryServer` was never * called) — production code therefore stays decoupled from the SDK. */ function buildWebErrorReporter(): ErrorReporter { const noop = createNoopErrorReporter(); return { report(event: ErrorReportEvent): void { if (!initialized) { noop.report(event); return; } const tags: Record = { component: event.component, operation: event.operation, }; const sentryLevel: SentrySeverityLevel = event.severity ? SEVERITY_TO_SENTRY_LEVEL[event.severity] ?? "error" : "error"; const captureContext = { level: sentryLevel, extra: event as Record, tags, }; try { if (event.error !== undefined) { Sentry.captureException(event.error, captureContext); return; } const message = event.errorMessage ?? `${event.component}:${event.operation}`; Sentry.captureMessage(message, captureContext); } catch { // Transport failures must never mask the runtime failure. } }, }; } export const webErrorReporter: ErrorReporter = buildWebErrorReporter(); /** Test-only: reset the init guard for unit tests. */ export function __resetSentryServerForTests(): void { initialized = false; }