From 44f471653e8d4045e87886da1e1bbe04edbd60af Mon Sep 17 00:00:00 2001 From: mifi Date: Wed, 5 Aug 2026 13:04:46 -0300 Subject: [PATCH] feat(logger): add explicit Sentry event channel --- CHANGELOG.md | 5 ++--- README.md | 4 +++- src/logger.ts | 20 ++++++++++++++++++-- src/sentry.ts | 6 +++--- src/types.ts | 16 ++++++++++++++++ test/logger.test.ts | 20 ++++++++++++++++++++ test/sentry.test.ts | 5 ++++- 7 files changed, 66 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c64fbc..e573cf9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,9 @@ ## [0.9.3](https://git.mifi.dev/mifi/logger/compare/v0.9.2...v0.9.3) (2026-08-05) - ### Bug Fixes -* **release:** build package before publishing ([e0b4501](https://git.mifi.dev/mifi/logger/commit/e0b450157df7a8aa1ff8bf5299140b0e7b52a7f7)) -* **release:** build package before publishing ([cebe107](https://git.mifi.dev/mifi/logger/commit/cebe107034a15e9fd1517d872037e97337426b29)) +- **release:** build package before publishing ([e0b4501](https://git.mifi.dev/mifi/logger/commit/e0b450157df7a8aa1ff8bf5299140b0e7b52a7f7)) +- **release:** build package before publishing ([cebe107](https://git.mifi.dev/mifi/logger/commit/cebe107034a15e9fd1517d872037e97337426b29)) ## [0.9.2](https://git.mifi.dev/mifi/logger/compare/v0.9.1...v0.9.2) (2026-08-05) diff --git a/README.md b/README.md index e9c6e57..41344db 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Containers can use `MIFI_LOG_LEVEL` and `MIFI_LOG_NAMESPACES`. Explicit `level` ## Sentry -The Sentry adapter has no SDK dependency. Provide the SDK that your application already uses. In a production browser, errors go to Sentry without also appearing in the console. In Node, they go to both Sentry and stderr. +The Sentry adapter has no SDK dependency. Provide the SDK that your application already uses. In a production browser, errors go to Sentry without also appearing in the console. In Node, they go to both Sentry and stderr. Use `logger.sentry` to explicitly capture selected non-error events without changing the default policy or writing them to the console when they are otherwise suppressed. ```ts import * as Sentry from "@sentry/nextjs"; @@ -30,6 +30,8 @@ import { createLogger } from "@mifi/logger"; import { createSentrySink } from "@mifi/logger/sentry"; const logger = createLogger({ environment: "production", sentry: createSentrySink(Sentry) }); + +logger.sentry.info("Cache warmed", { entries: 42 }); ``` ## Releases diff --git a/src/logger.ts b/src/logger.ts index 647ed7d..09c7878 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -97,13 +97,20 @@ export function createLogger(options: LoggerOptions): Logger { levelRank(level) >= levelRank(threshold) && (!runtimeOverride?.namespaces.length || namespaceMatches(namespace, runtimeOverride.namespaces)); - const emit = (level: Exclude, arguments_: readonly unknown[]) => { - if (!enabled(level)) return; + const emit = ( + level: Exclude, + arguments_: readonly unknown[], + sendToSentry = false, + ) => { + const sendToConsole = enabled(level); + if (!sendToConsole && !sendToSentry) return; const event: LogEvent = { level, namespace, arguments: evaluate(arguments_), timestamp: new Date(), + sendToSentry, + sendToConsole, }; sinks.forEach((sink) => sink.emit(event)); }; @@ -123,6 +130,14 @@ export function createLogger(options: LoggerOptions): Logger { }; const api: Logger = { namespace, + sentry: { + trace: (...items) => emit("trace", items, true), + debug: (...items) => emit("debug", items, true), + log: (...items) => emit("info", items, true), + info: (...items) => emit("info", items, true), + warn: (...items) => emit("warn", items, true), + error: (...items) => emit("error", items, true), + }, child: (child) => createLogger({ ...options, namespace: [namespace, child].filter(Boolean) as string[] }), trace: (...items) => emit("trace", items), @@ -187,6 +202,7 @@ function supportsAnsi(): boolean { export function createConsoleSink(): LogSink { return { emit: (event) => { + if (!event.sendToConsole) return; const method = event.level === "trace" ? "debug" : event.level; const console_ = globalThis.console as Console & Record; const fn = console_[method]; diff --git a/src/sentry.ts b/src/sentry.ts index d725a73..928ad52 100644 --- a/src/sentry.ts +++ b/src/sentry.ts @@ -15,7 +15,7 @@ export interface SentryLike { export function createSentrySink(sentry: SentryLike): LogSink { return { emit(event: LogEvent) { - if (event.level !== "error") return; + if (event.level !== "error" && !event.sendToSentry) return; const error = event.arguments.find((item): item is Error => item instanceof Error); const extras = Object.fromEntries( event.arguments @@ -26,11 +26,11 @@ export function createSentrySink(sentry: SentryLike): LogSink { event.arguments.filter((item) => typeof item === "string").join(" ") || "Logger error"; sentry.withScope((scope) => { - scope.setLevel("error"); + scope.setLevel(event.level); if (event.namespace) scope.setTag("logger.namespace", event.namespace); scope.setExtras(extras); if (error) sentry.captureException(error); - else sentry.captureMessage(message, "error"); + else sentry.captureMessage(message, event.level); }); }, }; diff --git a/src/types.ts b/src/types.ts index d70ad93..27972d2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -9,6 +9,10 @@ export interface LogEvent { namespace?: string; arguments: readonly unknown[]; timestamp: Date; + /** Whether this event was explicitly selected for Sentry capture. */ + sendToSentry: boolean; + /** Whether the default console sink should render this event. */ + sendToConsole: boolean; } /** A destination for enabled log events. */ @@ -33,8 +37,20 @@ export interface LoggerOptions { sinks?: readonly LogSink[]; } +/** Explicit Sentry reporting methods. These bypass the logger's normal level threshold. */ +export interface SentryLogger { + trace(...arguments_: readonly unknown[]): void; + debug(...arguments_: readonly unknown[]): void; + log(...arguments_: readonly unknown[]): void; + info(...arguments_: readonly unknown[]): void; + warn(...arguments_: readonly unknown[]): void; + error(...arguments_: readonly unknown[]): void; +} + export interface Logger { readonly namespace?: string; + /** Sends selected events to a configured Sentry sink without changing the default policy for other logs. */ + readonly sentry: SentryLogger; child(namespace: string): Logger; trace(...arguments_: readonly unknown[]): void; debug(...arguments_: readonly unknown[]): void; diff --git a/test/logger.test.ts b/test/logger.test.ts index fe0f1e7..065c4bc 100644 --- a/test/logger.test.ts +++ b/test/logger.test.ts @@ -78,6 +78,26 @@ describe("createLogger", () => { consoleError.mockRestore(); }); + it("sends explicitly selected production events to Sentry 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.sentry.info("Cache warmed"); + expect(destination.events).toHaveLength(1); + expect(destination.events[0]).toMatchObject({ + level: "info", + arguments: ["Cache warmed"], + sendToSentry: true, + sendToConsole: false, + }); + expect(consoleInfo).not.toHaveBeenCalled(); + consoleInfo.mockRestore(); + }); + 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); diff --git a/test/sentry.test.ts b/test/sentry.test.ts index 998db09..6a62b2a 100644 --- a/test/sentry.test.ts +++ b/test/sentry.test.ts @@ -3,7 +3,7 @@ import { createLogger } from "../src/index.js"; import { createSentrySink } from "../src/sentry.js"; describe("createSentrySink", () => { - it("captures errors with namespace and structured context, but ignores warnings", () => { + it("captures errors and explicitly selected events with namespace and structured context", () => { const scope = { setLevel: vi.fn(), setTag: vi.fn(), setExtras: vi.fn() }; const sentry = { withScope: (callback: (value: typeof scope) => void) => callback(scope), @@ -17,8 +17,11 @@ describe("createSentrySink", () => { }); const error = new Error("offline"); logger.warn("ignored"); + logger.sentry.info("Cache warmed"); logger.error("Request failed", error, { requestId: "req_1" }); + expect(sentry.captureMessage).toHaveBeenCalledWith("Cache warmed", "info"); expect(sentry.captureException).toHaveBeenCalledWith(error); + expect(scope.setLevel).toHaveBeenCalledWith("info"); expect(scope.setTag).toHaveBeenCalledWith("logger.namespace", "API"); expect(scope.setExtras).toHaveBeenCalledWith({ context_0: { requestId: "req_1" } }); });