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

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