refactor(core-shared): delete Sentry scrub + orphaned server-init files (replaced by OTel processors)
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -19,8 +19,7 @@
|
||||
"./instrumentation/sentry/init-server": "./src/instrumentation/sentry/init-server.ts",
|
||||
"./instrumentation/sentry/init-client": "./src/instrumentation/sentry/init-client.ts",
|
||||
"./instrumentation/sentry/init-server-node": "./src/instrumentation/sentry/init-server-node.ts",
|
||||
"./instrumentation/sentry/init-client-react": "./src/instrumentation/sentry/init-client-react.ts",
|
||||
"./instrumentation/sentry/scrub": "./src/instrumentation/sentry/scrub.ts"
|
||||
"./instrumentation/sentry/init-client-react": "./src/instrumentation/sentry/init-client-react.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc --noEmit",
|
||||
|
||||
@@ -27,5 +27,6 @@ export {
|
||||
export { bindOtelInstrumentation as bindSentryInstrumentation } from "./di/bind-otel-instrumentation";
|
||||
export type { BindOtelOpts as BindSentryOpts } from "./di/bind-otel-instrumentation";
|
||||
|
||||
export { initSentryServerNode } from "./sentry/init-server-node";
|
||||
// initSentryServerNode removed from barrel — callers use the subpath export directly:
|
||||
// @repo/core-shared/instrumentation/sentry/init-server-node
|
||||
export { initSentryClientReact } from "./sentry/init-client-react";
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
// Sentry beforeSend / beforeSendTransaction hooks (R32, R33) — scrubbing now
|
||||
// happens at the OTel layer, vendor-agnostic.
|
||||
|
||||
import type { ReadableSpan, Span, SpanProcessor } from "@opentelemetry/sdk-trace-base";
|
||||
import type { ReadableSpan, SpanProcessor } from "@opentelemetry/sdk-trace-base";
|
||||
import type { Span } from "@opentelemetry/api";
|
||||
import type { Context } from "@opentelemetry/api";
|
||||
import type { LogRecord, LogRecordProcessor } from "@opentelemetry/sdk-logs";
|
||||
import { PII_KEY_SUBSTRINGS, REDACTED_VALUE } from "./pii-fields";
|
||||
|
||||
@@ -42,7 +44,7 @@ export class PiiScrubSpanProcessor implements SpanProcessor {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
onStart(_span: Span): void {
|
||||
onStart(_span: Span, _parentContext: Context): void {
|
||||
// no-op — scrub on completion when all attributes are set
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,65 @@
|
||||
// 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 { beforeSend, beforeSendTransaction } from "./scrub";
|
||||
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).
|
||||
@@ -24,15 +82,23 @@ export function initSentryClientReact(opts: InitClientOpts): void {
|
||||
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: beforeSend as unknown as NonNullable<InitOpts>["beforeSend"], // R32
|
||||
beforeSendTransaction:
|
||||
beforeSendTransaction as unknown as NonNullable<InitOpts>["beforeSendTransaction"], // R33
|
||||
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: [
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
// 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 { beforeSend, beforeSendTransaction } from "./scrub";
|
||||
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;
|
||||
@@ -8,6 +18,54 @@ export type InitClientOpts = {
|
||||
release?: string;
|
||||
};
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
export function initSentryClient(opts: InitClientOpts): void {
|
||||
if (!opts.dsn) return;
|
||||
|
||||
@@ -24,14 +82,23 @@ export function initSentryClient(opts: InitClientOpts): void {
|
||||
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, // R31
|
||||
beforeSend: beforeSend as unknown as InitOpts["beforeSend"], // R32
|
||||
beforeSendTransaction: beforeSendTransaction as unknown as InitOpts["beforeSendTransaction"], // R33
|
||||
beforeSend: ((event: SentryEvent) => deepScrub(event)) as unknown as 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 InitOpts["beforeSendTransaction"],
|
||||
replaysSessionSampleRate: 0.0, // R37 — privacy default
|
||||
replaysOnErrorSampleRate: 1.0, // R37
|
||||
integrations: [
|
||||
|
||||
@@ -22,14 +22,16 @@ describe("initSentryServerNode", () => {
|
||||
expect(call["sendDefaultPii"]).toBe(false);
|
||||
});
|
||||
|
||||
it("attaches beforeSend + beforeSendTransaction scrubbers", () => {
|
||||
it("does NOT attach beforeSend/beforeSendTransaction (scrubbing is at OTel layer)", () => {
|
||||
initSentryServerNode({ dsn: "https://x@y/1", app: "web-tanstack" });
|
||||
const call = (SentryNode.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");
|
||||
// PII scrubbing happens in PiiScrubSpanProcessor / PiiScrubLogRecordProcessor
|
||||
// before data reaches the Sentry exporter — no need for beforeSend hooks here.
|
||||
expect(call["beforeSend"]).toBeUndefined();
|
||||
expect(call["beforeSendTransaction"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("tags events with the app name", () => {
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
// packages/core-shared/src/instrumentation/sentry/init-server-node.ts
|
||||
// NOTE: PII scrubbing is now handled at the OTel pipeline layer via PiiScrubSpanProcessor
|
||||
// and PiiScrubLogRecordProcessor (see otel/pii-scrub-processor.ts). The beforeSend /
|
||||
// beforeSendTransaction hooks are no longer needed here (R32, R33 still enforced at OTel layer).
|
||||
import * as SentryNode from "@sentry/node";
|
||||
import { beforeSend, beforeSendTransaction } from "./scrub";
|
||||
import type { InitServerOpts } from "./init-server";
|
||||
|
||||
/**
|
||||
* Server-side init for non-Next.js runtimes (TanStack Start). Mirrors
|
||||
* init-server.ts but uses @sentry/node directly. R31, R32, R33 still apply.
|
||||
* init-server.ts but uses @sentry/node directly. R31, R32, R33 still apply
|
||||
* (scrubbing enforced at the OTel processor layer before data reaches Sentry).
|
||||
*/
|
||||
export function initSentryServerNode(opts: InitServerOpts): void {
|
||||
if (!opts.dsn) return;
|
||||
@@ -25,16 +28,13 @@ export function initSentryServerNode(opts: InitServerOpts): void {
|
||||
"development";
|
||||
const release = opts.release ?? process.env["VITE_GIT_COMMIT_SHA"] ?? "unknown";
|
||||
|
||||
type InitOpts = Parameters<typeof SentryNode.init>[0];
|
||||
SentryNode.init({
|
||||
dsn: opts.dsn,
|
||||
environment,
|
||||
release,
|
||||
tracesSampleRate,
|
||||
sendDefaultPii: false, // R31
|
||||
beforeSend: beforeSend as unknown as NonNullable<InitOpts>["beforeSend"], // R32
|
||||
beforeSendTransaction:
|
||||
beforeSendTransaction as unknown as NonNullable<InitOpts>["beforeSendTransaction"], // R33
|
||||
// R32, R33: PII scrubbing happens at the OTel processor layer before data reaches Sentry.
|
||||
initialScope: { tags: { app: opts.app } },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -33,14 +33,16 @@ describe("initSentryServer", () => {
|
||||
expect(call["dsn"]).toBe("https://x@y/1");
|
||||
});
|
||||
|
||||
it("attaches beforeSend + beforeSendTransaction scrubbers", () => {
|
||||
it("does NOT attach beforeSend/beforeSendTransaction (scrubbing is at OTel layer)", () => {
|
||||
initSentryServer({ 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");
|
||||
// PII scrubbing happens in PiiScrubSpanProcessor / PiiScrubLogRecordProcessor
|
||||
// before data reaches the Sentry exporter — no need for beforeSend hooks here.
|
||||
expect(call["beforeSend"]).toBeUndefined();
|
||||
expect(call["beforeSendTransaction"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses SENTRY_TRACES_SAMPLE_RATE env when set", () => {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// packages/core-shared/src/instrumentation/sentry/init-server.ts
|
||||
// NOTE: PII scrubbing is now handled at the OTel pipeline layer via PiiScrubSpanProcessor
|
||||
// and PiiScrubLogRecordProcessor (see otel/pii-scrub-processor.ts). The beforeSend /
|
||||
// beforeSendTransaction hooks are no longer needed here (R32, R33 still enforced at OTel layer).
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { beforeSend, beforeSendTransaction } from "./scrub";
|
||||
|
||||
export type InitServerOpts = {
|
||||
dsn: string | undefined;
|
||||
@@ -26,15 +28,13 @@ export function initSentryServer(opts: InitServerOpts): void {
|
||||
"development";
|
||||
const release = opts.release ?? process.env["VERCEL_GIT_COMMIT_SHA"] ?? "unknown";
|
||||
|
||||
type InitOpts = Parameters<typeof Sentry.init>[0];
|
||||
Sentry.init({
|
||||
dsn: opts.dsn,
|
||||
environment,
|
||||
release,
|
||||
tracesSampleRate,
|
||||
sendDefaultPii: false, // R31 — non-negotiable
|
||||
beforeSend: beforeSend as unknown as InitOpts["beforeSend"], // R32
|
||||
beforeSendTransaction: beforeSendTransaction as unknown as InitOpts["beforeSendTransaction"], // R33
|
||||
// R32, R33: PII scrubbing happens at the OTel processor layer before data reaches Sentry.
|
||||
initialScope: { tags: { app: opts.app } },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
// packages/core-shared/src/instrumentation/sentry/scrub.test.ts
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { beforeSend, beforeSendTransaction } from "@/instrumentation/sentry/scrub";
|
||||
|
||||
// Use structural types that match what scrub functions accept
|
||||
type ScrubEvent = Parameters<typeof beforeSend>[0];
|
||||
type ScrubHint = Parameters<typeof beforeSend>[1];
|
||||
type TxEvent = Parameters<typeof beforeSendTransaction>[0];
|
||||
type TxHint = Parameters<typeof beforeSendTransaction>[1];
|
||||
|
||||
const hint = {} as ScrubHint;
|
||||
const txHint = {} as TxHint;
|
||||
|
||||
describe("beforeSend", () => {
|
||||
it("redacts top-level keys whose names contain PII substrings", () => {
|
||||
const event = {
|
||||
extra: { email: "a@b.c", username: "alice" },
|
||||
contexts: { custom: { password: "p", note: "ok" } },
|
||||
} as unknown as ScrubEvent;
|
||||
const result = beforeSend(event, hint) as Record<string, unknown>;
|
||||
const extra = result["extra"] as Record<string, unknown>;
|
||||
const contexts = result["contexts"] as { custom: Record<string, unknown> };
|
||||
expect(extra["email"]).toBe("[redacted]");
|
||||
expect(extra["username"]).toBe("alice");
|
||||
expect(contexts.custom["password"]).toBe("[redacted]");
|
||||
expect(contexts.custom["note"]).toBe("ok");
|
||||
});
|
||||
|
||||
it("redacts derived key names (substring match): userEmail, accessToken, apiKey", () => {
|
||||
const event = {
|
||||
extra: { userEmail: "a@b.c", accessToken: "t", apiKey: "k", id: "u1" },
|
||||
} as unknown as ScrubEvent;
|
||||
const result = beforeSend(event, hint) as Record<string, unknown>;
|
||||
const extra = result["extra"] as Record<string, unknown>;
|
||||
expect(extra["userEmail"]).toBe("[redacted]");
|
||||
expect(extra["accessToken"]).toBe("[redacted]");
|
||||
expect(extra["apiKey"]).toBe("[redacted]");
|
||||
expect(extra["id"]).toBe("u1");
|
||||
});
|
||||
|
||||
it("redacts headers map keys case-insensitively", () => {
|
||||
const event = {
|
||||
request: {
|
||||
headers: { Authorization: "Bearer x", "Set-Cookie": "session=abc", "User-Agent": "ua" },
|
||||
},
|
||||
} as unknown as ScrubEvent;
|
||||
const result = beforeSend(event, hint) as Record<string, unknown>;
|
||||
const request = result["request"] as { headers: Record<string, unknown> };
|
||||
expect(request.headers["Authorization"]).toBe("[redacted]");
|
||||
expect(request.headers["Set-Cookie"]).toBe("[redacted]");
|
||||
expect(request.headers["User-Agent"]).toBe("ua");
|
||||
});
|
||||
|
||||
it("redacts IPv4 addresses found in string values", () => {
|
||||
const event = {
|
||||
extra: { note: "Connection from 192.168.1.10 failed" },
|
||||
} as unknown as ScrubEvent;
|
||||
const result = beforeSend(event, hint) as Record<string, unknown>;
|
||||
const extra = result["extra"] as Record<string, unknown>;
|
||||
expect(extra["note"]).toBe("Connection from [redacted-ip] failed");
|
||||
});
|
||||
|
||||
it("redacts IPv6 addresses found in string values", () => {
|
||||
const event = {
|
||||
extra: { note: "Tunnel to fe80::1ff:fe23:4567:890a established" },
|
||||
} as unknown as ScrubEvent;
|
||||
const result = beforeSend(event, hint) as Record<string, unknown>;
|
||||
const extra = result["extra"] as Record<string, unknown>;
|
||||
expect(extra["note"] as string).toContain("[redacted-ip]");
|
||||
});
|
||||
|
||||
it("does not crash on null/undefined branches", () => {
|
||||
expect(beforeSend({ extra: null } as unknown as ScrubEvent, hint)).toBeTruthy();
|
||||
expect(beforeSend({} as ScrubEvent, hint)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("returns the event (not null) — keeps Sentry transport flowing", () => {
|
||||
expect(beforeSend({ extra: { ok: true } } as unknown as ScrubEvent, hint)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("beforeSendTransaction", () => {
|
||||
it("strips PII query params from request.url", () => {
|
||||
const event = {
|
||||
request: { url: "https://app/api/foo?token=secret&user=alice&email=a@b.c" },
|
||||
} as unknown as TxEvent;
|
||||
const result = beforeSendTransaction(event, txHint) as Record<string, unknown>;
|
||||
const request = result["request"] as { url: string };
|
||||
expect(request.url).toContain("token=%5Bredacted%5D");
|
||||
expect(request.url).toContain("email=%5Bredacted%5D");
|
||||
expect(request.url).toContain("user=alice");
|
||||
});
|
||||
|
||||
it("strips PII query params from event.transaction", () => {
|
||||
const event = { transaction: "/foo?token=x&id=y" } as unknown as TxEvent;
|
||||
const result = beforeSendTransaction(event, txHint) as Record<string, unknown>;
|
||||
expect(result["transaction"] as string).toContain("token=%5Bredacted%5D");
|
||||
expect(result["transaction"] as string).toContain("id=y");
|
||||
});
|
||||
|
||||
it("matches derived param names (accessToken, ApiSecret)", () => {
|
||||
const event = {
|
||||
request: { url: "https://x/y?accessToken=t&ApiSecret=z&safe=1" },
|
||||
} as unknown as TxEvent;
|
||||
const result = beforeSendTransaction(event, txHint) as Record<string, unknown>;
|
||||
const request = result["request"] as { url: string };
|
||||
expect(request.url).toContain("accessToken=%5Bredacted%5D");
|
||||
expect(request.url).toContain("ApiSecret=%5Bredacted%5D");
|
||||
expect(request.url).toContain("safe=1");
|
||||
});
|
||||
|
||||
it("returns the event when no URL present", () => {
|
||||
expect(beforeSendTransaction({} as TxEvent, txHint)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,90 +0,0 @@
|
||||
// packages/core-shared/src/instrumentation/sentry/scrub.ts
|
||||
// Use structural types matching Sentry's beforeSend/beforeSendTransaction signatures
|
||||
// to avoid importing @sentry/core types directly (they're not re-exported by @sentry/nextjs).
|
||||
|
||||
import {
|
||||
IPV4_REGEX,
|
||||
IPV6_REGEX,
|
||||
REDACTED_IP,
|
||||
REDACTED_VALUE,
|
||||
keyContainsPii,
|
||||
queryParamContainsPii,
|
||||
} from "../otel/pii-fields";
|
||||
|
||||
// Minimal structural types matching Sentry's event shape used in scrubbers.
|
||||
// Using index signatures broad enough to satisfy both ErrorEvent and TransactionEvent.
|
||||
type SentryRequest = {
|
||||
url?: string;
|
||||
headers?: Record<string, string | undefined>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type SentryEvent = {
|
||||
extra?: Record<string, unknown> | null;
|
||||
contexts?: Record<string, Record<string, unknown> | undefined>;
|
||||
request?: SentryRequest;
|
||||
transaction?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type SentryEventHint = Record<string, unknown>;
|
||||
|
||||
function redactString(s: string): string {
|
||||
// Create new regexes each call since regexes with /g are stateful
|
||||
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;
|
||||
}
|
||||
|
||||
export function beforeSend(event: SentryEvent, _hint: SentryEventHint): SentryEvent | null {
|
||||
return deepScrub(event) as SentryEvent;
|
||||
}
|
||||
|
||||
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 beforeSendTransaction(
|
||||
event: SentryEvent,
|
||||
_hint: SentryEventHint,
|
||||
): SentryEvent | null {
|
||||
const out: SentryEvent = { ...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;
|
||||
}
|
||||
Reference in New Issue
Block a user