Initial commit
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
// packages/core-shared/src/instrumentation/sentry/init-client-react.test.ts
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const { replayIntegration, feedbackIntegration } = vi.hoisted(() => {
|
||||
const replayIntegration = vi.fn((opts: unknown) => ({
|
||||
name: "Replay",
|
||||
_opts: opts,
|
||||
}));
|
||||
const feedbackIntegration = vi.fn((opts: unknown) => ({
|
||||
name: "Feedback",
|
||||
_opts: opts,
|
||||
}));
|
||||
return { replayIntegration, feedbackIntegration };
|
||||
});
|
||||
|
||||
vi.mock("@sentry/react", () => ({
|
||||
init: vi.fn(),
|
||||
replayIntegration,
|
||||
feedbackIntegration,
|
||||
}));
|
||||
|
||||
import * as SentryReact from "@sentry/react";
|
||||
import { initSentryClientReact } from "@/instrumentation/sentry/init-client-react";
|
||||
|
||||
describe("initSentryClientReact", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("calls SentryReact.init with sendDefaultPii: false", () => {
|
||||
initSentryClientReact({ dsn: "https://x@y/1", app: "web-tanstack" });
|
||||
const call = (SentryReact.init as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0]![0] as Record<string, unknown>;
|
||||
expect(call["sendDefaultPii"]).toBe(false);
|
||||
});
|
||||
|
||||
it("attaches replay integration with mask flags", () => {
|
||||
initSentryClientReact({ dsn: "https://x@y/1", app: "web-tanstack" });
|
||||
expect(replayIntegration).toHaveBeenCalledTimes(1);
|
||||
const replayOpts = (replayIntegration as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0]![0] as Record<string, unknown>;
|
||||
expect(replayOpts["maskAllText"]).toBe(true);
|
||||
expect(replayOpts["maskAllInputs"]).toBe(true);
|
||||
expect(replayOpts["blockAllMedia"]).toBe(true);
|
||||
});
|
||||
|
||||
it("defaults replay sample rates", () => {
|
||||
initSentryClientReact({ dsn: "https://x@y/1", app: "web-tanstack" });
|
||||
const call = (SentryReact.init as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0]![0] as Record<string, unknown>;
|
||||
expect(call["replaysSessionSampleRate"]).toBe(0.0);
|
||||
expect(call["replaysOnErrorSampleRate"]).toBe(1.0);
|
||||
});
|
||||
|
||||
it("attaches beforeSend + beforeSendTransaction scrubbers", () => {
|
||||
initSentryClientReact({ dsn: "https://x@y/1", app: "web-tanstack" });
|
||||
const call = (SentryReact.init as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0]![0] as Record<string, unknown>;
|
||||
expect(typeof call["beforeSend"]).toBe("function");
|
||||
expect(typeof call["beforeSendTransaction"]).toBe("function");
|
||||
});
|
||||
|
||||
it("is a no-op when dsn is missing", () => {
|
||||
initSentryClientReact({ dsn: "", app: "web-tanstack" });
|
||||
expect(SentryReact.init).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("attaches feedbackIntegration when SentryReact.feedbackIntegration is available", () => {
|
||||
initSentryClientReact({ dsn: "https://x@y/1", app: "web-tanstack" });
|
||||
expect(feedbackIntegration).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("passes styleNonce and scriptNonce to feedbackIntegration when nonce provided", () => {
|
||||
initSentryClientReact({
|
||||
dsn: "https://x@y/1",
|
||||
app: "web-tanstack",
|
||||
nonce: "abc123",
|
||||
});
|
||||
const feedbackOpts = (feedbackIntegration as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0]![0] as Record<string, unknown>;
|
||||
expect(feedbackOpts["styleNonce"]).toBe("abc123");
|
||||
expect(feedbackOpts["scriptNonce"]).toBe("abc123");
|
||||
});
|
||||
|
||||
it("omits nonce props from feedbackIntegration when nonce not provided", () => {
|
||||
initSentryClientReact({ dsn: "https://x@y/1", app: "web-tanstack" });
|
||||
const feedbackOpts = (feedbackIntegration as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0]![0] as Record<string, unknown>;
|
||||
expect(feedbackOpts["styleNonce"]).toBeUndefined();
|
||||
expect(feedbackOpts["scriptNonce"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
// 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";
|
||||
|
||||
// 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. Same PII,
|
||||
* replay, and scrubbing requirements apply.
|
||||
*/
|
||||
export function initSentryClientReact(opts: InitClientOpts): void {
|
||||
if (!opts.dsn) return;
|
||||
|
||||
const isProd = process.env["NODE_ENV"] === "production";
|
||||
const { nonce } = opts;
|
||||
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,
|
||||
beforeSend: ((event: SentryEvent) =>
|
||||
deepScrub(event)) as unknown as NonNullable<InitOpts>["beforeSend"],
|
||||
beforeSendTransaction: ((event: SentryEvent) => {
|
||||
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,
|
||||
replaysOnErrorSampleRate: 1.0,
|
||||
integrations: [
|
||||
// mandatory mask flags; allowlist starts empty
|
||||
SentryReact.replayIntegration({
|
||||
maskAllText: true,
|
||||
maskAllInputs: true,
|
||||
blockAllMedia: true,
|
||||
}),
|
||||
...(SentryReact.feedbackIntegration
|
||||
? [
|
||||
SentryReact.feedbackIntegration({
|
||||
...(nonce ? { styleNonce: nonce, scriptNonce: nonce } : {}),
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
initialScope: { tags: { app: opts.app } },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// packages/core-shared/src/instrumentation/sentry/init-client.test.ts
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const { replayIntegration, feedbackIntegration } = vi.hoisted(() => {
|
||||
const replayIntegration = vi.fn((opts: unknown) => ({
|
||||
name: "Replay",
|
||||
_opts: opts,
|
||||
}));
|
||||
const feedbackIntegration = vi.fn((opts: unknown) => ({
|
||||
name: "Feedback",
|
||||
_opts: opts,
|
||||
}));
|
||||
return { replayIntegration, feedbackIntegration };
|
||||
});
|
||||
|
||||
vi.mock("@sentry/nextjs", () => ({
|
||||
init: vi.fn(),
|
||||
replayIntegration,
|
||||
feedbackIntegration,
|
||||
}));
|
||||
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { initSentryClient } from "@/instrumentation/sentry/init-client";
|
||||
|
||||
describe("initSentryClient", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("calls Sentry.init with sendDefaultPii: false", () => {
|
||||
initSentryClient({ dsn: "https://x@y/1", app: "web-next" });
|
||||
const call = (Sentry.init as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0]![0] as Record<string, unknown>;
|
||||
expect(call["sendDefaultPii"]).toBe(false);
|
||||
});
|
||||
|
||||
it("attaches replay integration with maskAllText/maskAllInputs/blockAllMedia: true", () => {
|
||||
initSentryClient({ dsn: "https://x@y/1", app: "web-next" });
|
||||
expect(replayIntegration).toHaveBeenCalledTimes(1);
|
||||
const replayOpts = (replayIntegration as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0]![0] as Record<string, unknown>;
|
||||
expect(replayOpts["maskAllText"]).toBe(true);
|
||||
expect(replayOpts["maskAllInputs"]).toBe(true);
|
||||
expect(replayOpts["blockAllMedia"]).toBe(true);
|
||||
});
|
||||
|
||||
it("defaults replaysSessionSampleRate to 0.0", () => {
|
||||
initSentryClient({ dsn: "https://x@y/1", app: "web-next" });
|
||||
const call = (Sentry.init as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0]![0] as Record<string, unknown>;
|
||||
expect(call["replaysSessionSampleRate"]).toBe(0.0);
|
||||
});
|
||||
|
||||
it("defaults replaysOnErrorSampleRate to 1.0", () => {
|
||||
initSentryClient({ dsn: "https://x@y/1", app: "web-next" });
|
||||
const call = (Sentry.init as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0]![0] as Record<string, unknown>;
|
||||
expect(call["replaysOnErrorSampleRate"]).toBe(1.0);
|
||||
});
|
||||
|
||||
it("attaches beforeSend + beforeSendTransaction", () => {
|
||||
initSentryClient({ dsn: "https://x@y/1", app: "web-next" });
|
||||
const call = (Sentry.init as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0]![0] as Record<string, unknown>;
|
||||
expect(typeof call["beforeSend"]).toBe("function");
|
||||
expect(typeof call["beforeSendTransaction"]).toBe("function");
|
||||
});
|
||||
|
||||
it("is a no-op when dsn is empty", () => {
|
||||
initSentryClient({ dsn: "", app: "web-next" });
|
||||
expect(Sentry.init).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("attaches feedbackIntegration when Sentry.feedbackIntegration is available", () => {
|
||||
initSentryClient({ dsn: "https://x@y/1", app: "web-next" });
|
||||
expect(feedbackIntegration).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("passes styleNonce and scriptNonce to feedbackIntegration when nonce provided", () => {
|
||||
initSentryClient({
|
||||
dsn: "https://x@y/1",
|
||||
app: "web-next",
|
||||
nonce: "abc123",
|
||||
});
|
||||
const feedbackOpts = (feedbackIntegration as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0]![0] as Record<string, unknown>;
|
||||
expect(feedbackOpts["styleNonce"]).toBe("abc123");
|
||||
expect(feedbackOpts["scriptNonce"]).toBe("abc123");
|
||||
});
|
||||
|
||||
it("omits nonce props from feedbackIntegration when nonce not provided", () => {
|
||||
initSentryClient({ dsn: "https://x@y/1", app: "web-next" });
|
||||
const feedbackOpts = (feedbackIntegration as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0]![0] as Record<string, unknown>;
|
||||
expect(feedbackOpts["styleNonce"]).toBeUndefined();
|
||||
expect(feedbackOpts["scriptNonce"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
142
packages/core-shared/src/instrumentation/sentry/init-client.ts
Normal file
142
packages/core-shared/src/instrumentation/sentry/init-client.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
// packages/core-shared/src/instrumentation/sentry/init-client.ts
|
||||
// Browser-side Sentry init. PII scrubbing is applied via beforeSend/beforeSendTransaction
|
||||
// hooks because browser does NOT use the OTel pipeline (server-only migration). The
|
||||
// PII field lists come from otel/pii-fields.ts (vendor-neutral location).
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import {
|
||||
PII_KEY_SUBSTRINGS,
|
||||
PII_QUERY_PARAM_SUBSTRINGS,
|
||||
REDACTED_VALUE,
|
||||
REDACTED_IP,
|
||||
IPV4_REGEX,
|
||||
IPV6_REGEX,
|
||||
} from "../otel/pii-fields";
|
||||
|
||||
export type InitClientOpts = {
|
||||
dsn: string | undefined;
|
||||
app: "web-next" | "cms" | "web-tanstack";
|
||||
release?: string;
|
||||
nonce?: string;
|
||||
};
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
export function initSentryClient(opts: InitClientOpts): void {
|
||||
if (!opts.dsn) return;
|
||||
const { nonce } = opts;
|
||||
|
||||
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 Sentry.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;
|
||||
};
|
||||
|
||||
Sentry.init({
|
||||
dsn: opts.dsn,
|
||||
environment,
|
||||
release,
|
||||
tracesSampleRate,
|
||||
sendDefaultPii: false,
|
||||
beforeSend: ((event: SentryEvent) =>
|
||||
deepScrub(event)) as unknown as InitOpts["beforeSend"],
|
||||
beforeSendTransaction: ((event: SentryEvent) => {
|
||||
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 InitOpts["beforeSendTransaction"],
|
||||
replaysSessionSampleRate: 0.0, // privacy default
|
||||
replaysOnErrorSampleRate: 1.0,
|
||||
integrations: [
|
||||
// mandatory mask flags; allowlist starts empty
|
||||
Sentry.replayIntegration({
|
||||
maskAllText: true,
|
||||
maskAllInputs: true,
|
||||
blockAllMedia: true,
|
||||
}),
|
||||
...(Sentry.feedbackIntegration
|
||||
? [
|
||||
Sentry.feedbackIntegration({
|
||||
...(nonce ? { styleNonce: nonce, scriptNonce: nonce } : {}),
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
initialScope: { tags: { app: opts.app } },
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user