diff --git a/src/lib/__tests__/logger-sentry.test.ts b/src/lib/__tests__/logger-sentry.test.ts new file mode 100644 index 00000000..baaed6f7 --- /dev/null +++ b/src/lib/__tests__/logger-sentry.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Mock the Sentry SDK so we can assert errorLog's forwarding behaviour +// without an initialised client. `vi.hoisted` lets the mock factory +// reference these stubs safely (the factory is hoisted above imports). +const h = vi.hoisted(() => ({ + captureException: vi.fn(), + client: { present: true } as { present: boolean }, +})); + +vi.mock("@sentry/nextjs", () => ({ + captureException: h.captureException, + getClient: () => (h.client.present ? {} : undefined), +})); + +import { errorLog } from "../logger"; + +describe("errorLog → Sentry forwarding", () => { + beforeEach(() => { + vi.clearAllMocks(); + h.client.present = true; + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + it("captures the Error when data is an Error and a client is active", () => { + const err = new Error("boom"); + errorLog("svc", "it broke", err); + expect(h.captureException).toHaveBeenCalledTimes(1); + expect(h.captureException.mock.calls[0][0]).toBe(err); + expect(h.captureException.mock.calls[0][1]).toMatchObject({ + tags: { log_tag: "svc" }, + extra: { message: "it broke" }, + }); + }); + + it("extracts a nested Error from { error }", () => { + const err = new Error("nested"); + errorLog("svc", "wrapped", { error: err }); + expect(h.captureException).toHaveBeenCalledTimes(1); + expect(h.captureException.mock.calls[0][0]).toBe(err); + }); + + it("does nothing when Sentry has no active client", () => { + h.client.present = false; + errorLog("svc", "broke", new Error("x")); + expect(h.captureException).not.toHaveBeenCalled(); + }); + + it("does nothing when data carries no Error", () => { + errorLog("svc", "plain", { status: 500 }); + expect(h.captureException).not.toHaveBeenCalled(); + }); + + it("sanitizes CR/LF out of the tag forwarded to Sentry", () => { + errorLog("svc\ninject", "msg", new Error("x")); + expect(h.captureException.mock.calls[0][1]).toMatchObject({ + tags: { log_tag: "svcinject" }, + }); + }); +}); diff --git a/src/lib/logger.ts b/src/lib/logger.ts index 69156c58..87e69664 100644 --- a/src/lib/logger.ts +++ b/src/lib/logger.ts @@ -1,3 +1,4 @@ +import * as Sentry from "@sentry/nextjs"; import { env } from "@/lib/env"; import { getLogContext } from "@/lib/log-context"; @@ -20,6 +21,22 @@ function serializeData(data: unknown): unknown { return data; } +/** + * Pull an `Error` out of the common shapes passed as error-log `data` + * (the error itself, or wrapped under `error`/`err`/`cause`/`exception`) + * so it can be forwarded to Sentry with a real stack trace. + */ +function extractError(data: unknown): Error | undefined { + if (data instanceof Error) return data; + if (data && typeof data === "object") { + const d = data as Record; + for (const key of ["error", "err", "cause", "exception"]) { + if (d[key] instanceof Error) return d[key] as Error; + } + } + return undefined; +} + /** * Write a single JSON record. Routes through `console.{log,warn,error,debug}` * (which writes to stdout/stderr under the hood) so existing test @@ -88,4 +105,15 @@ export function warnLog(tag: string, message: string, data?: unknown): void { export function errorLog(tag: string, message: string, data?: unknown): void { // eslint-disable-next-line no-console emit(console.error.bind(console), buildRecord("error", tag, message, data)); + // Forward to Sentry when an Error object is present so the many catch + // sites that report failures via errorLog are no longer invisible to + // error tracking. No-op when Sentry has no active client (tests, or a + // self-host deployment without a configured DSN). + const err = extractError(data); + if (err && Sentry.getClient()) { + Sentry.captureException(err, { + tags: { log_tag: sanitizeMsg(tag) }, + extra: { message: sanitizeMsg(message) }, + }); + } }