diff --git a/src/observability/logger/root.test.ts b/src/observability/logger/root.test.ts new file mode 100644 index 00000000..eb4b432b --- /dev/null +++ b/src/observability/logger/root.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it, vi, afterEach } from "vitest"; + +describe("createRootLogger", () => { + afterEach(async () => { + const mod = await import("./root.js"); + mod.__resetRootLoggerForTests(); + vi.resetModules(); + }); + + it("returns a Logger with console transport in development", async () => { + process.env["NODE_ENV"] = "development"; + const { createRootLogger, __resetRootLoggerForTests } = await import("./root.js"); + __resetRootLoggerForTests(); + const logger = createRootLogger(); + expect(logger).toBeDefined(); + expect(typeof logger.info).toBe("function"); + expect(typeof logger.child).toBe("function"); + }); + + it("returns a Logger with JSON-lines transport in production", async () => { + process.env["NODE_ENV"] = "production"; + process.env["LOGS_ENDPOINT"] = "https://logs.example/ingest"; + const { createRootLogger, __resetRootLoggerForTests } = await import("./root.js"); + __resetRootLoggerForTests(); + const logger = createRootLogger(); + expect(logger).toBeDefined(); + expect(typeof logger.info).toBe("function"); + }); + + it("caches the logger instance (returns same object on repeated calls)", async () => { + process.env["NODE_ENV"] = "development"; + const { createRootLogger, __resetRootLoggerForTests } = await import("./root.js"); + __resetRootLoggerForTests(); + const a = createRootLogger(); + const b = createRootLogger(); + expect(a).toBe(b); + }); + + it("child() produces a Logger with merged context", async () => { + process.env["NODE_ENV"] = "development"; + const spy = vi.spyOn(console, "info").mockImplementation(() => {}); + const { createRootLogger, __resetRootLoggerForTests } = await import("./root.js"); + __resetRootLoggerForTests(); + const logger = createRootLogger(); + const child = logger.child({ traceId: "test-123" }); + child.info("hello"); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy.mock.calls[0]?.[0]).toContain("test-123"); + spy.mockRestore(); + }); +}); diff --git a/src/observability/logger/root.ts b/src/observability/logger/root.ts new file mode 100644 index 00000000..8e6b9d00 --- /dev/null +++ b/src/observability/logger/root.ts @@ -0,0 +1,39 @@ +import type { Logger, LogTransport } from "./types.js"; +import { LoggerImpl } from "./logger-impl.js"; +import { ConsoleTransport } from "./console-transport.js"; +import { JsonLinesHttpTransport } from "./json-lines-transport.js"; + +let cached: Logger | undefined; + +/** + * Creates or returns the cached root logger. In development, uses + * ConsoleTransport. In other envs, uses JsonLinesHttpTransport if + * LOGS_ENDPOINT is set, otherwise falls back to console. + */ +export function createRootLogger(): Logger { + if (cached) return cached; + + const env = process.env["NODE_ENV"] ?? "development"; + const logsEndpoint = process.env["LOGS_ENDPOINT"]; + + let transport: LogTransport; + + if (env === "development" || !logsEndpoint) { + transport = new ConsoleTransport(); + } else { + transport = new JsonLinesHttpTransport({ + endpoint: logsEndpoint, + batchSize: 50, + flushIntervalMs: 5000, + maxBufferSize: 500, + }); + } + + cached = new LoggerImpl(transport); + return cached; +} + +/** Test-only: clears the cached root logger. */ +export function __resetRootLoggerForTests(): void { + cached = undefined; +}