115 lines
4.2 KiB
TypeScript
115 lines
4.2 KiB
TypeScript
// packages/core-shared/src/instrumentation/sentry/init-client-react.ts
|
|
// Browser-side Sentry init for Vite/React runtimes (TanStack Start). PII scrubbing is
|
|
// applied via beforeSend/beforeSendTransaction because browser does NOT use the OTel pipeline.
|
|
// PII field lists imported from otel/pii-fields.ts (vendor-neutral).
|
|
import * as SentryReact from "@sentry/react";
|
|
import type { InitClientOpts } from "./init-client";
|
|
import {
|
|
PII_KEY_SUBSTRINGS,
|
|
PII_QUERY_PARAM_SUBSTRINGS,
|
|
REDACTED_VALUE,
|
|
REDACTED_IP,
|
|
IPV4_REGEX,
|
|
IPV6_REGEX,
|
|
} from "../otel/pii-fields";
|
|
|
|
// R32 — inline scrub helpers for browser-side Sentry (server uses OTel processors instead).
|
|
function keyContainsPii(key: string): boolean {
|
|
const lower = key.toLowerCase();
|
|
return PII_KEY_SUBSTRINGS.some((s) => lower.includes(s));
|
|
}
|
|
|
|
function queryParamContainsPii(key: string): boolean {
|
|
const lower = key.toLowerCase();
|
|
return PII_QUERY_PARAM_SUBSTRINGS.some((s) => lower.includes(s));
|
|
}
|
|
|
|
function redactString(s: string): string {
|
|
const ipv4 = new RegExp(IPV4_REGEX.source, "g");
|
|
const ipv6 = new RegExp(IPV6_REGEX.source, "g");
|
|
return s.replace(ipv4, REDACTED_IP).replace(ipv6, REDACTED_IP);
|
|
}
|
|
|
|
function deepScrub(value: unknown, parentKey = ""): unknown {
|
|
if (value === null || value === undefined) return value;
|
|
if (typeof value === "string") {
|
|
return parentKey && keyContainsPii(parentKey) ? REDACTED_VALUE : redactString(value);
|
|
}
|
|
if (typeof value === "number" || typeof value === "boolean") {
|
|
return parentKey && keyContainsPii(parentKey) ? REDACTED_VALUE : value;
|
|
}
|
|
if (Array.isArray(value)) return value.map((v) => deepScrub(v, parentKey));
|
|
if (typeof value === "object") {
|
|
const out: Record<string, unknown> = {};
|
|
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
out[k] = keyContainsPii(k) ? REDACTED_VALUE : deepScrub(v, k);
|
|
}
|
|
return out;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function scrubUrl(url: string): string {
|
|
try {
|
|
const u = new URL(url, "http://placeholder.local");
|
|
for (const [k] of Array.from(u.searchParams.entries())) {
|
|
if (queryParamContainsPii(k)) u.searchParams.set(k, REDACTED_VALUE);
|
|
}
|
|
return url.startsWith("/") ? `${u.pathname}${u.search}` : u.toString();
|
|
} catch {
|
|
return url;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Client-side init for non-Next.js (Vite/React) runtimes (TanStack Start).
|
|
* Mirrors init-client.ts but uses @sentry/react directly. R31, R32, R33,
|
|
* R34, R35, R37 still apply.
|
|
*/
|
|
export function initSentryClientReact(opts: InitClientOpts): void {
|
|
if (!opts.dsn) return;
|
|
|
|
const isProd = process.env["NODE_ENV"] === "production";
|
|
const tracesSampleRate =
|
|
process.env["SENTRY_TRACES_SAMPLE_RATE"] !== undefined
|
|
? Number(process.env["SENTRY_TRACES_SAMPLE_RATE"])
|
|
: isProd
|
|
? 0.1
|
|
: 1.0;
|
|
|
|
const environment =
|
|
process.env["SENTRY_ENVIRONMENT"] ?? process.env["NODE_ENV"] ?? "development";
|
|
const release = opts.release ?? "unknown";
|
|
|
|
type InitOpts = Parameters<typeof SentryReact.init>[0];
|
|
type SentryEvent = { extra?: Record<string, unknown> | null; contexts?: Record<string, Record<string, unknown> | undefined>; request?: { url?: string; headers?: Record<string, string | undefined>; [key: string]: unknown }; transaction?: string; [key: string]: unknown };
|
|
|
|
SentryReact.init({
|
|
dsn: opts.dsn,
|
|
environment,
|
|
release,
|
|
tracesSampleRate,
|
|
sendDefaultPii: false, // R31
|
|
beforeSend: ((event: SentryEvent) => deepScrub(event)) as unknown as NonNullable<InitOpts>["beforeSend"], // R32
|
|
beforeSendTransaction: ((event: SentryEvent) => { // R33
|
|
const out = { ...event };
|
|
if (out.request?.url) out.request = { ...out.request, url: scrubUrl(out.request.url) };
|
|
if (out.transaction && (out.transaction.includes("?") || out.transaction.includes("="))) {
|
|
out.transaction = scrubUrl(out.transaction);
|
|
}
|
|
return out;
|
|
}) as unknown as NonNullable<InitOpts>["beforeSendTransaction"],
|
|
replaysSessionSampleRate: 0.0, // R37
|
|
replaysOnErrorSampleRate: 1.0, // R37
|
|
integrations: [
|
|
// R34, R35 — mandatory mask flags; allowlist starts empty
|
|
SentryReact.replayIntegration({
|
|
maskAllText: true,
|
|
maskAllInputs: true,
|
|
blockAllMedia: true,
|
|
}),
|
|
],
|
|
initialScope: { tags: { app: opts.app } },
|
|
});
|
|
}
|