Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions src/lib/__tests__/logger-sentry.test.ts
Original file line number Diff line number Diff line change
@@ -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" },
});
});
});
28 changes: 28 additions & 0 deletions src/lib/logger.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as Sentry from "@sentry/nextjs";
import { env } from "@/lib/env";
import { getLogContext } from "@/lib/log-context";

Expand All @@ -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<string, unknown>;
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
Expand Down Expand Up @@ -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) },
});
}
}
Loading