fix(core-shared): TypeScript type fixes for Sentry adapter layer — null attr filter, structural event types, non-null assertions, vi.stubEnv

This commit is contained in:
2026-05-06 23:56:46 +02:00
parent 5f74230ad4
commit b08da12447
10 changed files with 1571 additions and 181 deletions

View File

@@ -21,7 +21,7 @@ describe("initSentryClient", () => {
it("calls Sentry.init with sendDefaultPii: false (R31)", () => {
initSentryClient({ dsn: "https://x@y/1", app: "web-next" });
const call = (Sentry.init as ReturnType<typeof vi.fn>).mock.calls[0][0] as Record<
const call = (Sentry.init as ReturnType<typeof vi.fn>).mock.calls[0]![0] as Record<
string,
unknown
>;
@@ -31,7 +31,7 @@ describe("initSentryClient", () => {
it("attaches replay integration with maskAllText/maskAllInputs/blockAllMedia: true (R34, R35)", () => {
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<
const replayOpts = (replayIntegration as ReturnType<typeof vi.fn>).mock.calls[0]![0] as Record<
string,
unknown
>;
@@ -42,7 +42,7 @@ describe("initSentryClient", () => {
it("defaults replaysSessionSampleRate to 0.0 (R37)", () => {
initSentryClient({ dsn: "https://x@y/1", app: "web-next" });
const call = (Sentry.init as ReturnType<typeof vi.fn>).mock.calls[0][0] as Record<
const call = (Sentry.init as ReturnType<typeof vi.fn>).mock.calls[0]![0] as Record<
string,
unknown
>;
@@ -51,7 +51,7 @@ describe("initSentryClient", () => {
it("defaults replaysOnErrorSampleRate to 1.0 (R37)", () => {
initSentryClient({ dsn: "https://x@y/1", app: "web-next" });
const call = (Sentry.init as ReturnType<typeof vi.fn>).mock.calls[0][0] as Record<
const call = (Sentry.init as ReturnType<typeof vi.fn>).mock.calls[0]![0] as Record<
string,
unknown
>;
@@ -60,7 +60,7 @@ describe("initSentryClient", () => {
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<
const call = (Sentry.init as ReturnType<typeof vi.fn>).mock.calls[0]![0] as Record<
string,
unknown
>;

View File

@@ -23,14 +23,15 @@ export function initSentryClient(opts: InitClientOpts): void {
process.env["SENTRY_ENVIRONMENT"] ?? process.env["NODE_ENV"] ?? "development";
const release = opts.release ?? "unknown";
type InitOpts = Parameters<typeof Sentry.init>[0];
Sentry.init({
dsn: opts.dsn,
environment,
release,
tracesSampleRate,
sendDefaultPii: false, // R31
beforeSend, // R32
beforeSendTransaction, // R33
beforeSend: beforeSend as unknown as InitOpts["beforeSend"], // R32
beforeSendTransaction: beforeSendTransaction as unknown as InitOpts["beforeSendTransaction"], // R33
replaysSessionSampleRate: 0.0, // R37 — privacy default
replaysOnErrorSampleRate: 1.0, // R37
integrations: [

View File

@@ -17,7 +17,7 @@ describe("initSentryServer", () => {
it("calls Sentry.init with sendDefaultPii: false (R31)", () => {
initSentryServer({ dsn: "https://x@y/1", app: "web-next" });
expect(Sentry.init).toHaveBeenCalledTimes(1);
const call = (Sentry.init as ReturnType<typeof vi.fn>).mock.calls[0][0] as Record<
const call = (Sentry.init as ReturnType<typeof vi.fn>).mock.calls[0]![0] as Record<
string,
unknown
>;
@@ -26,7 +26,7 @@ describe("initSentryServer", () => {
it("passes the configured DSN", () => {
initSentryServer({ dsn: "https://x@y/1", app: "web-next" });
const call = (Sentry.init as ReturnType<typeof vi.fn>).mock.calls[0][0] as Record<
const call = (Sentry.init as ReturnType<typeof vi.fn>).mock.calls[0]![0] as Record<
string,
unknown
>;
@@ -35,7 +35,7 @@ describe("initSentryServer", () => {
it("attaches beforeSend + beforeSendTransaction scrubbers", () => {
initSentryServer({ dsn: "https://x@y/1", app: "web-next" });
const call = (Sentry.init as ReturnType<typeof vi.fn>).mock.calls[0][0] as Record<
const call = (Sentry.init as ReturnType<typeof vi.fn>).mock.calls[0]![0] as Record<
string,
unknown
>;
@@ -44,48 +44,45 @@ describe("initSentryServer", () => {
});
it("uses SENTRY_TRACES_SAMPLE_RATE env when set", () => {
const prev = process.env["SENTRY_TRACES_SAMPLE_RATE"];
process.env["SENTRY_TRACES_SAMPLE_RATE"] = "0.25";
vi.stubEnv("SENTRY_TRACES_SAMPLE_RATE", "0.25");
initSentryServer({ dsn: "https://x@y/1", app: "web-next" });
const call = (Sentry.init as ReturnType<typeof vi.fn>).mock.calls[0][0] as Record<
const call = (Sentry.init as ReturnType<typeof vi.fn>).mock.calls[0]![0] as Record<
string,
unknown
>;
expect(call["tracesSampleRate"]).toBe(0.25);
if (prev === undefined) delete process.env["SENTRY_TRACES_SAMPLE_RATE"];
else process.env["SENTRY_TRACES_SAMPLE_RATE"] = prev;
vi.unstubAllEnvs();
});
it("defaults tracesSampleRate to 1.0 in dev, 0.1 in production", () => {
const prevEnv = process.env["NODE_ENV"];
// Save and clear rate env to test default logic
const prevRate = process.env["SENTRY_TRACES_SAMPLE_RATE"];
delete process.env["SENTRY_TRACES_SAMPLE_RATE"];
process.env["NODE_ENV"] = "development";
vi.stubEnv("NODE_ENV", "development");
initSentryServer({ dsn: "https://x@y/1", app: "web-next" });
expect(
((Sentry.init as ReturnType<typeof vi.fn>).mock.calls[0][0] as Record<string, unknown>)[
((Sentry.init as ReturnType<typeof vi.fn>).mock.calls[0]![0] as Record<string, unknown>)[
"tracesSampleRate"
],
).toBe(1.0);
(Sentry.init as ReturnType<typeof vi.fn>).mockClear();
process.env["NODE_ENV"] = "production";
vi.stubEnv("NODE_ENV", "production");
initSentryServer({ dsn: "https://x@y/1", app: "web-next" });
expect(
((Sentry.init as ReturnType<typeof vi.fn>).mock.calls[0][0] as Record<string, unknown>)[
((Sentry.init as ReturnType<typeof vi.fn>).mock.calls[0]![0] as Record<string, unknown>)[
"tracesSampleRate"
],
).toBe(0.1);
if (prevEnv === undefined) delete process.env["NODE_ENV"];
else process.env["NODE_ENV"] = prevEnv;
vi.unstubAllEnvs();
if (prevRate !== undefined) process.env["SENTRY_TRACES_SAMPLE_RATE"] = prevRate;
});
it("tags events with the app name", () => {
initSentryServer({ dsn: "https://x@y/1", app: "web-next" });
const call = (Sentry.init as ReturnType<typeof vi.fn>).mock.calls[0][0] as Record<
const call = (Sentry.init as ReturnType<typeof vi.fn>).mock.calls[0]![0] as Record<
string,
unknown
>;

View File

@@ -26,14 +26,15 @@ 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, // R32
beforeSendTransaction, // R33
beforeSend: beforeSend as unknown as InitOpts["beforeSend"], // R32
beforeSendTransaction: beforeSendTransaction as unknown as InitOpts["beforeSendTransaction"], // R33
initialScope: { tags: { app: opts.app } },
});
}

View File

@@ -2,16 +2,22 @@
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 Parameters<typeof beforeSend>[0];
const result = beforeSend(event, {} as Parameters<typeof beforeSend>[1]) as Record<
string,
unknown
>;
} 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]");
@@ -23,11 +29,8 @@ describe("beforeSend", () => {
it("redacts derived key names (substring match): userEmail, accessToken, apiKey", () => {
const event = {
extra: { userEmail: "a@b.c", accessToken: "t", apiKey: "k", id: "u1" },
} as Parameters<typeof beforeSend>[0];
const result = beforeSend(event, {} as Parameters<typeof beforeSend>[1]) as Record<
string,
unknown
>;
} 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]");
@@ -40,11 +43,8 @@ describe("beforeSend", () => {
request: {
headers: { Authorization: "Bearer x", "Set-Cookie": "session=abc", "User-Agent": "ua" },
},
} as Parameters<typeof beforeSend>[0];
const result = beforeSend(event, {} as Parameters<typeof beforeSend>[1]) as Record<
string,
unknown
>;
} 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]");
@@ -52,38 +52,30 @@ describe("beforeSend", () => {
});
it("redacts IPv4 addresses found in string values", () => {
const event = { extra: { note: "Connection from 192.168.1.10 failed" } } as Parameters<
typeof beforeSend
>[0];
const result = beforeSend(event, {} as Parameters<typeof beforeSend>[1]) as Record<
string,
unknown
>;
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 Parameters<
typeof beforeSend
>[0];
const result = beforeSend(event, {} as Parameters<typeof beforeSend>[1]) as Record<
string,
unknown
>;
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 Parameters<typeof beforeSend>[0], {} as Parameters<typeof beforeSend>[1])).toBeTruthy();
expect(beforeSend({} as Parameters<typeof beforeSend>[0], {} as Parameters<typeof beforeSend>[1])).toBeTruthy();
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 Parameters<typeof beforeSend>[0], {} as Parameters<typeof beforeSend>[1]),
).toBeTruthy();
expect(beforeSend({ extra: { ok: true } } as unknown as ScrubEvent, hint)).toBeTruthy();
});
});
@@ -91,11 +83,8 @@ 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 Parameters<typeof beforeSendTransaction>[0];
const result = beforeSendTransaction(
event,
{} as Parameters<typeof beforeSendTransaction>[1],
) as Record<string, unknown>;
} 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");
@@ -103,25 +92,17 @@ describe("beforeSendTransaction", () => {
});
it("strips PII query params from event.transaction", () => {
const event = { transaction: "/foo?token=x&id=y" } as Parameters<
typeof beforeSendTransaction
>[0];
const result = beforeSendTransaction(
event,
{} as Parameters<typeof beforeSendTransaction>[1],
) as Record<string, unknown>;
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 Parameters<
typeof beforeSendTransaction
>[0];
const result = beforeSendTransaction(
event,
{} as Parameters<typeof beforeSendTransaction>[1],
) as Record<string, unknown>;
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");
@@ -129,11 +110,6 @@ describe("beforeSendTransaction", () => {
});
it("returns the event when no URL present", () => {
expect(
beforeSendTransaction(
{} as Parameters<typeof beforeSendTransaction>[0],
{} as Parameters<typeof beforeSendTransaction>[1],
),
).toBeTruthy();
expect(beforeSendTransaction({} as TxEvent, txHint)).toBeTruthy();
});
});

View File

@@ -1,5 +1,7 @@
// packages/core-shared/src/instrumentation/sentry/scrub.ts
import type { ErrorEvent, EventHint, TransactionEvent } from "@sentry/nextjs";
// 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,
@@ -9,6 +11,24 @@ import {
queryParamContainsPii,
} from "./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");
@@ -37,8 +57,8 @@ function deepScrub(value: unknown, parentKey = ""): unknown {
return value;
}
export function beforeSend(event: ErrorEvent, _hint: EventHint): ErrorEvent | null {
return deepScrub(event) as ErrorEvent;
export function beforeSend(event: SentryEvent, _hint: SentryEventHint): SentryEvent | null {
return deepScrub(event) as SentryEvent;
}
function scrubUrl(url: string): string {
@@ -56,10 +76,10 @@ function scrubUrl(url: string): string {
}
export function beforeSendTransaction(
event: TransactionEvent,
_hint: EventHint,
): TransactionEvent | null {
const out: TransactionEvent = { ...event };
event: SentryEvent,
_hint: SentryEventHint,
): SentryEvent | null {
const out: SentryEvent = { ...event };
if (out.request?.url) {
out.request = { ...out.request, url: scrubUrl(out.request.url) };
}

View File

@@ -21,7 +21,7 @@ describe("SentryLogger", () => {
const err = new Error("boom");
logger.captureException(err, { tags: { feature: "blog" } });
expect(Sentry.captureException).toHaveBeenCalledTimes(1);
expect((Sentry.captureException as ReturnType<typeof vi.fn>).mock.calls[0][0]).toBe(err);
expect((Sentry.captureException as ReturnType<typeof vi.fn>).mock.calls[0]![0]).toBe(err);
});
it("captureException is a no-op when err already marked __sentryReported", () => {

View File

@@ -23,7 +23,7 @@ describe("SentryTracer", () => {
);
expect(result).toBe("value");
expect(Sentry.startSpan).toHaveBeenCalledTimes(1);
expect((Sentry.startSpan as ReturnType<typeof vi.fn>).mock.calls[0][0]).toMatchObject({
expect((Sentry.startSpan as ReturnType<typeof vi.fn>).mock.calls[0]![0]).toMatchObject({
name: "blog.getArticles",
op: "use-case",
});
@@ -36,7 +36,7 @@ describe("SentryTracer", () => {
async () => undefined,
);
expect(
(Sentry.startSpan as ReturnType<typeof vi.fn>).mock.calls[0][0].attributes,
(Sentry.startSpan as ReturnType<typeof vi.fn>).mock.calls[0]![0].attributes,
).toEqual({
collection: "articles",
limit: 10,

View File

@@ -4,11 +4,18 @@ import type { ITracer, ISpan, SpanOpts } from "../tracer.interface";
export class SentryTracer implements ITracer {
async startSpan<T>(opts: SpanOpts, fn: (span: ISpan) => Promise<T>): Promise<T> {
// Filter out null values — Sentry SpanAttributes doesn't allow null
const attributes = opts.attributes
? Object.fromEntries(
Object.entries(opts.attributes).filter(([, v]) => v !== null),
) as Record<string, string | number | boolean>
: undefined;
return Sentry.startSpan(
{
name: opts.name,
op: opts.op,
attributes: opts.attributes,
attributes,
},
async (sentrySpan) => {
const adapter: ISpan = {