diff --git a/packages/core-shared/src/instrumentation/sentry/sentry-logger.test.ts b/packages/core-shared/src/instrumentation/sentry/sentry-logger.test.ts new file mode 100644 index 0000000..78a10b7 --- /dev/null +++ b/packages/core-shared/src/instrumentation/sentry/sentry-logger.test.ts @@ -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).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); + }); +}); diff --git a/packages/core-shared/src/instrumentation/sentry/sentry-logger.ts b/packages/core-shared/src/instrumentation/sentry/sentry-logger.ts new file mode 100644 index 0000000..d14c393 --- /dev/null +++ b/packages/core-shared/src/instrumentation/sentry/sentry-logger.ts @@ -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)[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; + 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 }); + } +}