feat(logger): add explicit Sentry event channel
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful

This commit is contained in:
2026-08-05 13:05:06 -03:00
parent 0ccb7f911d
commit 44f471653e
7 changed files with 66 additions and 10 deletions
+2 -3
View File
@@ -1,10 +1,9 @@
## [0.9.3](https://git.mifi.dev/mifi/logger/compare/v0.9.2...v0.9.3) (2026-08-05) ## [0.9.3](https://git.mifi.dev/mifi/logger/compare/v0.9.2...v0.9.3) (2026-08-05)
### Bug Fixes ### Bug Fixes
* **release:** build package before publishing ([e0b4501](https://git.mifi.dev/mifi/logger/commit/e0b450157df7a8aa1ff8bf5299140b0e7b52a7f7)) - **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 ([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) ## [0.9.2](https://git.mifi.dev/mifi/logger/compare/v0.9.1...v0.9.2) (2026-08-05)
+3 -1
View File
@@ -22,7 +22,7 @@ Containers can use `MIFI_LOG_LEVEL` and `MIFI_LOG_NAMESPACES`. Explicit `level`
## Sentry ## 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 ```ts
import * as Sentry from "@sentry/nextjs"; import * as Sentry from "@sentry/nextjs";
@@ -30,6 +30,8 @@ import { createLogger } from "@mifi/logger";
import { createSentrySink } from "@mifi/logger/sentry"; import { createSentrySink } from "@mifi/logger/sentry";
const logger = createLogger({ environment: "production", sentry: createSentrySink(Sentry) }); const logger = createLogger({ environment: "production", sentry: createSentrySink(Sentry) });
logger.sentry.info("Cache warmed", { entries: 42 });
``` ```
## Releases ## Releases
+18 -2
View File
@@ -97,13 +97,20 @@ export function createLogger(options: LoggerOptions): Logger {
levelRank(level) >= levelRank(threshold) && levelRank(level) >= levelRank(threshold) &&
(!runtimeOverride?.namespaces.length || (!runtimeOverride?.namespaces.length ||
namespaceMatches(namespace, runtimeOverride.namespaces)); namespaceMatches(namespace, runtimeOverride.namespaces));
const emit = (level: Exclude<LogLevel, "silent">, arguments_: readonly unknown[]) => { const emit = (
if (!enabled(level)) return; level: Exclude<LogLevel, "silent">,
arguments_: readonly unknown[],
sendToSentry = false,
) => {
const sendToConsole = enabled(level);
if (!sendToConsole && !sendToSentry) return;
const event: LogEvent = { const event: LogEvent = {
level, level,
namespace, namespace,
arguments: evaluate(arguments_), arguments: evaluate(arguments_),
timestamp: new Date(), timestamp: new Date(),
sendToSentry,
sendToConsole,
}; };
sinks.forEach((sink) => sink.emit(event)); sinks.forEach((sink) => sink.emit(event));
}; };
@@ -123,6 +130,14 @@ export function createLogger(options: LoggerOptions): Logger {
}; };
const api: Logger = { const api: Logger = {
namespace, 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) => child: (child) =>
createLogger({ ...options, namespace: [namespace, child].filter(Boolean) as string[] }), createLogger({ ...options, namespace: [namespace, child].filter(Boolean) as string[] }),
trace: (...items) => emit("trace", items), trace: (...items) => emit("trace", items),
@@ -187,6 +202,7 @@ function supportsAnsi(): boolean {
export function createConsoleSink(): LogSink { export function createConsoleSink(): LogSink {
return { return {
emit: (event) => { emit: (event) => {
if (!event.sendToConsole) return;
const method = event.level === "trace" ? "debug" : event.level; const method = event.level === "trace" ? "debug" : event.level;
const console_ = globalThis.console as Console & Record<string, unknown>; const console_ = globalThis.console as Console & Record<string, unknown>;
const fn = console_[method]; const fn = console_[method];
+3 -3
View File
@@ -15,7 +15,7 @@ export interface SentryLike {
export function createSentrySink(sentry: SentryLike): LogSink { export function createSentrySink(sentry: SentryLike): LogSink {
return { return {
emit(event: LogEvent) { 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 error = event.arguments.find((item): item is Error => item instanceof Error);
const extras = Object.fromEntries( const extras = Object.fromEntries(
event.arguments event.arguments
@@ -26,11 +26,11 @@ export function createSentrySink(sentry: SentryLike): LogSink {
event.arguments.filter((item) => typeof item === "string").join(" ") || event.arguments.filter((item) => typeof item === "string").join(" ") ||
"Logger error"; "Logger error";
sentry.withScope((scope) => { sentry.withScope((scope) => {
scope.setLevel("error"); scope.setLevel(event.level);
if (event.namespace) scope.setTag("logger.namespace", event.namespace); if (event.namespace) scope.setTag("logger.namespace", event.namespace);
scope.setExtras(extras); scope.setExtras(extras);
if (error) sentry.captureException(error); if (error) sentry.captureException(error);
else sentry.captureMessage(message, "error"); else sentry.captureMessage(message, event.level);
}); });
}, },
}; };
+16
View File
@@ -9,6 +9,10 @@ export interface LogEvent {
namespace?: string; namespace?: string;
arguments: readonly unknown[]; arguments: readonly unknown[];
timestamp: Date; 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. */ /** A destination for enabled log events. */
@@ -33,8 +37,20 @@ export interface LoggerOptions {
sinks?: readonly LogSink[]; 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 { export interface Logger {
readonly namespace?: string; 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; child(namespace: string): Logger;
trace(...arguments_: readonly unknown[]): void; trace(...arguments_: readonly unknown[]): void;
debug(...arguments_: readonly unknown[]): void; debug(...arguments_: readonly unknown[]): void;
+20
View File
@@ -78,6 +78,26 @@ describe("createLogger", () => {
consoleError.mockRestore(); 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", () => { it("keeps production Node errors on stderr as well as sending them to Sentry", () => {
const destination = sink(); const destination = sink();
const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
+4 -1
View File
@@ -3,7 +3,7 @@ import { createLogger } from "../src/index.js";
import { createSentrySink } from "../src/sentry.js"; import { createSentrySink } from "../src/sentry.js";
describe("createSentrySink", () => { 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 scope = { setLevel: vi.fn(), setTag: vi.fn(), setExtras: vi.fn() };
const sentry = { const sentry = {
withScope: (callback: (value: typeof scope) => void) => callback(scope), withScope: (callback: (value: typeof scope) => void) => callback(scope),
@@ -17,8 +17,11 @@ describe("createSentrySink", () => {
}); });
const error = new Error("offline"); const error = new Error("offline");
logger.warn("ignored"); logger.warn("ignored");
logger.sentry.info("Cache warmed");
logger.error("Request failed", error, { requestId: "req_1" }); logger.error("Request failed", error, { requestId: "req_1" });
expect(sentry.captureMessage).toHaveBeenCalledWith("Cache warmed", "info");
expect(sentry.captureException).toHaveBeenCalledWith(error); expect(sentry.captureException).toHaveBeenCalledWith(error);
expect(scope.setLevel).toHaveBeenCalledWith("info");
expect(scope.setTag).toHaveBeenCalledWith("logger.namespace", "API"); expect(scope.setTag).toHaveBeenCalledWith("logger.namespace", "API");
expect(scope.setExtras).toHaveBeenCalledWith({ context_0: { requestId: "req_1" } }); expect(scope.setExtras).toHaveBeenCalledWith({ context_0: { requestId: "req_1" } });
}); });