import { describe, expect, it, vi } from "vitest"; import { createLogger, isLogCallOptions, parseLoggingOverride } from "../src/index"; import type { LogEvent, LogSink } from "../src/index"; import { createSentrySink } from "../src/sentry"; function sink(): { sink: LogSink; events: LogEvent[] } { const events: LogEvent[] = []; return { events, sink: { emit: (event) => events.push(event) } }; } describe("parseLoggingOverride", () => { it("accepts a global level and a namespace-filtered level", () => { expect(parseLoggingOverride("debug")).toEqual({ level: "debug", namespaces: [] }); expect(parseLoggingOverride("debug:WIDGET, API")).toEqual({ level: "debug", namespaces: ["WIDGET", "API"], }); expect(parseLoggingOverride("verbose")).toBeUndefined(); }); }); describe("isLogCallOptions", () => { it("accepts only known option keys", () => { expect(isLogCallOptions({ sentry: true })).toBe(true); expect(isLogCallOptions({ suppressSentry: true })).toBe(true); expect(isLogCallOptions({ sentry: true, suppressSentry: false })).toBe(true); expect(isLogCallOptions({ requestId: "req_1" })).toBe(false); expect(isLogCallOptions({ sentry: true, requestId: "req_1" })).toBe(false); expect(isLogCallOptions({})).toBe(false); }); }); describe("createLogger", () => { it("uses environment defaults and never evaluates suppressed lazy data", () => { const destination = sink(); const expensive = vi.fn(() => ({ huge: "snapshot" })); const logger = createLogger({ environment: "production", sinks: [destination.sink] }); logger.debug("ignored", expensive); logger.error("kept", expensive); expect(expensive).toHaveBeenCalledTimes(1); expect(destination.events).toHaveLength(1); expect(destination.events[0]?.arguments).toEqual(["kept", { huge: "snapshot" }]); expect(destination.events[0]?.async).toBeUndefined(); }); it("never starts suppressed async lazy factories", () => { const destination = sink(); const expensive = vi.fn(async () => ({ huge: "snapshot" })); const logger = createLogger({ environment: "production", sinks: [destination.sink] }); logger.debug("ignored", expensive); expect(expensive).not.toHaveBeenCalled(); expect(destination.events).toHaveLength(0); }); it("settles async lazy factories before emitting and sets async", async () => { const destination = sink(); const factory = vi.fn(async () => ({ data: 1 })); const logger = createLogger({ environment: "development", sinks: [destination.sink] }); logger.debug("response", factory); expect(factory).toHaveBeenCalledTimes(1); expect(destination.events).toHaveLength(0); await vi.waitFor(() => expect(destination.events).toHaveLength(1)); expect(destination.events[0]).toMatchObject({ arguments: ["response", { data: 1 }], async: true, }); }); it("unwraps direct Promise arguments and keeps rejection reasons", async () => { const destination = sink(); const reason = new Error("boom"); const logger = createLogger({ environment: "development", sinks: [destination.sink] }); logger.error("failed", { id: 1 }, Promise.reject(reason)); expect(destination.events).toHaveLength(0); await vi.waitFor(() => expect(destination.events).toHaveLength(1)); expect(destination.events[0]).toMatchObject({ arguments: ["failed", { id: 1 }, reason], async: true, }); }); it("applies session namespace filtering to namespace descendants", () => { const destination = sink(); const storage = { getItem: () => "debug:WIDGET" }; createLogger({ environment: "production", namespace: "WIDGET:Button", sessionStorage: storage, sinks: [destination.sink], }).debug("shown"); createLogger({ environment: "production", namespace: "API", sessionStorage: storage, sinks: [destination.sink], }).debug("hidden"); expect(destination.events.map((event) => event.arguments[0])).toEqual(["shown"]); }); it("builds immutable child namespaces and reports failed assertions as errors", () => { const destination = sink(); const root = createLogger({ environment: "development", namespace: "WIDGET", sinks: [destination.sink], }); root.child("Button").assert(false, "missing label"); expect(root.namespace).toBe("WIDGET"); expect(destination.events[0]).toMatchObject({ level: "error", namespace: "WIDGET:Button", arguments: ["Assertion failed", "missing label"], }); }); it("sends production browser errors to Sentry without writing to the console", () => { const destination = sink(); const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); const logger = createLogger({ environment: "production", runtime: "browser", sentry: destination.sink, }); logger.error("captured"); expect(destination.events).toHaveLength(1); expect(destination.events[0]).toMatchObject({ sendToSentryIssue: true, sendToSentryLogs: false, sendToConsole: true, }); expect(consoleError).not.toHaveBeenCalled(); consoleError.mockRestore(); }); it("forces selected events to Sentry Logs without writing them to the console", () => { const destination = sink(); const consoleInfo = vi.spyOn(console, "info").mockImplementation(() => undefined); const logger = createLogger({ environment: "production", runtime: "browser", sentry: destination.sink, }); logger.info("Cache warmed", { sentry: true }); expect(destination.events).toHaveLength(1); expect(destination.events[0]).toMatchObject({ level: "info", arguments: ["Cache warmed"], sendToSentryLogs: true, sendToSentryIssue: false, sendToConsole: false, }); expect(consoleInfo).not.toHaveBeenCalled(); consoleInfo.mockRestore(); }); it("strips trailing call options from event arguments and supports data plus options", () => { const destination = sink(); const logger = createLogger({ environment: "production", runtime: "browser", sentry: destination.sink, }); logger.warn("slow", { ms: 1200 }, { sentry: true }); expect(destination.events[0]).toMatchObject({ level: "warn", arguments: ["slow", { ms: 1200 }], sendToSentryLogs: true, sendToConsole: false, }); }); it("suppresses Sentry Issues when suppressSentry is set", () => { const destination = sink(); const logger = createLogger({ environment: "production", runtime: "browser", sentry: destination.sink, }); logger.error("expected", new Error("nope"), { suppressSentry: true }); expect(destination.events).toHaveLength(1); expect(destination.events[0]).toMatchObject({ sendToSentryIssue: false, sendToSentryLogs: false, arguments: ["expected", expect.any(Error)], }); }); it("never marks events for Sentry in development", () => { const destination = sink(); const logger = createLogger({ environment: "development", sinks: [ destination.sink, createSentrySink( { withScope: (callback) => callback({ setLevel: () => undefined, setTag: () => undefined, setExtras: () => undefined, }), captureException: vi.fn(), captureMessage: vi.fn(), logger: { trace: vi.fn(), debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), }, }, { logs: true }, ), ], }); logger.error("local only"); logger.info("probe", { sentry: true }); expect(destination.events).toHaveLength(2); expect( destination.events.every( (event) => !event.sendToSentryIssue && !event.sendToSentryLogs, ), ).toBe(true); }); it("keeps production Node errors on stderr as well as sending them to Sentry", () => { const destination = sink(); const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); const logger = createLogger({ environment: "production", runtime: "node", sentry: destination.sink, }); logger.error("captured"); expect(destination.events).toHaveLength(1); expect(consoleError).toHaveBeenCalledTimes(1); consoleError.mockRestore(); }); it("mirrors warn to Sentry Logs in production when logs are enabled on the sink", () => { const destination = sink(); const logger = createLogger({ environment: "production", runtime: "browser", sinks: [ destination.sink, createSentrySink( { withScope: () => undefined, captureException: vi.fn(), captureMessage: vi.fn(), logger: { trace: vi.fn(), debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), }, }, { logs: true }, ), ], }); logger.warn("noisy"); logger.info("below default production logs threshold"); expect(destination.events).toHaveLength(1); expect(destination.events[0]).toMatchObject({ level: "warn", sendToSentryLogs: true, sendToConsole: false, }); }); it("mirrors info to Sentry Logs in staging when logs are enabled", () => { const destination = sink(); const logger = createLogger({ environment: "staging", runtime: "browser", sinks: [ destination.sink, createSentrySink( { withScope: () => undefined, captureException: vi.fn(), captureMessage: vi.fn(), logger: { trace: vi.fn(), debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), }, }, { logs: true }, ), ], }); logger.info("visible in staging logs"); logger.debug("still below staging logs threshold"); expect(destination.events.map((event) => event.arguments[0])).toEqual([ "visible in staging logs", ]); expect(destination.events[0]).toMatchObject({ sendToSentryLogs: true, sendToConsole: false, }); }); });