From 7bb5f06a5776ba25894fe06adda9a42fa4d04641 Mon Sep 17 00:00:00 2001 From: Pascal Klesse Date: Thu, 21 May 2026 15:07:16 +0200 Subject: [PATCH] fix(observability): unmask Error objects in PinoLoggerService MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NestJS' ExceptionHandler passes the thrown Error object to logger.error() on module/DI/bootstrap failures. format() did JSON.stringify() for non-strings, and JSON.stringify(new Error("boom")) === "{}" (Error's message/stack are non-enumerable) — so the real cause was masked as "[ExceptionHandler] {}". format() also threw on circular objects, which could crash the logger. - error(): unwrap Error → log its message + preserve the stack. - format(): Error → message; non-serialisable (circular/BigInt) → String() fallback instead of throwing. Adds tests/stories/pino-logger-service.story.test.ts. --- src/core/observability/pino-logger.service.ts | 19 ++++- .../stories/pino-logger-service.story.test.ts | 70 +++++++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 tests/stories/pino-logger-service.story.test.ts diff --git a/src/core/observability/pino-logger.service.ts b/src/core/observability/pino-logger.service.ts index 8fa727e1..88c47489 100644 --- a/src/core/observability/pino-logger.service.ts +++ b/src/core/observability/pino-logger.service.ts @@ -22,6 +22,15 @@ export class PinoLoggerService implements LoggerService { } error(message: unknown, stack?: string, context?: string): void { + // NestJS' ExceptionHandler passes the thrown Error *object* here on + // module/DI/bootstrap failures. `JSON.stringify(error) === "{}"` + // (Error's message/stack are non-enumerable), which silently masked + // the real cause as `[ExceptionHandler] {}`. Unwrap the Error so its + // message becomes the log message and its stack is preserved. + if (message instanceof Error) { + this.logger.error({ context, stack: stack ?? message.stack }, message.message); + return; + } this.logger.error({ context, stack }, this.format(message)); } @@ -34,6 +43,14 @@ export class PinoLoggerService implements LoggerService { } private format(message: unknown): string { - return typeof message === "string" ? message : JSON.stringify(message); + if (typeof message === "string") return message; + if (message instanceof Error) return message.message; + try { + return JSON.stringify(message); + } catch { + // Circular / BigInt / other non-serialisable payloads must never + // crash the logger — fall back to a best-effort string. + return String(message); + } } } diff --git a/tests/stories/pino-logger-service.story.test.ts b/tests/stories/pino-logger-service.story.test.ts new file mode 100644 index 00000000..a766724a --- /dev/null +++ b/tests/stories/pino-logger-service.story.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; + +import type { Logger } from "../../src/core/observability/logger.js"; +import { PinoLoggerService } from "../../src/core/observability/pino-logger.service.js"; + +/** + * Story · PinoLoggerService — NestJS LoggerService → Pino bridge. + * + * Regression guard for the boot-failure DX bug: NestJS' internal + * `ExceptionHandler` passes the thrown `Error` *object* to + * `logger.error(...)` when module/DI initialisation fails. The previous + * `format()` did `JSON.stringify(message)` for non-strings, and + * `JSON.stringify(new Error("boom")) === "{}"` (Error's `message`/`stack` + * are non-enumerable) — so the real cause was masked as `[ExceptionHandler] {}`. + * It also threw on circular objects (`JSON.stringify` cycle error), which + * would crash the logger itself. + */ + +interface Captured { + level: string; + obj: unknown; + msg: unknown; +} + +function fakePino(): { logger: Logger; calls: Captured[] } { + const calls: Captured[] = []; + const at = + (level: string) => + (obj: unknown, msg?: unknown): void => { + calls.push({ level, obj, msg }); + }; + const logger = { + info: at("info"), + warn: at("warn"), + error: at("error"), + debug: at("debug"), + trace: at("trace"), + } as unknown as Logger; + return { logger, calls }; +} + +describe("Story · PinoLoggerService", () => { + it("forwards a string message unchanged with its context", () => { + const { logger, calls } = fakePino(); + new PinoLoggerService(logger).log("hello", "Ctx"); + expect(calls[0]!.level).toBe("info"); + expect(calls[0]!.msg).toBe("hello"); + expect((calls[0]!.obj as { context?: string }).context).toBe("Ctx"); + }); + + it("unmasks an Error passed to error() instead of logging '{}'", () => { + const { logger, calls } = fakePino(); + new PinoLoggerService(logger).error(new Error("storage driver missing aws-sdk")); + const call = calls[0]!; + expect(call.level).toBe("error"); + // The real message must survive — JSON.stringify(error) === '{}' is the bug. + expect(call.msg).toBe("storage driver missing aws-sdk"); + expect(call.msg).not.toBe("{}"); + // The stack is preserved in the structured field. + expect((call.obj as { stack?: string }).stack).toContain("storage driver missing aws-sdk"); + }); + + it("does not throw when a non-serialisable (circular) object is logged", () => { + const { logger, calls } = fakePino(); + const circular: Record = {}; + circular.self = circular; + expect(() => new PinoLoggerService(logger).log(circular)).not.toThrow(); + expect(typeof calls[0]!.msg).toBe("string"); + }); +});