148 lines
5.0 KiB
TypeScript
148 lines
5.0 KiB
TypeScript
/**
|
|
* 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
|
|
* `<script>window.__SENTRY_WEB_DSN__ = "..."</script>` without a rebuild).
|
|
*/
|
|
export function readClientSentryConfig(env: Record<string, string | undefined> = (import.meta as unknown as { env?: Record<string, string | undefined> }).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;
|
|
}
|