feat(core-shared): SentryLogger with double-report guard + R36 user-context strip
This commit is contained in:
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user