Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/sentry.server.ts
T

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;
}