diff --git a/README.md b/README.md index 41344db..e6c3cab 100644 --- a/README.md +++ b/README.md @@ -5,41 +5,201 @@ Intentional, namespaced logging for TypeScript browser and Node.js applications. ```ts import { createLogger } from "@mifi/logger"; -const logger = createLogger({ environment: "production" }); -logger.child("WIDGET").debug("Rendered", () => ({ expensive: "only evaluated when shown" })); +const logger = createLogger({ environment: "production", namespace: "WIDGET" }); +logger.child("Button").debug("Rendered", () => ({ expensive: "only evaluated when shown" })); ``` +## Install + +```bash +pnpm add @mifi/logger +``` + +Requires Node.js `>=24`. Published to the private `@mifi` registry. + +## Quick start + +```ts +import { createLogger } from "@mifi/logger"; + +const logger = createLogger({ + environment: "development", // "development" | "staging" | "production" + namespace: "API", // optional; string or string[] joined with ":" +}); + +logger.trace("very detailed"); +logger.debug("diagnostic", () => ({ snapshot: heavyWork() })); +logger.info("ready"); +logger.warn("slow response", { ms: 1200 }); +logger.error("request failed", error); +logger.assert(userId, "missing user id"); + +const auth = logger.child("auth"); // namespace → "API:auth" +auth.info("token refreshed"); +``` + +Console utility methods (`group`, `groupCollapsed`, `table`, `time`, `count`, `dir`, …) are available and gated by the same level policy as `info`. + ## Runtime policy -Development shows every level, staging shows `warn` and `error`, and production shows `error` only. Browser sessions can override the policy with `sessionStorage.showLoggingFor`: +Default levels by environment: -```text -debug -debug:WIDGET,API +| Environment | Default level | Console shows | +| ------------- | ------------- | --------------- | +| `development` | `trace` | all levels | +| `staging` | `warn` | `warn`, `error` | +| `production` | `error` | `error` only | + +Levels in order (most → least verbose): `trace` → `debug` → `info` → `warn` → `error` → `silent`. + +### Override precedence + +Highest wins: + +1. Explicit `level` option on `createLogger` +2. Browser `sessionStorage.showLoggingFor` +3. Env vars `MIFI_LOG_LEVEL` / `MIFI_LOG_NAMESPACES` +4. Environment default (table above) + +### Browser session override + +```js +sessionStorage.setItem("showLoggingFor", "debug"); +// or restrict to namespaces (exact or descendants): +sessionStorage.setItem("showLoggingFor", "debug:WIDGET,API"); ``` -Containers can use `MIFI_LOG_LEVEL` and `MIFI_LOG_NAMESPACES`. Explicit `level` options take precedence. +`WIDGET` matches `WIDGET` and `WIDGET:Button`. + +### Container / Node env overrides + +See [Environment variables](#environment-variables) and [`env.example`](./env.example). + +```bash +MIFI_LOG_LEVEL=debug +MIFI_LOG_NAMESPACES=WIDGET,API +``` + +## Environment variables + +These are read from `process.env` (or from `options.env` when testing/injecting): + +| Variable | Description | +| --------------------- | --------------------------------------------------------------------------- | +| `MIFI_LOG_LEVEL` | Override level: `trace`, `debug`, `info`, `warn`, `error`, or `silent`. | +| `MIFI_LOG_NAMESPACES` | Optional comma-separated namespace filters applied with the level override. | + +When only `MIFI_LOG_LEVEL` is set, all namespaces are enabled at that level. When `MIFI_LOG_NAMESPACES` is also set, only matching namespaces (and their children) emit at that level. + +Copy [`env.example`](./env.example) as a starting point for local or container config. ## 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. 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. +The Sentry adapter has **no SDK dependency**. Provide the SDK your application already uses via `@mifi/logger/sentry`. + +**Development never sends to Sentry.** Staging and production attach the sink when `sentry` is configured. + +Destination policy when `sentry` is configured and `sinks` is not: + +| Runtime + environment | Console | Sentry | +| ---------------------- | ------------------------------- | ------------------------------------------- | +| Production **browser** | Off | Issues for errors; optional Logs | +| Production **Node** | On (stderr for errors) | Issues for errors; optional Logs | +| Staging | On (default `warn`+) | Issues for errors; optional Logs | +| Development | On | Nothing | + +### Issues vs Logs + +- **Errors** → Sentry Issues (`captureException` / `captureMessage`). Not also written to Logs. +- **Warnings and lower** → Sentry Logs (`sentry.logger.*`) only when `{ logs: true }` on the sink (or forced per call). + +Default Sentry Logs thresholds when `logs` is enabled: + +| Environment | Logs threshold | +| ----------- | -------------- | +| Staging | `info` | +| Production | `warn` | + +Override with `logLevel` on `createSentrySink`. Console policy stays independent — production browsers can stay quiet while warnings still appear in Sentry Logs. + +### Per-call options + +`trace` / `debug` / `log` / `info` / `warn` / `error` (and `assert`) accept a trailing options object. Console utilities (`group`, `time`, …) do not. ```ts import * as Sentry from "@sentry/nextjs"; import { createLogger } from "@mifi/logger"; import { createSentrySink } from "@mifi/logger/sentry"; -const logger = createLogger({ environment: "production", sentry: createSentrySink(Sentry) }); +const logger = createLogger({ + environment: "production", + namespace: "API", + sentry: createSentrySink(Sentry, { logs: true }), +}); -logger.sentry.info("Cache warmed", { entries: 42 }); +logger.error("Request failed", error, { requestId: "req_1" }); +logger.warn("slow", { ms: 1200 }); // → Sentry Logs in production +logger.info("investigating", { orderId }, { sentry: true }); // force Logs +logger.error("expected", err, { suppressSentry: true }); // console only (when enabled) +``` + +| Option | Effect | +| ----------------- | ---------------------------------------------------------------------- | +| `sentry: true` | Force this event to Sentry Logs (ignores Logs threshold; not for errors) | +| `suppressSentry: true` | Skip creating a Sentry Issue for an error | + +A last argument is treated as options only when every key is `sentry` or `suppressSentry`. Prefer the third-argument form when you also pass data. + +Logs payload: string message + flat primitive attributes (including `logger.namespace`). Nested objects are not unfurled. + +## Custom sinks + +Replace the default destinations entirely with `sinks`: + +```ts +import { createLogger, createConsoleSink, type LogSink } from "@mifi/logger"; + +const analyticsSink: LogSink = { + emit(event) { + if (event.sendToConsole) analytics.track("log", event); + }, +}; + +const logger = createLogger({ + environment: "production", + sinks: [createConsoleSink(), analyticsSink], +}); +``` + +When `sinks` is provided, the `sentry` option is not used for default sink selection. + +## API overview + +| Export | From | Purpose | +| ---------------------- | --------------------- | ------------------------------------------------ | +| `createLogger` | `@mifi/logger` | Build a namespaced, Console-shaped logger | +| `createConsoleSink` | `@mifi/logger` | Default styled console destination | +| `parseLoggingOverride` | `@mifi/logger` | Parse `debug` / `debug:NS1,NS2` override strings | +| `createSentrySink` | `@mifi/logger/sentry` | Adapter for any Sentry-like SDK | + +Types: `Logger`, `LoggerOptions`, `LogLevel`, `LoggerEnvironment`, `LogEvent`, `LogSink`, `LogData`, `LogCallOptions`, and (from `/sentry`) `SentryLike`, `SentryScopeLike`, `SentrySink`, `SentrySinkOptions`. + +Optional destinations live under `src/sinks/` (console, Sentry today; New Relic / Datadog-style adapters can land beside them later). Public import paths stay `@mifi/logger` and `@mifi/logger/sentry`. + +All public APIs include JSDoc with parameter and example documentation in the TypeScript sources. + +## Lazy arguments + +Any function passed **after** the first argument is invoked only if the event is emitted: + +```ts +logger.debug("state", () => buildHugeSnapshot()); // skipped when debug is suppressed ``` ## Releases Woodpecker verifies pull requests and `main`, reports CI to Mattermost, then automatically releases from Conventional Commits merged to `main`. It updates `package.json` and `CHANGELOG.md`, creates a `vX.Y.Z` tag, and publishes to the private `@mifi` registry. -Use `fix:` for a patch, `feat:` for a minor release, and `!` or a `BREAKING CHANGE:` footer for a major release. Commits such as `docs:`, `test:`, and `chore:` do not release a package. Before enabling the automation, publish and tag the existing `v0.9.1` once as its baseline. +Use `fix:` for a patch, `feat:` for a minor release, and `!` or a `BREAKING CHANGE:` footer for a major release. Commits such as `docs:`, `test:`, and `chore:` do not release a package. The release workflow uses the existing global `gitea_package_token`, `gitea_registry_username`, `gitea_release_token`, `mattermost_bot_access_token`, `mattermost_tests_channel_id`, `mattermost_pushes_channel_id`, and `mattermost_post_api_url` secrets. - -The repository includes a guarded, one-time manual bootstrap workflow for `v0.9.1`; remove it after that initial package version has been published. diff --git a/env.example b/env.example new file mode 100644 index 0000000..366d9e8 --- /dev/null +++ b/env.example @@ -0,0 +1,19 @@ +# @mifi/logger — environment variables +# +# Copy this file or set the variables in your container / process environment. +# Explicit `level` on createLogger() always takes precedence over these values. +# In browsers, sessionStorage.showLoggingFor is checked before these env vars. + +# Override the log level for this process. +# One of: trace | debug | info | warn | error | silent +# When unset, the level defaults from the logger's `environment` option: +# development → trace +# staging → warn +# production → error +# MIFI_LOG_LEVEL=debug + +# Optional comma-separated namespace filters applied together with MIFI_LOG_LEVEL. +# A filter matches the namespace exactly or as a prefix of a child +# (e.g. WIDGET matches WIDGET and WIDGET:Button). +# When unset (and MIFI_LOG_LEVEL is set), all namespaces are enabled at that level. +# MIFI_LOG_NAMESPACES=WIDGET,API diff --git a/src/index.ts b/src/index.ts index 29d355a..a6b12be 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,16 @@ -export * from "./logger.js"; -export * from "./types.js"; +/** + * @mifi/logger — intentional, namespaced logging for browser and Node.js. + * + * @packageDocumentation + * + * @example + * ```ts + * import { createLogger } from "@mifi/logger"; + * + * const logger = createLogger({ environment: "development", namespace: "APP" }); + * logger.info("started"); + * ``` + */ +export * from "./logger"; +export * from "./sinks/console"; +export * from "./types"; diff --git a/src/logger.ts b/src/logger.ts index 09c7878..9f4a2f7 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -1,4 +1,6 @@ +import { createConsoleSink } from "./sinks/console"; import type { + LogCallOptions, LogData, LogEvent, Logger, @@ -6,15 +8,68 @@ import type { LoggerOptions, LogLevel, LogSink, -} from "./types.js"; +} from "./types"; + +/** Duck-type for sinks created by `createSentrySink` (avoids importing the sentry entry). */ +type SentrySinkLike = LogSink & { + kind: "sentry"; + options: { logs?: boolean; logLevel?: LogLevel }; +}; + +function isSentrySinkLike(sink: LogSink): sink is SentrySinkLike { + return "kind" in sink && (sink as { kind?: unknown }).kind === "sentry"; +} const LEVELS: readonly Exclude[] = ["trace", "debug", "info", "warn", "error"]; +const LOG_CALL_OPTION_KEYS = new Set(["sentry", "suppressSentry"]); + +/** Numeric rank for comparing levels; `silent` is above every emit level. */ const levelRank = (level: LogLevel): number => level === "silent" ? Infinity : LEVELS.indexOf(level); + +/** + * Default {@link LogLevel} for a {@link LoggerEnvironment} when no override is set. + * + * @param environment - Deployment stage. + * @returns `trace` (development), `warn` (staging), or `error` (production). + */ const defaultLevel = (environment: LoggerEnvironment): LogLevel => environment === "development" ? "trace" : environment === "staging" ? "warn" : "error"; -/** Parses `debug` or `debug:WIDGET,API`, used by browser and container overrides. */ +/** + * Default Sentry Logs threshold when `createSentrySink(..., { logs: true })` is + * used without an explicit `logLevel`. + * + * @param environment - Deployment stage. + * @returns `info` (staging) or `warn` (production). Development never sends. + */ +export const defaultSentryLogLevel = (environment: LoggerEnvironment): LogLevel => + environment === "staging" ? "info" : "warn"; + +/** + * Parses a runtime logging override string used by browser session storage and + * container env composition. + * + * Accepted forms: + * - `"debug"` — set level only (all namespaces) + * - `"debug:WIDGET,API"` — set level and restrict to matching namespaces + * (exact match or descendant, e.g. `WIDGET` matches `WIDGET:Button`) + * + * @param value - Raw override string, or `null`/`undefined` when unset. + * @returns Parsed level and namespace filters, or `undefined` if invalid/empty. + * + * @example + * ```ts + * parseLoggingOverride("debug"); + * // → { level: "debug", namespaces: [] } + * + * parseLoggingOverride("warn:WIDGET, API"); + * // → { level: "warn", namespaces: ["WIDGET", "API"] } + * + * parseLoggingOverride("verbose"); + * // → undefined + * ``` + */ export function parseLoggingOverride( value: string | null | undefined, ): { level: LogLevel; namespaces: readonly string[] } | undefined { @@ -31,6 +86,40 @@ export function parseLoggingOverride( }; } +/** + * Whether a value is a trailing {@link LogCallOptions} object (only known keys). + * + * @param value - Candidate last argument from a log call. + */ +export function isLogCallOptions(value: unknown): value is LogCallOptions { + if (!value || typeof value !== "object" || Array.isArray(value) || value instanceof Error) + return false; + const keys = Object.keys(value); + return keys.length > 0 && keys.every((key) => LOG_CALL_OPTION_KEYS.has(key)); +} + +/** + * Splits trailing {@link LogCallOptions} from log arguments. + * + * @param arguments_ - Raw call arguments. + * @returns Remaining arguments and parsed options (defaults to `{}`). + */ +function splitLogCallArguments(arguments_: readonly unknown[]): { + arguments: readonly unknown[]; + options: LogCallOptions; +} { + if (arguments_.length === 0) return { arguments: arguments_, options: {} }; + const last = arguments_[arguments_.length - 1]; + if (!isLogCallOptions(last)) return { arguments: arguments_, options: {} }; + return { arguments: arguments_.slice(0, -1), options: last }; +} + +/** + * Resolves session storage for browser overrides. + * + * @param provided - Optional storage; when omitted, uses `globalThis.sessionStorage`. + * @returns A `getItem`-compatible storage, or `undefined` if unavailable. + */ function resolveStorage(provided?: Pick): Pick | undefined { if (provided) return provided; try { @@ -40,6 +129,12 @@ function resolveStorage(provided?: Pick): Pick, ): Record { @@ -50,11 +145,26 @@ function resolveEnv( ); } +/** + * Resolves whether the logger is running in a browser or Node-like runtime. + * + * @param provided - Explicit runtime; when omitted, inferred from `globalThis.window`. + * @returns `"browser"` if `window` is defined, otherwise `"node"`. + */ function resolveRuntime(provided?: "browser" | "node"): "browser" | "node" { if (provided) return provided; return typeof (globalThis as { window?: unknown }).window === "undefined" ? "node" : "browser"; } +/** + * Whether a logger namespace matches any of the configured filters. + * + * Empty `filters` means “match all”. Otherwise the namespace must equal a + * filter or start with `filter:`. + * + * @param namespace - Logger namespace, if any. + * @param filters - Namespace prefixes from a runtime override. + */ function namespaceMatches(namespace: string | undefined, filters: readonly string[]): boolean { return ( filters.length === 0 || @@ -65,13 +175,75 @@ function namespaceMatches(namespace: string | undefined, filters: readonly strin ); } +/** + * Evaluates lazy log arguments: every argument after the first that is a + * zero-arg function is invoked; earlier arguments and non-functions are kept. + * + * @param arguments_ - Raw call arguments (message first, then optional data). + * @returns Arguments with lazy factories resolved to their return values. + */ function evaluate(arguments_: readonly unknown[]): readonly unknown[] { return arguments_.map((argument, index) => index > 0 && typeof argument === "function" ? (argument as () => LogData)() : argument, ); } -/** Creates an intentional, Console-shaped logger. Functions passed after the message are evaluated lazily. */ +/** + * Resolves Sentry sink options from `options.sentry` or a Sentry sink in `sinks`. + * Development always yields `undefined` (nothing is sent to Sentry). + */ +function resolveSentrySinkOptions( + options: LoggerOptions, +): { logs: boolean; logLevel: LogLevel } | undefined { + if (options.environment === "development") return undefined; + const candidate = options.sentry + ? options.sentry + : options.sinks?.find(isSentrySinkLike); + if (!candidate) return undefined; + if (isSentrySinkLike(candidate)) { + return { + logs: Boolean(candidate.options.logs), + logLevel: candidate.options.logLevel ?? defaultSentryLogLevel(options.environment), + }; + } + return { logs: false, logLevel: defaultSentryLogLevel(options.environment) }; +} + +/** + * Creates an intentional, Console-shaped logger with namespacing, lazy + * arguments, environment-aware defaults, and optional Sentry integration. + * + * **Console level precedence** (highest wins): + * 1. `options.level` + * 2. `sessionStorage.showLoggingFor` (browser) + * 3. `MIFI_LOG_LEVEL` + optional `MIFI_LOG_NAMESPACES` (Node/containers) + * 4. Default for `options.environment` + * + * **Default sinks** (when `options.sinks` is omitted): + * - Always include a console sink except production **browser** with Sentry. + * - In staging/production with `options.sentry`, include that sink. + * - Development never attaches or sends to Sentry. + * + * Functions passed after the first argument are evaluated lazily — only when + * the event is emitted — so expensive snapshots stay cheap when suppressed. + * + * @param options - Logger configuration. See {@link LoggerOptions}. + * @returns A {@link Logger} instance. + * + * @example + * ```ts + * import { createLogger } from "@mifi/logger"; + * + * const logger = createLogger({ + * environment: "production", + * namespace: "API", + * }); + * + * logger.error("Request failed", error); + * logger.debug("skipped in production", () => hugeObject()); + * logger.warn("investigate", { id: 1 }, { sentry: true }); + * ``` + */ export function createLogger(options: LoggerOptions): Logger { const namespace = typeof options.namespace === "string" @@ -87,29 +259,38 @@ export function createLogger(options: LoggerOptions): Logger { ); const threshold = options.level ?? runtimeOverride?.level ?? defaultLevel(options.environment); const runtime = resolveRuntime(options.runtime); + const sentrySinkOptions = resolveSentrySinkOptions(options); + const sentryActive = sentrySinkOptions !== undefined; const sinks: readonly LogSink[] = options.sinks ?? [ ...(runtime === "node" || options.environment !== "production" || !options.sentry ? [createConsoleSink()] : []), - ...(options.environment === "production" && options.sentry ? [options.sentry] : []), + ...(options.sentry && options.environment !== "development" ? [options.sentry] : []), ]; const enabled = (level: Exclude) => levelRank(level) >= levelRank(threshold) && (!runtimeOverride?.namespaces.length || namespaceMatches(namespace, runtimeOverride.namespaces)); - const emit = ( - level: Exclude, - arguments_: readonly unknown[], - sendToSentry = false, - ) => { + const emit = (level: Exclude, arguments_: readonly unknown[]) => { + const split = splitLogCallArguments(arguments_); const sendToConsole = enabled(level); - if (!sendToConsole && !sendToSentry) return; + const sendToSentryIssue = + sentryActive && level === "error" && !split.options.suppressSentry; + const sendToSentryLogs = + sentryActive && + level !== "error" && + (Boolean(split.options.sentry) || + (Boolean(sentrySinkOptions?.logs) && + levelRank(level) >= levelRank(sentrySinkOptions.logLevel))); + if (!sendToConsole && !sendToSentryIssue && !sendToSentryLogs) return; const event: LogEvent = { level, namespace, - arguments: evaluate(arguments_), + arguments: evaluate(split.arguments), timestamp: new Date(), - sendToSentry, + environment: options.environment, + sendToSentryLogs, + sendToSentryIssue, sendToConsole, }; sinks.forEach((sink) => sink.emit(event)); @@ -130,14 +311,6 @@ 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), @@ -168,52 +341,13 @@ export function createLogger(options: LoggerOptions): Logger { return api; } +/** + * Prepends a bracketed namespace label to console utility method arguments. + * + * @param namespace - Colon-joined namespace, if any. + * @param arguments_ - Evaluated log arguments. + * @returns Arguments with `[A][B]` prefix when namespaced. + */ function prefix(namespace: string | undefined, arguments_: readonly unknown[]): readonly unknown[] { return namespace ? [`[${namespace.split(":").join("][")}]`, ...arguments_] : arguments_; } - -const ANSI_RESET = "\u001B[0m"; -const ANSI_BY_LEVEL: Record, string> = { - trace: "\u001B[90m", - debug: "\u001B[36m", - info: "\u001B[32m", - warn: "\u001B[33m", - error: "\u001B[31m", -}; -const BROWSER_STYLE_BY_LEVEL: Record, string> = { - trace: "color: #6b7280; font-weight: 600", - debug: "color: #0891b2; font-weight: 600", - info: "color: #15803d; font-weight: 600", - warn: "color: #a16207; font-weight: 700", - error: "color: #dc2626; font-weight: 700", -}; - -function isBrowser(): boolean { - return typeof (globalThis as { window?: unknown }).window !== "undefined"; -} - -function supportsAnsi(): boolean { - return Boolean( - (globalThis as { process?: { stdout?: { isTTY?: boolean } } }).process?.stdout?.isTTY, - ); -} - -/** Default readable console destination. Node's `console.error` writes to stderr. */ -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]; - if (typeof fn !== "function") return; - const label = event.namespace ? `[${event.namespace.split(":").join("][")}]` : "[LOG]"; - const arguments_ = isBrowser() - ? [`%c${label}`, BROWSER_STYLE_BY_LEVEL[event.level], ...event.arguments] - : supportsAnsi() - ? [`${ANSI_BY_LEVEL[event.level]}${label}${ANSI_RESET}`, ...event.arguments] - : [label, ...event.arguments]; - (fn as (...items: unknown[]) => void).apply(console_, arguments_); - }, - }; -} diff --git a/src/sentry.ts b/src/sentry.ts index 928ad52..3be7fee 100644 --- a/src/sentry.ts +++ b/src/sentry.ts @@ -1,37 +1,2 @@ -import type { LogEvent, LogSink } from "./types.js"; - -export interface SentryScopeLike { - setLevel(level: string): void; - setTag(key: string, value: string): void; - setExtras(extras: Record): void; -} -export interface SentryLike { - withScope(callback: (scope: SentryScopeLike) => void): void; - captureException(error: Error): void; - captureMessage(message: string, level: string): void; -} - -/** Creates an optional Sentry adapter without adding a Sentry SDK dependency to this package. */ -export function createSentrySink(sentry: SentryLike): LogSink { - return { - emit(event: LogEvent) { - 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 - .filter((item) => item && typeof item === "object" && !(item instanceof Error)) - .map((item, index) => [`context_${index}`, item]), - ); - const message = - event.arguments.filter((item) => typeof item === "string").join(" ") || - "Logger error"; - sentry.withScope((scope) => { - 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, event.level); - }); - }, - }; -} +/** Public entry for `@mifi/logger/sentry`. Implementation: `src/sinks/sentry.ts`. */ +export * from "./sinks/sentry"; diff --git a/src/sinks/console.ts b/src/sinks/console.ts new file mode 100644 index 0000000..7f8acae --- /dev/null +++ b/src/sinks/console.ts @@ -0,0 +1,67 @@ +import type { LogLevel, LogSink } from "../types"; + +const ANSI_RESET = "\u001B[0m"; +const ANSI_BY_LEVEL: Record, string> = { + trace: "\u001B[90m", + debug: "\u001B[36m", + info: "\u001B[32m", + warn: "\u001B[33m", + error: "\u001B[31m", +}; +const BROWSER_STYLE_BY_LEVEL: Record, string> = { + trace: "color: #6b7280; font-weight: 600", + debug: "color: #0891b2; font-weight: 600", + info: "color: #15803d; font-weight: 600", + warn: "color: #a16207; font-weight: 700", + error: "color: #dc2626; font-weight: 700", +}; + +/** Whether the current global looks like a browser (`window` is defined). */ +function isBrowser(): boolean { + return typeof (globalThis as { window?: unknown }).window !== "undefined"; +} + +/** Whether Node stdout is a TTY that can display ANSI colors. */ +function supportsAnsi(): boolean { + return Boolean( + (globalThis as { process?: { stdout?: { isTTY?: boolean } } }).process?.stdout?.isTTY, + ); +} + +/** + * Default readable console {@link LogSink}. + * + * - Skips events where `sendToConsole` is `false`. + * - Maps `trace` to `console.debug`. + * - Prefixes a styled namespace label (`[A][B]` or `[LOG]`). + * - Uses CSS `%c` styling in browsers and ANSI colors on Node TTYs. + * - Node `error` level uses `console.error` (stderr). + * + * @returns A sink suitable as the default destination or for custom sink lists. + * + * @example + * ```ts + * const logger = createLogger({ + * environment: "development", + * sinks: [createConsoleSink(), customSink], + * }); + * ``` + */ +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]; + if (typeof fn !== "function") return; + const label = event.namespace ? `[${event.namespace.split(":").join("][")}]` : "[LOG]"; + const arguments_ = isBrowser() + ? [`%c${label}`, BROWSER_STYLE_BY_LEVEL[event.level], ...event.arguments] + : supportsAnsi() + ? [`${ANSI_BY_LEVEL[event.level]}${label}${ANSI_RESET}`, ...event.arguments] + : [label, ...event.arguments]; + (fn as (...items: unknown[]) => void).apply(console_, arguments_); + }, + }; +} diff --git a/src/sinks/sentry.ts b/src/sinks/sentry.ts new file mode 100644 index 0000000..373e768 --- /dev/null +++ b/src/sinks/sentry.ts @@ -0,0 +1,197 @@ +import type { LogEvent, LogLevel, LogSink } from "../types"; + +/** + * Options for {@link createSentrySink}. + * + * Sentry Logs are independent of the console level. Defaults when `logs` is + * enabled and `logLevel` is omitted: staging → `info`, production → `warn`. + * Development never sends (enforced by the logger). + */ +export interface SentrySinkOptions { + /** + * When `true`, non-error events at or above {@link SentrySinkOptions.logLevel} + * are written to `sentry.logger.*`. Errors still become Issues only. + */ + logs?: boolean; + /** + * Minimum level for automatic Sentry Logs delivery when {@link SentrySinkOptions.logs} + * is enabled. Per-call `{ sentry: true }` bypasses this threshold. + */ + logLevel?: LogLevel; +} + +/** + * Minimal Sentry scope surface required by {@link createSentrySink}. + * Satisfied by `@sentry/browser`, `@sentry/node`, `@sentry/nextjs`, etc. + */ +export interface SentryScopeLike { + /** + * Sets the severity for the event about to be captured. + * @param level - Logger level string (e.g. `"error"`, `"info"`). + */ + setLevel(level: string): void; + /** + * Attaches a string tag to the event. + * @param key - Tag name (this package uses `"logger.namespace"`). + * @param value - Tag value. + */ + setTag(key: string, value: string): void; + /** + * Attaches structured extras derived from non-Error object arguments. + * @param extras - Key/value map of searchable context. + */ + setExtras(extras: Record): void; +} + +/** + * Minimal `sentry.logger` surface for Sentry Logs. + * Optional on {@link SentryLike}; Logs are skipped when absent. + */ +export interface SentryLoggerApiLike { + trace(message: string, attributes?: Record): void; + debug(message: string, attributes?: Record): void; + info(message: string, attributes?: Record): void; + warn(message: string, attributes?: Record): void; + error(message: string, attributes?: Record): void; +} + +/** + * Minimal Sentry client surface required by {@link createSentrySink}. + * Pass your app's Sentry SDK module; this package does not depend on Sentry. + * + * @example + * ```ts + * import * as Sentry from "@sentry/nextjs"; + * import { createSentrySink } from "@mifi/logger/sentry"; + * + * const sink = createSentrySink(Sentry, { logs: true }); + * ``` + */ +export interface SentryLike { + /** + * Runs `callback` with an isolated scope for tagging/extras. + * @param callback - Receives a {@link SentryScopeLike} to configure. + */ + withScope(callback: (scope: SentryScopeLike) => void): void; + /** + * Captures an `Error` instance as an exception event. + * @param error - The first `Error` found in the log arguments, if any. + */ + captureException(error: Error): void; + /** + * Captures a string message when no `Error` is present in the arguments. + * @param message - Joined string arguments, or a fallback. + * @param level - Event severity. + */ + captureMessage(message: string, level: string): void; + /** Structured Logs API. Required when using `{ logs: true }` or `{ sentry: true }`. */ + logger?: SentryLoggerApiLike; +} + +/** Sink returned by {@link createSentrySink}. */ +export interface SentrySink extends LogSink { + readonly kind: "sentry"; + readonly options: SentrySinkOptions; +} + +/** + * Type guard for sinks created by {@link createSentrySink}. + * + * @param sink - Any {@link LogSink}. + */ +export function isSentrySink(sink: LogSink): sink is SentrySink { + return "kind" in sink && (sink as { kind?: unknown }).kind === "sentry"; +} + +function isAttributeValue(value: unknown): value is string | number | boolean { + return typeof value === "string" || typeof value === "number" || typeof value === "boolean"; +} + +/** + * Builds a Sentry Logs message and flat primitive attributes from a log event. + * + * @param event - Emitted log event (call options already stripped from arguments). + */ +export function toSentryLogPayload(event: LogEvent): { + message: string; + attributes: Record; +} { + const message = + event.arguments.filter((item) => typeof item === "string").join(" ") || "Log event"; + const attributes: Record = {}; + if (event.namespace) attributes["logger.namespace"] = event.namespace; + for (const item of event.arguments) { + if (!item || typeof item !== "object" || item instanceof Error || Array.isArray(item)) + continue; + for (const [key, value] of Object.entries(item as Record)) { + if (isAttributeValue(value)) attributes[key] = value; + } + } + return { message, attributes }; +} + +/** + * Creates a {@link LogSink} that forwards events to a Sentry-compatible SDK + * without adding a Sentry dependency to `@mifi/logger`. + * + * Capture rules (flags are set by {@link createLogger}): + * - `sendToSentryIssue` → `captureException` / `captureMessage` (errors only). + * - `sendToSentryLogs` → `sentry.logger[level]` (non-errors; never doubles an Issue). + * + * Enable automatic Logs with `{ logs: true }`. Override the threshold with + * `logLevel` (defaults: staging `info`, production `warn`). Force a single + * event with `{ sentry: true }` on the log call. Suppress an Issue with + * `{ suppressSentry: true }`. + * + * @param sentry - Any object implementing {@link SentryLike} (typically the Sentry SDK). + * @param options - Optional Logs configuration. + * @returns A sink to pass as `sentry` or inside `sinks` on {@link createLogger}. + * + * @example + * ```ts + * import * as Sentry from "@sentry/node"; + * import { createLogger } from "@mifi/logger"; + * import { createSentrySink } from "@mifi/logger/sentry"; + * + * const logger = createLogger({ + * environment: "production", + * namespace: "API", + * sentry: createSentrySink(Sentry, { logs: true }), + * }); + * + * logger.error("Request failed", new Error("offline"), { requestId: "req_1" }); + * logger.warn("slow", { ms: 1200 }); // → Sentry Logs in production when logs enabled + * logger.info("probe", { id: 1 }, { sentry: true }); // forced Logs + * logger.error("expected", err, { suppressSentry: true }); + * ``` + */ +export function createSentrySink( + sentry: SentryLike, + options: SentrySinkOptions = {}, +): SentrySink { + return { + kind: "sentry", + options, + emit(event: LogEvent) { + if (event.environment === "development") return; + if (event.sendToSentryIssue) { + const error = event.arguments.find((item): item is Error => item instanceof Error); + const { message, attributes } = toSentryLogPayload(event); + sentry.withScope((scope) => { + scope.setLevel(event.level); + if (event.namespace) scope.setTag("logger.namespace", event.namespace); + scope.setExtras(attributes); + if (error) sentry.captureException(error); + else sentry.captureMessage(message, event.level); + }); + return; + } + if (!event.sendToSentryLogs || !sentry.logger) return; + const method = event.level === "trace" ? "trace" : event.level; + const log = sentry.logger[method]; + if (typeof log !== "function") return; + const { message, attributes } = toSentryLogPayload(event); + log.call(sentry.logger, message, attributes); + }, + }; +} diff --git a/src/types.ts b/src/types.ts index 27972d2..bbf7fcb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,77 +1,289 @@ -/** A severity threshold, ordered from most to least verbose. */ +/** + * Severity threshold for log filtering, ordered from most to least verbose. + * + * | Level | Meaning | + * |-----------|----------------------------------------------| + * | `trace` | Extremely detailed diagnostics | + * | `debug` | Development diagnostics | + * | `info` | Routine operational messages | + * | `warn` | Unexpected but recoverable conditions | + * | `error` | Failures that need attention | + * | `silent` | Suppresses all console-bound output | + * + * A logger emits events at or above its configured threshold (e.g. `warn` + * allows `warn` and `error`). + */ export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "silent"; + +/** + * Deployment stage used to pick a default log level when none is configured + * explicitly or via runtime overrides. + * + * Defaults: + * - `development` → `trace` + * - `staging` → `warn` + * - `production` → `error` + */ export type LoggerEnvironment = "development" | "staging" | "production"; + +/** + * A value passed as a log argument after the message. + * + * Prefer a zero-argument function for expensive payloads: it is only invoked + * when the event is actually emitted, so suppressed logs avoid the work. + * + * @example + * ```ts + * logger.debug("state", () => expensiveSnapshot()); + * ``` + */ export type LogData = unknown | (() => unknown); -/** A structured event after its level policy has allowed it to be emitted. */ +/** + * Per-call options for `trace` / `debug` / `log` / `info` / `warn` / `error` + * (and `assert`). Not supported on console utilities such as `group`. + * + * When the last argument is a plain object whose keys are only these option + * names, it is treated as options rather than log data. + */ +export interface LogCallOptions { + /** + * Force this event to Sentry Logs, ignoring the Sentry Logs threshold. + * No-op for `error` (errors become Issues, not Logs) and in development. + */ + sentry?: boolean; + /** + * Skip creating a Sentry Issue for an `error`-level event. + * No-op at lower levels. + */ + suppressSentry?: boolean; +} + +/** + * A structured log event after level and namespace policy have allowed it + * (or after it was selected for Sentry delivery). + */ export interface LogEvent { + /** Severity of this event. Never `silent`. */ level: Exclude; + /** + * Colon-joined namespace for this logger (e.g. `"WIDGET:Button"`). + * Omitted when the logger was created without a namespace. + */ namespace?: string; + /** + * Evaluated arguments for the event (call options already stripped). + * Lazy `() => unknown` functions have already been invoked. + */ arguments: readonly unknown[]; + /** Wall-clock time when the event was created. */ timestamp: Date; - /** Whether this event was explicitly selected for Sentry capture. */ - sendToSentry: boolean; - /** Whether the default console sink should render this event. */ + /** Deployment stage from {@link LoggerOptions.environment}. */ + environment: LoggerEnvironment; + /** + * `true` when the event should be written to Sentry Logs + * (`sentry.logger.*`), not as an Issue. + */ + sendToSentryLogs: boolean; + /** + * `true` when the event should be captured as a Sentry Issue + * (`captureException` / `captureMessage`). + */ + sendToSentryIssue: boolean; + /** + * `true` when the event passed the logger's level/namespace policy and + * should be rendered by console-oriented sinks. + */ sendToConsole: boolean; } -/** A destination for enabled log events. */ +/** + * Destination that receives enabled {@link LogEvent}s. + * + * Provide custom sinks via {@link LoggerOptions.sinks}, or use + * {@link createConsoleSink} / `createSentrySink` from `@mifi/logger/sentry`. + * + * @example + * ```ts + * const sink: LogSink = { + * emit(event) { + * if (event.sendToConsole) analytics.track("log", event); + * }, + * }; + * ``` + */ export interface LogSink { + /** + * Handle a single log event. + * Implementations should respect `sendToConsole` / `sendToSentryLogs` / + * `sendToSentryIssue` as needed. + */ emit(event: LogEvent): void; } +/** + * Options for {@link createLogger}. + * + * Precedence for the effective console level (highest wins): + * 1. Explicit {@link LoggerOptions.level} + * 2. Browser `sessionStorage.showLoggingFor` (or {@link LoggerOptions.sessionStorage}) + * 3. `MIFI_LOG_LEVEL` / `MIFI_LOG_NAMESPACES` from {@link LoggerOptions.env} or `process.env` + * 4. Default for {@link LoggerOptions.environment} + */ export interface LoggerOptions { + /** + * Deployment stage. Selects the default log level when no override applies. + * @see LoggerEnvironment + */ environment: LoggerEnvironment; + /** + * Optional namespace label(s). A string is used as-is; an array is joined + * with `:` (empty segments dropped). Shown in console output as + * `[A][B]` for `"A:B"`. + * + * @example + * ```ts + * createLogger({ environment: "development", namespace: "API" }); + * createLogger({ environment: "development", namespace: ["WIDGET", "Button"] }); + * ``` + */ namespace?: string | readonly string[]; - /** Runtime used for the default destination policy. It is inferred when omitted. */ + /** + * Runtime used for the default destination policy (console vs Sentry-only + * in production browsers). Inferred from `globalThis.window` when omitted. + */ runtime?: "browser" | "node"; - /** Hard override. It takes precedence over runtime environment defaults. */ + /** + * Hard level override. Takes precedence over session storage, env vars, + * and environment defaults. + */ level?: LogLevel; - /** Browser-only session override source; defaults to global sessionStorage when available. */ + /** + * Browser-only source for the `showLoggingFor` override key. + * Defaults to `globalThis.sessionStorage` when available. + * + * Expected value format matches {@link parseLoggingOverride}: + * `debug` or `debug:WIDGET,API`. + */ sessionStorage?: Pick; - /** Node/container environment source; defaults to process.env when available. */ + /** + * Node/container environment variable map. Defaults to `process.env` + * when available. + * + * Recognized keys: + * - `MIFI_LOG_LEVEL` — one of the {@link LogLevel} values + * - `MIFI_LOG_NAMESPACES` — optional comma-separated namespace filters + */ env?: Record; - /** Production error destination. Browser errors go only here; Node errors also go to stderr. */ + /** + * Staging/production Sentry destination (typically from `createSentrySink`). + * Ignored in development (nothing is sent to Sentry). + * + * - Production **browser**: default sinks are Sentry only (no console). + * - Production **Node**: console (stderr for errors) and Sentry. + * - Staging: console and Sentry. + * + * Ignored when {@link LoggerOptions.sinks} is provided. + */ sentry?: LogSink | false; - /** Complete destination override. When provided, `sentry` and default console behavior are not used. */ + /** + * Complete destination list. When set, replaces the default console/Sentry + * policy entirely — {@link LoggerOptions.sentry} is not used. + */ 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; -} - +/** + * Console-shaped logger with namespaces, lazy arguments, child loggers, + * and optional Sentry Issues / Logs delivery. + * + * Standard methods (`trace` … `error`) accept an optional trailing + * {@link LogCallOptions} object. Console utility methods (`group`, `time`, + * `table`, …) are gated by the same console policy and call through to + * `globalThis.console` when enabled — they do not accept {@link LogCallOptions}. + * + * @example + * ```ts + * const logger = createLogger({ environment: "development", namespace: "WIDGET" }); + * logger.info("ready"); + * logger.child("Button").debug("clicked", { id: 1 }); + * logger.warn("investigate", { orderId }, { sentry: true }); + * logger.error("expected", err, { suppressSentry: true }); + * ``` + */ export interface Logger { + /** + * Colon-joined namespace for this instance, if any. + * Immutable; use {@link Logger.child} to nest further segments. + */ readonly namespace?: string; - /** Sends selected events to a configured Sentry sink without changing the default policy for other logs. */ - readonly sentry: SentryLogger; + /** + * Returns a new logger that appends `namespace` to this logger's namespace. + * + * @param namespace - Segment to append (e.g. `"Button"` → `"WIDGET:Button"`). + * @returns A new {@link Logger} sharing the same options and sinks. + * + * @example + * ```ts + * const widget = createLogger({ environment: "development", namespace: "WIDGET" }); + * const button = widget.child("Button"); // namespace === "WIDGET:Button" + * ``` + */ child(namespace: string): Logger; + /** Emit a `trace` event when the level policy allows it. */ + trace(message?: unknown, data?: LogData, options?: LogCallOptions): void; trace(...arguments_: readonly unknown[]): void; + /** Emit a `debug` event when the level policy allows it. */ + debug(message?: unknown, data?: LogData, options?: LogCallOptions): void; debug(...arguments_: readonly unknown[]): void; + /** Alias of {@link Logger.info}. */ + log(message?: unknown, data?: LogData, options?: LogCallOptions): void; log(...arguments_: readonly unknown[]): void; + /** Emit an `info` event when the level policy allows it. */ + info(message?: unknown, data?: LogData, options?: LogCallOptions): void; info(...arguments_: readonly unknown[]): void; + /** Emit a `warn` event when the level policy allows it. */ + warn(message?: unknown, data?: LogData, options?: LogCallOptions): void; warn(...arguments_: readonly unknown[]): void; + /** Emit an `error` event when the level policy allows it. */ + error(message?: unknown, data?: LogData, options?: LogCallOptions): void; error(...arguments_: readonly unknown[]): void; + /** + * When `condition` is falsy, emits an `error` with `"Assertion failed"` + * followed by any extra arguments (optional trailing {@link LogCallOptions}). + * + * @param condition - Truthy values pass silently. + * @param arguments_ - Extra context included with the failure event. + */ assert(condition: unknown, ...arguments_: readonly unknown[]): void; + /** Starts a console group when the level policy allows `info`. */ group(...arguments_: readonly unknown[]): void; + /** Starts a collapsed console group when the level policy allows `info`. */ groupCollapsed(...arguments_: readonly unknown[]): void; + /** Ends the current console group when the level policy allows `info`. */ groupEnd(): void; + /** Logs an object with interactive inspection when the level policy allows `info`. */ dir(item?: unknown, options?: unknown): void; + /** Logs XML/HTML as an interactive tree when the level policy allows `info`. */ dirxml(...arguments_: readonly unknown[]): void; + /** Renders tabular data when the level policy allows `info`. */ table(tabularData?: unknown, properties?: readonly string[]): void; + /** Clears the console when the level policy allows `info`. */ clear(): void; + /** Increments a named counter when the level policy allows `info`. */ count(label?: string): void; + /** Resets a named counter when the level policy allows `info`. */ countReset(label?: string): void; + /** Starts a named timer when the level policy allows `info`. */ time(label?: string): void; + /** Logs elapsed time for a named timer when the level policy allows `info`. */ timeLog(label?: string, ...arguments_: readonly unknown[]): void; + /** Stops a named timer and logs elapsed time when the level policy allows `info`. */ timeEnd(label?: string): void; + /** Adds a timestamp marker to the performance timeline when allowed. */ timeStamp(label?: string): void; + /** Starts a CPU profile (where supported) when the level policy allows `info`. */ profile(label?: string): void; + /** Ends a CPU profile (where supported) when the level policy allows `info`. */ profileEnd(label?: string): void; } diff --git a/test/logger.test.ts b/test/logger.test.ts index 065c4bc..51152fa 100644 --- a/test/logger.test.ts +++ b/test/logger.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; -import { createLogger, parseLoggingOverride } from "../src/index.js"; -import type { LogEvent, LogSink } from "../src/index.js"; +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[] = []; @@ -18,6 +19,17 @@ describe("parseLoggingOverride", () => { }); }); +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(); @@ -74,11 +86,16 @@ describe("createLogger", () => { }); 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("sends explicitly selected production events to Sentry without writing them to the console", () => { + 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({ @@ -86,18 +103,87 @@ describe("createLogger", () => { runtime: "browser", sentry: destination.sink, }); - logger.sentry.info("Cache warmed"); + logger.info("Cache warmed", { sentry: true }); expect(destination.events).toHaveLength(1); expect(destination.events[0]).toMatchObject({ level: "info", arguments: ["Cache warmed"], - sendToSentry: true, + 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); @@ -111,4 +197,73 @@ describe("createLogger", () => { 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, + }); + }); }); diff --git a/test/sentry.test.ts b/test/sentry.test.ts index 6a62b2a..906fd27 100644 --- a/test/sentry.test.ts +++ b/test/sentry.test.ts @@ -1,28 +1,114 @@ import { describe, expect, it, vi } from "vitest"; -import { createLogger } from "../src/index.js"; -import { createSentrySink } from "../src/sentry.js"; +import { createLogger } from "../src/index"; +import { createSentrySink, toSentryLogPayload } from "../src/sentry"; -describe("createSentrySink", () => { - 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 = { +function createFakeSentry() { + const scope = { setLevel: vi.fn(), setTag: vi.fn(), setExtras: vi.fn() }; + return { + scope, + sentry: { withScope: (callback: (value: typeof scope) => void) => callback(scope), captureException: vi.fn(), captureMessage: vi.fn(), - }; + logger: { + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + }, + }; +} + +describe("toSentryLogPayload", () => { + it("joins string messages and flattens primitive object attributes", () => { + expect( + toSentryLogPayload({ + level: "info", + namespace: "API", + arguments: ["Cache warmed", { entries: 42, ok: true, nested: { a: 1 } }], + timestamp: new Date(), + environment: "production", + sendToSentryLogs: true, + sendToSentryIssue: false, + sendToConsole: false, + }), + ).toEqual({ + message: "Cache warmed", + attributes: { + "logger.namespace": "API", + entries: 42, + ok: true, + }, + }); + }); +}); + +describe("createSentrySink", () => { + it("captures errors as Issues with namespace and structured attributes", () => { + const { scope, sentry } = createFakeSentry(); const logger = createLogger({ environment: "production", namespace: "API", sinks: [createSentrySink(sentry)], }); const error = new Error("offline"); - logger.warn("ignored"); - logger.sentry.info("Cache warmed"); + logger.warn("ignored without logs"); 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(sentry.captureMessage).not.toHaveBeenCalled(); + expect(sentry.logger.info).not.toHaveBeenCalled(); + expect(scope.setLevel).toHaveBeenCalledWith("error"); expect(scope.setTag).toHaveBeenCalledWith("logger.namespace", "API"); - expect(scope.setExtras).toHaveBeenCalledWith({ context_0: { requestId: "req_1" } }); + expect(scope.setExtras).toHaveBeenCalledWith({ + "logger.namespace": "API", + requestId: "req_1", + }); + }); + + it("writes non-error events to Sentry Logs when enabled, not as Issues", () => { + const { sentry } = createFakeSentry(); + const logger = createLogger({ + environment: "production", + namespace: "API", + sinks: [createSentrySink(sentry, { logs: true })], + }); + logger.warn("slow", { ms: 1200 }); + logger.info("forced", { id: 1 }, { sentry: true }); + expect(sentry.captureMessage).not.toHaveBeenCalled(); + expect(sentry.captureException).not.toHaveBeenCalled(); + expect(sentry.logger.warn).toHaveBeenCalledWith("slow", { + "logger.namespace": "API", + ms: 1200, + }); + expect(sentry.logger.info).toHaveBeenCalledWith("forced", { + "logger.namespace": "API", + id: 1, + }); + }); + + it("does not create an Issue when suppressSentry is set", () => { + const { sentry } = createFakeSentry(); + const logger = createLogger({ + environment: "production", + runtime: "node", + sinks: [createSentrySink(sentry, { logs: true })], + }); + logger.error("expected", new Error("nope"), { suppressSentry: true }); + expect(sentry.captureException).not.toHaveBeenCalled(); + expect(sentry.logger.error).not.toHaveBeenCalled(); + }); + + it("never sends in development", () => { + const { sentry } = createFakeSentry(); + const logger = createLogger({ + environment: "development", + sinks: [createSentrySink(sentry, { logs: true })], + }); + logger.error("local", new Error("x")); + logger.warn("local warn", { sentry: true }); + expect(sentry.captureException).not.toHaveBeenCalled(); + expect(sentry.logger.warn).not.toHaveBeenCalled(); }); }); diff --git a/tsconfig.json b/tsconfig.json index 0fcbfbb..d1313c5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,8 +1,8 @@ { "compilerOptions": { "target": "ES2024", - "module": "NodeNext", - "moduleResolution": "NodeNext", + "module": "ESNext", + "moduleResolution": "bundler", "strict": true, "declaration": true, "verbatimModuleSyntax": true,