refactor: strip Phase/Plan/R-number references from source comments
This commit is contained in:
@@ -29,7 +29,7 @@ export type AuditFrom = {
|
||||
*/
|
||||
export type AuditEntry = {
|
||||
// WHO
|
||||
/** User id, or "system"/"service-{name}" for non-user actors. NEVER email or name (R36). */
|
||||
/** User id, or "system"/"service-{name}" for non-user actors. NEVER email or name. */
|
||||
actorId: string;
|
||||
actorType: "user" | "system" | "service";
|
||||
/** Snapshot of actor's roles AT TIME OF ACTION — preserves historical state. */
|
||||
|
||||
@@ -5,7 +5,10 @@ import { UndiciInstrumentation } from "@opentelemetry/instrumentation-undici";
|
||||
import { PgInstrumentation } from "@opentelemetry/instrumentation-pg";
|
||||
import { buildResource } from "./resource";
|
||||
import { createSentryOtelBridge } from "./sentry-bridge";
|
||||
import { PiiScrubSpanProcessor, PiiScrubLogRecordProcessor } from "./pii-scrub-processor";
|
||||
import {
|
||||
PiiScrubSpanProcessor,
|
||||
PiiScrubLogRecordProcessor,
|
||||
} from "./pii-scrub-processor";
|
||||
|
||||
const { BatchSpanProcessor } = tracing;
|
||||
|
||||
@@ -47,7 +50,7 @@ export function initOtelServerNode(opts: InitOtelServerNodeOpts): NodeSDK {
|
||||
// `as never` works around a TypeScript version conflict: `core-shared`'s direct
|
||||
// dep on `@opentelemetry/sdk-trace-base@1.30.1` has subtly incompatible types
|
||||
// vs the 1.28.0 bundled by `sdk-node@0.55.0`. The runtime objects are compatible;
|
||||
// the structural mismatch is type-only. Phase 1 implementer chose this rather than
|
||||
// the structural mismatch is type-only — chosen rather than
|
||||
// constraining sdk-trace-base to 1.28.x to avoid losing future bug fixes.
|
||||
new BatchSpanProcessor(bridge.spanProcessor as never),
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// packages/core-shared/src/instrumentation/otel/pii-fields.ts
|
||||
|
||||
// R32 — substring match on event keys (case-insensitive).
|
||||
// Substring match on event keys (case-insensitive).
|
||||
// IP address attribute KEYS from OTel HttpInstrumentation (semconv 1.20 and 1.27+)
|
||||
// are listed here so they are key-redacted in addition to the value-level regex
|
||||
// scrubbing in pii-scrub-processor.ts.
|
||||
@@ -28,7 +28,7 @@ export const PII_KEY_SUBSTRINGS = [
|
||||
"host.ip",
|
||||
] as const;
|
||||
|
||||
// R33 — substring match on URL query-param keys (case-insensitive)
|
||||
// Substring match on URL query-param keys (case-insensitive)
|
||||
export const PII_QUERY_PARAM_SUBSTRINGS = [
|
||||
"token",
|
||||
"email",
|
||||
|
||||
@@ -10,11 +10,17 @@ import {
|
||||
SimpleLogRecordProcessor,
|
||||
} from "@opentelemetry/sdk-logs";
|
||||
import { SeverityNumber } from "@opentelemetry/api-logs";
|
||||
import { PiiScrubSpanProcessor, PiiScrubLogRecordProcessor } from "./pii-scrub-processor";
|
||||
import {
|
||||
PiiScrubSpanProcessor,
|
||||
PiiScrubLogRecordProcessor,
|
||||
} from "./pii-scrub-processor";
|
||||
|
||||
const spanExporter = new InMemorySpanExporter();
|
||||
const tracerProvider = new BasicTracerProvider({
|
||||
spanProcessors: [new PiiScrubSpanProcessor(), new SimpleSpanProcessor(spanExporter)],
|
||||
spanProcessors: [
|
||||
new PiiScrubSpanProcessor(),
|
||||
new SimpleSpanProcessor(spanExporter),
|
||||
],
|
||||
});
|
||||
|
||||
// Use addLogRecordProcessor to chain processors in the right order.
|
||||
@@ -43,7 +49,7 @@ describe("PiiScrubSpanProcessor", () => {
|
||||
const exported = spanExporter.getFinishedSpans();
|
||||
expect(exported[0]!.attributes["user.email"]).toBe("[redacted]");
|
||||
expect(exported[0]!.attributes["auth.token"]).toBe("[redacted]");
|
||||
expect(exported[0]!.attributes["user.id"]).toBe("u_123"); // id is fine per R36
|
||||
expect(exported[0]!.attributes["user.id"]).toBe("u_123"); // id is fine
|
||||
expect(exported[0]!.attributes["request.path"]).toBe("/api/users");
|
||||
});
|
||||
|
||||
@@ -119,7 +125,7 @@ describe("PiiScrubLogRecordProcessor", () => {
|
||||
expect(records[0]!.body).toBe("user signed in successfully");
|
||||
});
|
||||
|
||||
it("scrubs IPv4 in log record body (C2 / R32)", () => {
|
||||
it("scrubs IPv4 in log record body", () => {
|
||||
const logger = logProvider.getLogger("test");
|
||||
logger.emit({
|
||||
severityNumber: SeverityNumber.INFO,
|
||||
@@ -132,7 +138,7 @@ describe("PiiScrubLogRecordProcessor", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("PiiScrubSpanProcessor — IP address scrubbing (C2 / R32)", () => {
|
||||
describe("PiiScrubSpanProcessor — IP address scrubbing", () => {
|
||||
it("scrubs IPv4 addresses in attribute values", () => {
|
||||
const tracer = tracerProvider.getTracer("test");
|
||||
const span = tracer.startSpan("test-span", {
|
||||
@@ -140,7 +146,9 @@ describe("PiiScrubSpanProcessor — IP address scrubbing (C2 / R32)", () => {
|
||||
});
|
||||
span.end();
|
||||
const exported = spanExporter.getFinishedSpans();
|
||||
expect(exported[0]!.attributes["request.note"]).toBe("request from [redacted-ip]");
|
||||
expect(exported[0]!.attributes["request.note"]).toBe(
|
||||
"request from [redacted-ip]",
|
||||
);
|
||||
});
|
||||
|
||||
it("scrubs IPv6 addresses in attribute values", () => {
|
||||
|
||||
@@ -3,14 +3,23 @@
|
||||
// PII scrub processors for OTel spans and log records.
|
||||
// These run FIRST in their respective processor chains so downstream exporters
|
||||
// (including the Sentry exporter) see scrubbed data. This replaces the old
|
||||
// Sentry beforeSend / beforeSendTransaction hooks (R32, R33) — scrubbing now
|
||||
// Sentry beforeSend / beforeSendTransaction hooks — scrubbing now
|
||||
// happens at the OTel layer, vendor-agnostic.
|
||||
|
||||
import type { ReadableSpan, 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, IPV4_REGEX, IPV6_REGEX, REDACTED_IP } from "./pii-fields";
|
||||
import {
|
||||
PII_KEY_SUBSTRINGS,
|
||||
REDACTED_VALUE,
|
||||
IPV4_REGEX,
|
||||
IPV6_REGEX,
|
||||
REDACTED_IP,
|
||||
} from "./pii-fields";
|
||||
|
||||
function isPiiKey(key: string): boolean {
|
||||
const lower = key.toLowerCase();
|
||||
@@ -27,7 +36,7 @@ function containsPiiSubstring(s: string): boolean {
|
||||
* Called for attribute values whose KEYS did not match a PII substring — the
|
||||
* old Sentry beforeSend hook performed this kind of value-level scrubbing; we
|
||||
* replicate it here so IP addresses embedded in non-IP-keyed attributes
|
||||
* (e.g. "request.note": "from 10.0.0.1") are still redacted (C2 fix / R32).
|
||||
* (e.g. "request.note": "from 10.0.0.1") are still redacted.
|
||||
*/
|
||||
function scrubValue(value: unknown): unknown {
|
||||
if (typeof value !== "string") return value;
|
||||
@@ -39,7 +48,9 @@ function scrubValue(value: unknown): unknown {
|
||||
return scrubbed;
|
||||
}
|
||||
|
||||
function scrubAttributes(attrs: Record<string, unknown>): Record<string, unknown> {
|
||||
function scrubAttributes(
|
||||
attrs: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(attrs)) {
|
||||
if (isPiiKey(key)) {
|
||||
@@ -54,7 +65,7 @@ function scrubAttributes(attrs: Record<string, unknown>): Record<string, unknown
|
||||
/**
|
||||
* Runs FIRST in the span processor chain so downstream exporters see scrubbed attributes.
|
||||
* Redacts any span attribute whose key contains a PII substring (case-insensitive).
|
||||
* R32 — attribute-key-based PII redaction.
|
||||
* Attribute-key-based PII redaction.
|
||||
*/
|
||||
export class PiiScrubSpanProcessor implements SpanProcessor {
|
||||
forceFlush(): Promise<void> {
|
||||
@@ -70,7 +81,9 @@ export class PiiScrubSpanProcessor implements SpanProcessor {
|
||||
}
|
||||
|
||||
onEnd(span: ReadableSpan): void {
|
||||
const scrubbed = scrubAttributes(span.attributes as Record<string, unknown>);
|
||||
const scrubbed = scrubAttributes(
|
||||
span.attributes as Record<string, unknown>,
|
||||
);
|
||||
Object.assign(span.attributes, scrubbed);
|
||||
}
|
||||
}
|
||||
@@ -80,7 +93,7 @@ export class PiiScrubSpanProcessor implements SpanProcessor {
|
||||
* - Strips PII from attributes (key-based substring match, case-insensitive).
|
||||
* - Strips PII from the log body string (substring match — if any PII substring
|
||||
* appears in the body, the entire body is redacted to avoid partial leakage).
|
||||
* R32 — attribute-key-based PII redaction; R33 — body-level redaction.
|
||||
* Attribute-key-based PII redaction; body-level redaction.
|
||||
*/
|
||||
export class PiiScrubLogRecordProcessor implements LogRecordProcessor {
|
||||
forceFlush(): Promise<void> {
|
||||
@@ -93,7 +106,9 @@ export class PiiScrubLogRecordProcessor implements LogRecordProcessor {
|
||||
|
||||
onEmit(record: LogRecord): void {
|
||||
if (record.attributes) {
|
||||
const scrubbed = scrubAttributes(record.attributes as Record<string, unknown>);
|
||||
const scrubbed = scrubAttributes(
|
||||
record.attributes as Record<string, unknown>,
|
||||
);
|
||||
Object.assign(record.attributes, scrubbed);
|
||||
}
|
||||
if (typeof record.body === "string") {
|
||||
@@ -103,7 +118,7 @@ export class PiiScrubLogRecordProcessor implements LogRecordProcessor {
|
||||
record.body = REDACTED_VALUE;
|
||||
} else {
|
||||
// No PII keyword, but may still contain IP addresses embedded in text.
|
||||
// Apply value-level regex scrubbing (C2 fix / R32).
|
||||
// Apply value-level regex scrubbing.
|
||||
record.body = scrubValue(record.body) as string;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ describe("createSentryOtelBridge", () => {
|
||||
const { createSentryOtelBridge } = await import("./sentry-bridge");
|
||||
const bridge = createSentryOtelBridge({ dsn: "https://test@sentry.io/1" });
|
||||
expect(bridge.spanProcessor).toBeDefined();
|
||||
// Phase 3: logRecordProcessor is now wired (SentryLogRecordForwarder)
|
||||
// logRecordProcessor is wired (SentryLogRecordForwarder)
|
||||
expect(bridge.logRecordProcessor).toBeDefined();
|
||||
expect(bridge.logRecordProcessor).not.toBeNull();
|
||||
});
|
||||
@@ -50,7 +50,10 @@ describe("SentryLogRecordForwarder", () => {
|
||||
|
||||
const captureException = vi.fn();
|
||||
const captureMessage = vi.fn();
|
||||
const forwarder = new SentryLogRecordForwarder({ captureException, captureMessage });
|
||||
const forwarder = new SentryLogRecordForwarder({
|
||||
captureException,
|
||||
captureMessage,
|
||||
});
|
||||
|
||||
const record = {
|
||||
severityNumber: SEVERITY_ERROR,
|
||||
@@ -84,7 +87,10 @@ describe("SentryLogRecordForwarder", () => {
|
||||
|
||||
const captureException = vi.fn();
|
||||
const captureMessage = vi.fn();
|
||||
const forwarder = new SentryLogRecordForwarder({ captureException, captureMessage });
|
||||
const forwarder = new SentryLogRecordForwarder({
|
||||
captureException,
|
||||
captureMessage,
|
||||
});
|
||||
|
||||
const record = {
|
||||
severityNumber: SEVERITY_ERROR,
|
||||
@@ -110,7 +116,10 @@ describe("SentryLogRecordForwarder", () => {
|
||||
|
||||
const captureException = vi.fn();
|
||||
const captureMessage = vi.fn();
|
||||
const forwarder = new SentryLogRecordForwarder({ captureException, captureMessage });
|
||||
const forwarder = new SentryLogRecordForwarder({
|
||||
captureException,
|
||||
captureMessage,
|
||||
});
|
||||
|
||||
const record = {
|
||||
severityNumber,
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { tracing as sdkTracing, logs as sdkLogs } from "@opentelemetry/sdk-node";
|
||||
import type {
|
||||
tracing as sdkTracing,
|
||||
logs as sdkLogs,
|
||||
} from "@opentelemetry/sdk-node";
|
||||
import { SeverityNumber } from "@opentelemetry/api-logs";
|
||||
import type { LogRecord } from "@opentelemetry/sdk-logs";
|
||||
|
||||
@@ -64,7 +67,8 @@ export class SentryLogRecordForwarder implements LogRecordProcessor {
|
||||
if (severityNumber >= SeverityNumber.ERROR) {
|
||||
// Reconstruct the error from OTel semantic convention attributes
|
||||
const message =
|
||||
(attrs["exception.message"] as string | undefined) ?? String(record.body ?? "");
|
||||
(attrs["exception.message"] as string | undefined) ??
|
||||
String(record.body ?? "");
|
||||
const err = new Error(message);
|
||||
if (attrs["exception.type"]) {
|
||||
err.name = attrs["exception.type"] as string;
|
||||
@@ -77,7 +81,11 @@ export class SentryLogRecordForwarder implements LogRecordProcessor {
|
||||
? (attrs["sentry.fingerprint"] as string).split("|")
|
||||
: undefined;
|
||||
|
||||
Sentry.captureException(err, { tags, extra, ...(fingerprint ? { fingerprint } : {}) });
|
||||
Sentry.captureException(err, {
|
||||
tags,
|
||||
extra,
|
||||
...(fingerprint ? { fingerprint } : {}),
|
||||
});
|
||||
} else {
|
||||
// Map severity to Sentry level
|
||||
const level = severityNumber >= SeverityNumber.WARN ? "warning" : "info";
|
||||
@@ -99,9 +107,11 @@ export class SentryLogRecordForwarder implements LogRecordProcessor {
|
||||
* Creates Sentry-as-OTel-exporter processors. The OTel SDK uses these to
|
||||
* forward spans and log records to Sentry. This is the ONLY file in
|
||||
* core-shared that imports from `@sentry/opentelemetry` — all other Sentry
|
||||
* coupling is excluded by the R40/R52 ESLint allowlist.
|
||||
* coupling is excluded by the ESLint allowlist.
|
||||
*/
|
||||
export function createSentryOtelBridge(opts: SentryOtelBridgeOpts): SentryOtelBridge {
|
||||
export function createSentryOtelBridge(
|
||||
opts: SentryOtelBridgeOpts,
|
||||
): SentryOtelBridge {
|
||||
if (!opts.dsn) {
|
||||
return { spanProcessor: null, logRecordProcessor: null };
|
||||
}
|
||||
@@ -125,7 +135,9 @@ function extractTags(attrs: Record<string, unknown>): Record<string, string> {
|
||||
return tags;
|
||||
}
|
||||
|
||||
function extractExtras(attrs: Record<string, unknown>): Record<string, unknown> {
|
||||
function extractExtras(
|
||||
attrs: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const extras: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(attrs)) {
|
||||
if (k.startsWith("extra.")) {
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const { replayIntegration } = vi.hoisted(() => {
|
||||
const replayIntegration = vi.fn((opts: unknown) => ({ name: "Replay", _opts: opts }));
|
||||
const replayIntegration = vi.fn((opts: unknown) => ({
|
||||
name: "Replay",
|
||||
_opts: opts,
|
||||
}));
|
||||
return { replayIntegration };
|
||||
});
|
||||
|
||||
@@ -19,43 +22,35 @@ describe("initSentryClientReact", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("calls SentryReact.init with sendDefaultPii: false (R31)", () => {
|
||||
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
|
||||
>;
|
||||
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 (R34, R35)", () => {
|
||||
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
|
||||
>;
|
||||
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 per R37", () => {
|
||||
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
|
||||
>;
|
||||
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
|
||||
>;
|
||||
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");
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
IPV6_REGEX,
|
||||
} from "../otel/pii-fields";
|
||||
|
||||
// R32 — inline scrub helpers for browser-side Sentry (server uses OTel processors instead).
|
||||
// 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));
|
||||
@@ -33,7 +33,9 @@ function redactString(s: string): string {
|
||||
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);
|
||||
return parentKey && keyContainsPii(parentKey)
|
||||
? REDACTED_VALUE
|
||||
: redactString(value);
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return parentKey && keyContainsPii(parentKey) ? REDACTED_VALUE : value;
|
||||
@@ -63,8 +65,8 @@ function scrubUrl(url: string): string {
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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;
|
||||
@@ -78,31 +80,48 @@ export function initSentryClientReact(opts: InitClientOpts): void {
|
||||
: 1.0;
|
||||
|
||||
const environment =
|
||||
process.env["SENTRY_ENVIRONMENT"] ?? process.env["NODE_ENV"] ?? "development";
|
||||
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 };
|
||||
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
|
||||
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("="))) {
|
||||
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
|
||||
replaysSessionSampleRate: 0.0,
|
||||
replaysOnErrorSampleRate: 1.0,
|
||||
integrations: [
|
||||
// R34, R35 — mandatory mask flags; allowlist starts empty
|
||||
// mandatory mask flags; allowlist starts empty
|
||||
SentryReact.replayIntegration({
|
||||
maskAllText: true,
|
||||
maskAllInputs: true,
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const { replayIntegration } = vi.hoisted(() => {
|
||||
const replayIntegration = vi.fn((opts: unknown) => ({ name: "Replay", _opts: opts }));
|
||||
const replayIntegration = vi.fn((opts: unknown) => ({
|
||||
name: "Replay",
|
||||
_opts: opts,
|
||||
}));
|
||||
return { replayIntegration };
|
||||
});
|
||||
|
||||
@@ -19,51 +22,41 @@ describe("initSentryClient", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("calls Sentry.init with sendDefaultPii: false (R31)", () => {
|
||||
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
|
||||
>;
|
||||
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 (R34, R35)", () => {
|
||||
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
|
||||
>;
|
||||
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 (R37)", () => {
|
||||
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
|
||||
>;
|
||||
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 (R37)", () => {
|
||||
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
|
||||
>;
|
||||
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
|
||||
>;
|
||||
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");
|
||||
});
|
||||
|
||||
@@ -18,7 +18,7 @@ export type InitClientOpts = {
|
||||
release?: string;
|
||||
};
|
||||
|
||||
// R32 — inline scrub helpers for browser-side Sentry (server uses OTel processors instead).
|
||||
// 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));
|
||||
@@ -38,7 +38,9 @@ function redactString(s: string): string {
|
||||
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);
|
||||
return parentKey && keyContainsPii(parentKey)
|
||||
? REDACTED_VALUE
|
||||
: redactString(value);
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return parentKey && keyContainsPii(parentKey) ? REDACTED_VALUE : value;
|
||||
@@ -78,31 +80,48 @@ export function initSentryClient(opts: InitClientOpts): void {
|
||||
: 1.0;
|
||||
|
||||
const environment =
|
||||
process.env["SENTRY_ENVIRONMENT"] ?? process.env["NODE_ENV"] ?? "development";
|
||||
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 };
|
||||
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: ((event: SentryEvent) => deepScrub(event)) as unknown as InitOpts["beforeSend"], // R32
|
||||
beforeSendTransaction: ((event: SentryEvent) => { // R33
|
||||
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("="))) {
|
||||
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
|
||||
replaysSessionSampleRate: 0.0, // privacy default
|
||||
replaysOnErrorSampleRate: 1.0,
|
||||
integrations: [
|
||||
// R34, R35 — mandatory mask flags; allowlist starts empty
|
||||
// mandatory mask flags; allowlist starts empty
|
||||
Sentry.replayIntegration({
|
||||
maskAllText: true,
|
||||
maskAllInputs: true,
|
||||
|
||||
Reference in New Issue
Block a user