137 lines
4.6 KiB
TypeScript
137 lines
4.6 KiB
TypeScript
/**
|
|
* 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<ErrorReportSeverity, SentrySeverityLevel> = {
|
|
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<string, string | undefined> = {
|
|
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<string, unknown>,
|
|
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;
|
|
}
|