refactor(core-shared): delete Sentry scrub + orphaned server-init files (replaced by OTel processors)

This commit is contained in:
2026-05-11 12:12:12 +02:00
parent ad609f8f01
commit 301e0ff3f8
12 changed files with 168 additions and 234 deletions

View File

@@ -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",

View File

@@ -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";

View File

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

View File

@@ -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: [

View File

@@ -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: [

View File

@@ -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", () => {

View File

@@ -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 } },
});
}

View File

@@ -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", () => {

View File

@@ -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 } },
});
}

View File

@@ -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();
});
});

View File

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