feat(core-shared): SentryLogger with double-report guard + R36 user-context strip

This commit is contained in:
2026-05-06 23:45:45 +02:00
parent a324da09c5
commit 6d4522438d
2 changed files with 147 additions and 0 deletions

View File

@@ -0,0 +1,82 @@
// packages/core-shared/src/instrumentation/sentry/sentry-logger.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@sentry/nextjs", () => ({
captureException: vi.fn(),
captureMessage: vi.fn(),
addBreadcrumb: vi.fn(),
setUser: vi.fn(),
}));
import * as Sentry from "@sentry/nextjs";
import { SentryLogger } from "@/instrumentation/sentry/sentry-logger";
describe("SentryLogger", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("captureException forwards to Sentry on first call", () => {
const logger = new 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);
});
it("captureException is a no-op when err already marked __sentryReported", () => {
const logger = new SentryLogger();
const err = new Error("already-reported");
Object.defineProperty(err, "__sentryReported", { value: true });
logger.captureException(err);
expect(Sentry.captureException).not.toHaveBeenCalled();
});
it("captureException marks err as __sentryReported after sending", () => {
const logger = new SentryLogger();
const err = new Error("once");
logger.captureException(err);
expect((err as unknown as { __sentryReported: boolean }).__sentryReported).toBe(true);
// Second call: no-op
logger.captureException(err);
expect(Sentry.captureException).toHaveBeenCalledTimes(1);
});
it("__sentryReported is non-enumerable", () => {
const logger = new SentryLogger();
const err = new Error("x");
logger.captureException(err);
expect(Object.keys(err)).not.toContain("__sentryReported");
expect(JSON.stringify(err)).not.toContain("__sentryReported");
});
it("captureMessage forwards to Sentry", () => {
const logger = new SentryLogger();
logger.captureMessage("hello", "warning", { tags: { foo: "bar" } });
expect(Sentry.captureMessage).toHaveBeenCalledWith(
"hello",
expect.objectContaining({ level: "warning" }),
);
});
it("addBreadcrumb forwards to Sentry", () => {
const logger = new SentryLogger();
logger.addBreadcrumb({ category: "test", message: "x", data: { k: "v" } });
expect(Sentry.addBreadcrumb).toHaveBeenCalledTimes(1);
});
it("setUser strips non-id keys and warns in dev", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const logger = new SentryLogger();
logger.setUser({ id: "u1", email: "a@b.c", username: "alice" } as unknown as { id: string });
expect(Sentry.setUser).toHaveBeenCalledWith({ id: "u1" });
expect(warn).toHaveBeenCalled();
warn.mockRestore();
});
it("setUser passes null through", () => {
const logger = new SentryLogger();
logger.setUser(null);
expect(Sentry.setUser).toHaveBeenCalledWith(null);
});
});

View File

@@ -0,0 +1,65 @@
// packages/core-shared/src/instrumentation/sentry/sentry-logger.ts
import * as Sentry from "@sentry/nextjs";
import type { ILogger, Breadcrumb, CaptureContext } from "../logger.interface";
const REPORTED = "__sentryReported" as const;
function isReported(err: unknown): boolean {
return (
err !== null &&
typeof err === "object" &&
Boolean((err as Record<string, unknown>)[REPORTED])
);
}
function markReported(err: unknown): void {
if (err !== null && typeof err === "object") {
Object.defineProperty(err, REPORTED, {
value: true,
enumerable: false,
configurable: false,
writable: false,
});
}
}
export class SentryLogger implements ILogger {
captureException(err: unknown, ctx?: CaptureContext): void {
if (isReported(err)) return;
Sentry.captureException(err, ctx);
markReported(err);
}
captureMessage(
msg: string,
level: "info" | "warning" | "error" = "info",
ctx?: CaptureContext,
): void {
Sentry.captureMessage(msg, { level, ...ctx });
}
addBreadcrumb(b: Breadcrumb): void {
Sentry.addBreadcrumb({
category: b.category,
message: b.message,
level: b.level,
data: b.data,
});
}
setUser(user: { id: string } | null): void {
if (user === null) {
Sentry.setUser(null);
return;
}
const { id, ...extra } = user as { id: string } & Record<string, unknown>;
if (Object.keys(extra).length > 0) {
// R36 — strip non-id keys; warn in dev for visibility
console.warn(
"[SentryLogger.setUser] stripped non-id keys for PII safety:",
Object.keys(extra),
);
}
Sentry.setUser({ id });
}
}