fix(docs):Docs and Exports
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/publish unknown status

- Add documentation for recently released changes
- Update some exports to include enums
This commit is contained in:
2026-08-21 11:45:56 -03:00
parent 2134ba58b8
commit f464fa5017
18 changed files with 1350 additions and 1307 deletions
+1 -1
View File
@@ -1 +1 @@
{ "singleQuote": false, "tabWidth": 4, "trailingComma": "all", "printWidth": 100, "semi": true } { "singleQuote": true, "tabWidth": 4, "trailingComma": "all", "printWidth": 100, "semi": true }
+1 -1
View File
@@ -4,7 +4,7 @@ when:
branch: main branch: main
labels: labels:
performance: low performance: high
steps: steps:
verify: verify:
+67 -34
View File
@@ -3,10 +3,11 @@
Intentional, namespaced logging for TypeScript browser and Node.js applications. Intentional, namespaced logging for TypeScript browser and Node.js applications.
```ts ```ts
import { createLogger } from "@mifi/logger"; import { createLogger } from '@mifi/logger';
const logger = createLogger({ environment: "production", namespace: "WIDGET" }); const logger = createLogger({ environment: 'production', namespace: 'WIDGET' });
logger.child("Button").debug("Rendered", () => ({ expensive: "only evaluated when shown" })); logger.child('Button').debug('Rendered', () => ({ expensive: 'only evaluated when shown' }));
logger.debug('response', async () => ({ data: await fetchBody() })); // settled before sinks
``` ```
## Install ## Install
@@ -20,22 +21,23 @@ Requires Node.js `>=24`. Published to the private `@mifi` registry.
## Quick start ## Quick start
```ts ```ts
import { createLogger } from "@mifi/logger"; import { createLogger } from '@mifi/logger';
const logger = createLogger({ const logger = createLogger({
environment: "development", // "development" | "staging" | "production" environment: 'development', // "development" | "staging" | "production"
namespace: "API", // optional; string or string[] joined with ":" namespace: 'API', // optional; string or string[] joined with ":"
}); });
logger.trace("very detailed"); logger.trace('very detailed');
logger.debug("diagnostic", () => ({ snapshot: heavyWork() })); logger.debug('diagnostic', () => ({ snapshot: heavyWork() }));
logger.info("ready"); logger.debug('response', async () => ({ data: await res.json(), url: res.url }));
logger.warn("slow response", { ms: 1200 }); logger.info('ready');
logger.error("request failed", error); logger.warn('slow response', { ms: 1200 });
logger.assert(userId, "missing user id"); logger.error('request failed', error);
logger.assert(userId, 'missing user id');
const auth = logger.child("auth"); // namespace → "API:auth" const auth = logger.child('auth'); // namespace → "API:auth"
auth.info("token refreshed"); auth.info('token refreshed');
``` ```
Console utility methods (`group`, `groupCollapsed`, `table`, `time`, `count`, `dir`, …) are available and gated by the same level policy as `info`. Console utility methods (`group`, `groupCollapsed`, `table`, `time`, `count`, `dir`, …) are available and gated by the same level policy as `info`.
@@ -64,9 +66,9 @@ Highest wins:
### Browser session override ### Browser session override
```js ```js
sessionStorage.setItem("showLoggingFor", "debug"); sessionStorage.setItem('showLoggingFor', 'debug');
// or restrict to namespaces (exact or descendants): // or restrict to namespaces (exact or descendants):
sessionStorage.setItem("showLoggingFor", "debug:WIDGET,API"); sessionStorage.setItem('showLoggingFor', 'debug:WIDGET,API');
``` ```
`WIDGET` matches `WIDGET` and `WIDGET:Button`. `WIDGET` matches `WIDGET` and `WIDGET:Button`.
@@ -127,20 +129,20 @@ Override with `logLevel` on `createSentrySink`. Console policy stays independent
`trace` / `debug` / `log` / `info` / `warn` / `error` (and `assert`) accept a trailing options object. Console utilities (`group`, `time`, …) do not. `trace` / `debug` / `log` / `info` / `warn` / `error` (and `assert`) accept a trailing options object. Console utilities (`group`, `time`, …) do not.
```ts ```ts
import * as Sentry from "@sentry/nextjs"; import * as Sentry from '@sentry/nextjs';
import { createLogger } from "@mifi/logger"; import { createLogger } from '@mifi/logger';
import { createSentrySink } from "@mifi/logger/sentry"; import { createSentrySink } from '@mifi/logger/sentry';
const logger = createLogger({ const logger = createLogger({
environment: "production", environment: 'production',
namespace: "API", namespace: 'API',
sentry: createSentrySink(Sentry, { logs: true }), sentry: createSentrySink(Sentry, { logs: true }),
}); });
logger.error("Request failed", error, { requestId: "req_1" }); logger.error('Request failed', error, { requestId: 'req_1' });
logger.warn("slow", { ms: 1200 }); // → Sentry Logs in production logger.warn('slow', { ms: 1200 }); // → Sentry Logs in production
logger.info("investigating", { orderId }, { sentry: true }); // force Logs logger.info('investigating', { orderId }, { sentry: true }); // force Logs
logger.error("expected", err, { suppressSentry: true }); // console only (when enabled) logger.error('expected', err, { suppressSentry: true }); // console only (when enabled)
``` ```
| Option | Effect | | Option | Effect |
@@ -150,7 +152,7 @@ logger.error("expected", err, { suppressSentry: true }); // console only (when e
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. 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. Logs payload: string message + flat primitive attributes (including `logger.namespace`). Nested objects are not unfurled. When emit was deferred for thenables, the payload also includes `async: true`.
Issue severities use Sentrys union (`warning`, not logger `warn`). The sink maps `warn``warning` and `trace``debug` for Issues only; Logs keep logger level names (`sentry.logger.warn`). Issue severities use Sentrys union (`warning`, not logger `warn`). The sink maps `warn``warning` and `trace``debug` for Issues only; Logs keep logger level names (`sentry.logger.warn`).
@@ -159,16 +161,17 @@ Issue severities use Sentrys union (`warning`, not logger `warn`). The sink m
Replace the default destinations entirely with `sinks`: Replace the default destinations entirely with `sinks`:
```ts ```ts
import { createLogger, createConsoleSink, type LogSink } from "@mifi/logger"; import { createLogger, createConsoleSink, type LogSink } from '@mifi/logger';
const analyticsSink: LogSink = { const analyticsSink: LogSink = {
emit(event) { emit(event) {
if (event.sendToConsole) analytics.track("log", event); if (event.sendToConsole) analytics.track('log', event);
// event.async === true when args were settled asynchronously
}, },
}; };
const logger = createLogger({ const logger = createLogger({
environment: "production", environment: 'production',
sinks: [createConsoleSink(), analyticsSink], sinks: [createConsoleSink(), analyticsSink],
}); });
``` ```
@@ -192,16 +195,46 @@ All public APIs include JSDoc with parameter and example documentation in the Ty
## Lazy arguments ## Lazy arguments
Any function passed **after** the first argument is invoked only if the event is emitted: Any function passed **after** the first argument is invoked only if the event will actually be emitted (console and/or Sentry). That keeps expensive snapshots cheap when the level is suppressed.
```ts ```ts
logger.debug("state", () => buildHugeSnapshot()); // skipped when debug is suppressed logger.debug('state', () => buildHugeSnapshot()); // skipped when debug is suppressed
logger.debug("response", async () => ({ data: await res.json(), url })); logger.error('failed', error, () => ({ body: pendingBody }));
``` ```
Top-level thenables (including Promises returned from lazy factories, or a Promise passed directly as an argument) are settled before sinks run. The log call stays fire-and-forget (`void`). Settled events set `LogEvent.async`, and the console sink renders an `[async]` label suffix. Nested promises inside plain objects are not walked — compose them inside the async factory. Async emits may appear out of order relative to sync logs. ### Async factories and Promises
Rejection reasons replace rejected thenables in that argument slot; other arguments still emit. Factories may be `async`, or you may pass a top-level `Promise` as an argument. Thenables are **settled before sinks run**; the log method stays fire-and-forget (`void`).
```ts
logger.debug('response', async () => ({
data: await res.json(),
url: res.url,
}));
logger.error('failed', { id }, responsePromise);
```
| Behavior | Detail |
| ----------------- | -------------------------------------------------------------------------------------------- |
| Unwrap scope | **Shallow** — only top-level thenables in the argument list |
| Nested promises | Not walked; compose with `await` / `Promise.all` inside the factory |
| Call return type | Still `void` (no `await logger.debug(...)`) |
| Timestamp | Captured at call time (before settle), so async logs correlate with the failure moment |
| `LogEvent.async` | `true` when emit was deferred; omitted for fully sync emits |
| Console | Label gains an `[async]` suffix, e.g. `[API][async]` |
| Sentry Logs | Attribute `async: true` when set |
| Ordering | Async emits may appear after later sync logs |
| Rejections | Rejection `reason` replaces that argument slot; other args still emit (`Promise.allSettled`) |
| Console utilities | `group` / `time` / etc. do not settle thenables (emit path only) |
```ts
// Nested promises — compose inside the factory:
logger.debug('route', async () => {
const [body, next] = await Promise.all([res.json(), nextRoute]);
return { body, next };
});
```
## Releases ## Releases
+962 -962
View File
File diff suppressed because it is too large Load Diff
+11 -11
View File
@@ -3,7 +3,7 @@ const releaseToken = process.env.GITEA_RELEASE_TOKEN;
const repositoryUrl = const repositoryUrl =
releaseUsername && releaseToken releaseUsername && releaseToken
? `https://${encodeURIComponent(releaseUsername)}:${encodeURIComponent(releaseToken)}@git.mifi.dev/mifi/logger.git` ? `https://${encodeURIComponent(releaseUsername)}:${encodeURIComponent(releaseToken)}@git.mifi.dev/mifi/logger.git`
: "https://git.mifi.dev/mifi/logger.git"; : 'https://git.mifi.dev/mifi/logger.git';
/** /**
* Default Angular preset rejects `feat!` / `fix!` headers entirely (no release). * Default Angular preset rejects `feat!` / `fix!` headers entirely (no release).
@@ -12,23 +12,23 @@ const repositoryUrl =
const conventionalCommitParserOpts = { const conventionalCommitParserOpts = {
headerPattern: /^(\w*)(?:\((.*)\))?!?: (.*)$/, headerPattern: /^(\w*)(?:\((.*)\))?!?: (.*)$/,
breakingHeaderPattern: /^(\w*)(?:\((.*)\))?!: (.*)$/, breakingHeaderPattern: /^(\w*)(?:\((.*)\))?!: (.*)$/,
noteKeywords: ["BREAKING CHANGE", "BREAKING-CHANGE"], noteKeywords: ['BREAKING CHANGE', 'BREAKING-CHANGE'],
}; };
module.exports = { module.exports = {
branches: ["main"], branches: ['main'],
repositoryUrl, repositoryUrl,
tagFormat: "v${version}", tagFormat: 'v${version}',
plugins: [ plugins: [
["@semantic-release/commit-analyzer", { parserOpts: conventionalCommitParserOpts }], ['@semantic-release/commit-analyzer', { parserOpts: conventionalCommitParserOpts }],
["@semantic-release/release-notes-generator", { parserOpts: conventionalCommitParserOpts }], ['@semantic-release/release-notes-generator', { parserOpts: conventionalCommitParserOpts }],
["@semantic-release/changelog", { changelogFile: "CHANGELOG.md" }], ['@semantic-release/changelog', { changelogFile: 'CHANGELOG.md' }],
"@semantic-release/npm", '@semantic-release/npm',
[ [
"@semantic-release/git", '@semantic-release/git',
{ {
assets: ["CHANGELOG.md", "package.json", "pnpm-lock.yaml"], assets: ['CHANGELOG.md', 'package.json', 'pnpm-lock.yaml'],
message: "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}", message: 'chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}',
}, },
], ],
], ],
+9 -9
View File
@@ -14,12 +14,12 @@
* allows `warn` and `error`). * allows `warn` and `error`).
*/ */
export enum LogLevel { export enum LogLevel {
TRACE = "trace", TRACE = 'trace',
DEBUG = "debug", DEBUG = 'debug',
INFO = "info", INFO = 'info',
WARN = "warn", WARN = 'warn',
ERROR = "error", ERROR = 'error',
SILENT = "silent", SILENT = 'silent',
} }
/** /**
@@ -27,7 +27,7 @@ export enum LogLevel {
* explicitly or via runtime overrides. * explicitly or via runtime overrides.
*/ */
export enum LoggerEnvironment { export enum LoggerEnvironment {
DEVELOPMENT = "development", DEVELOPMENT = 'development',
STAGING = "staging", STAGING = 'staging',
PRODUCTION = "production", PRODUCTION = 'production',
} }
+13 -3
View File
@@ -11,6 +11,16 @@
* logger.info("started"); * logger.info("started");
* ``` * ```
*/ */
export * from "./logger"; export * from './constants';
export * from "./sinks/console"; export * from './logger';
export * from "./types"; export * from './sinks/console';
export type {
LogLevel,
LoggerEnvironment,
LoggerOptions,
Logger,
LogData,
LogCallOptions,
LogEvent,
LogSink,
} from './types';
+57 -57
View File
@@ -1,4 +1,4 @@
import { createConsoleSink } from "./sinks/console"; import { createConsoleSink } from './sinks/console';
import type { import type {
LogCallOptions, LogCallOptions,
LogEvent, LogEvent,
@@ -7,24 +7,24 @@ import type {
LoggerOptions, LoggerOptions,
LogLevel, LogLevel,
LogSink, LogSink,
} from "./types"; } from './types';
/** Duck-type for sinks created by `createSentrySink` (avoids importing the sentry entry). */ /** Duck-type for sinks created by `createSentrySink` (avoids importing the sentry entry). */
type SentrySinkLike = LogSink & { type SentrySinkLike = LogSink & {
kind: "sentry"; kind: 'sentry';
options: { logs?: boolean; logLevel?: LogLevel }; options: { logs?: boolean; logLevel?: LogLevel };
}; };
function isSentrySinkLike(sink: LogSink): sink is SentrySinkLike { function isSentrySinkLike(sink: LogSink): sink is SentrySinkLike {
return "kind" in sink && (sink as { kind?: unknown }).kind === "sentry"; return 'kind' in sink && (sink as { kind?: unknown }).kind === 'sentry';
} }
const LEVELS: readonly Exclude<LogLevel, "silent">[] = ["trace", "debug", "info", "warn", "error"]; const LEVELS: readonly Exclude<LogLevel, 'silent'>[] = ['trace', 'debug', 'info', 'warn', 'error'];
const LOG_CALL_OPTION_KEYS = new Set(["sentry", "suppressSentry"]); const LOG_CALL_OPTION_KEYS = new Set(['sentry', 'suppressSentry']);
/** Numeric rank for comparing levels; `silent` is above every emit level. */ /** Numeric rank for comparing levels; `silent` is above every emit level. */
const levelRank = (level: LogLevel): number => const levelRank = (level: LogLevel): number =>
level === "silent" ? Infinity : LEVELS.indexOf(level); level === 'silent' ? Infinity : LEVELS.indexOf(level);
/** /**
* Default {@link LogLevel} for a {@link LoggerEnvironment} when no override is set. * Default {@link LogLevel} for a {@link LoggerEnvironment} when no override is set.
@@ -33,7 +33,7 @@ const levelRank = (level: LogLevel): number =>
* @returns `trace` (development), `warn` (staging), or `error` (production). * @returns `trace` (development), `warn` (staging), or `error` (production).
*/ */
const defaultLevel = (environment: LoggerEnvironment): LogLevel => const defaultLevel = (environment: LoggerEnvironment): LogLevel =>
environment === "development" ? "trace" : environment === "staging" ? "warn" : "error"; environment === 'development' ? 'trace' : environment === 'staging' ? 'warn' : 'error';
/** /**
* Default Sentry Logs threshold when `createSentrySink(..., { logs: true })` is * Default Sentry Logs threshold when `createSentrySink(..., { logs: true })` is
@@ -43,7 +43,7 @@ const defaultLevel = (environment: LoggerEnvironment): LogLevel =>
* @returns `info` (staging) or `warn` (production). Development never sends. * @returns `info` (staging) or `warn` (production). Development never sends.
*/ */
export const defaultSentryLogLevel = (environment: LoggerEnvironment): LogLevel => export const defaultSentryLogLevel = (environment: LoggerEnvironment): LogLevel =>
environment === "staging" ? "info" : "warn"; environment === 'staging' ? 'info' : 'warn';
/** /**
* Parses a runtime logging override string used by browser session storage and * Parses a runtime logging override string used by browser session storage and
@@ -73,13 +73,13 @@ export function parseLoggingOverride(
value: string | null | undefined, value: string | null | undefined,
): { level: LogLevel; namespaces: readonly string[] } | undefined { ): { level: LogLevel; namespaces: readonly string[] } | undefined {
if (!value) return undefined; if (!value) return undefined;
const [rawLevel, rawNamespaces = ""] = value.trim().split(":", 2); const [rawLevel, rawNamespaces = ''] = value.trim().split(':', 2);
if (!(["trace", "debug", "info", "warn", "error", "silent"] as string[]).includes(rawLevel)) if (!(['trace', 'debug', 'info', 'warn', 'error', 'silent'] as string[]).includes(rawLevel))
return undefined; return undefined;
return { return {
level: rawLevel as LogLevel, level: rawLevel as LogLevel,
namespaces: rawNamespaces namespaces: rawNamespaces
.split(",") .split(',')
.map((item) => item.trim()) .map((item) => item.trim())
.filter(Boolean), .filter(Boolean),
}; };
@@ -91,7 +91,7 @@ export function parseLoggingOverride(
* @param value - Candidate last argument from a log call. * @param value - Candidate last argument from a log call.
*/ */
export function isLogCallOptions(value: unknown): value is LogCallOptions { export function isLogCallOptions(value: unknown): value is LogCallOptions {
if (!value || typeof value !== "object" || Array.isArray(value) || value instanceof Error) if (!value || typeof value !== 'object' || Array.isArray(value) || value instanceof Error)
return false; return false;
const keys = Object.keys(value); const keys = Object.keys(value);
return keys.length > 0 && keys.every((key) => LOG_CALL_OPTION_KEYS.has(key)); return keys.length > 0 && keys.every((key) => LOG_CALL_OPTION_KEYS.has(key));
@@ -119,7 +119,7 @@ function splitLogCallArguments(arguments_: readonly unknown[]): {
* @param provided - Optional storage; when omitted, uses `globalThis.sessionStorage`. * @param provided - Optional storage; when omitted, uses `globalThis.sessionStorage`.
* @returns A `getItem`-compatible storage, or `undefined` if unavailable. * @returns A `getItem`-compatible storage, or `undefined` if unavailable.
*/ */
function resolveStorage(provided?: Pick<Storage, "getItem">): Pick<Storage, "getItem"> | undefined { function resolveStorage(provided?: Pick<Storage, 'getItem'>): Pick<Storage, 'getItem'> | undefined {
if (provided) return provided; if (provided) return provided;
try { try {
return globalThis.sessionStorage; return globalThis.sessionStorage;
@@ -150,9 +150,9 @@ function resolveEnv(
* @param provided - Explicit runtime; when omitted, inferred from `globalThis.window`. * @param provided - Explicit runtime; when omitted, inferred from `globalThis.window`.
* @returns `"browser"` if `window` is defined, otherwise `"node"`. * @returns `"browser"` if `window` is defined, otherwise `"node"`.
*/ */
function resolveRuntime(provided?: "browser" | "node"): "browser" | "node" { function resolveRuntime(provided?: 'browser' | 'node'): 'browser' | 'node' {
if (provided) return provided; if (provided) return provided;
return typeof (globalThis as { window?: unknown }).window === "undefined" ? "node" : "browser"; return typeof (globalThis as { window?: unknown }).window === 'undefined' ? 'node' : 'browser';
} }
/** /**
@@ -184,7 +184,7 @@ function namespaceMatches(namespace: string | undefined, filters: readonly strin
*/ */
function evaluate(arguments_: readonly unknown[]): readonly unknown[] { function evaluate(arguments_: readonly unknown[]): readonly unknown[] {
return arguments_.map((argument, index) => return arguments_.map((argument, index) =>
index > 0 && typeof argument === "function" ? (argument as () => unknown)() : argument, index > 0 && typeof argument === 'function' ? (argument as () => unknown)() : argument,
); );
} }
@@ -192,8 +192,8 @@ function evaluate(arguments_: readonly unknown[]): readonly unknown[] {
function isThenable(value: unknown): value is PromiseLike<unknown> { function isThenable(value: unknown): value is PromiseLike<unknown> {
return ( return (
value !== null && value !== null &&
(typeof value === "object" || typeof value === "function") && (typeof value === 'object' || typeof value === 'function') &&
typeof (value as { then?: unknown }).then === "function" typeof (value as { then?: unknown }).then === 'function'
); );
} }
@@ -205,7 +205,7 @@ async function settleArguments(arguments_: readonly unknown[]): Promise<readonly
const settled = await Promise.allSettled( const settled = await Promise.allSettled(
arguments_.map((argument) => (isThenable(argument) ? argument : Promise.resolve(argument))), arguments_.map((argument) => (isThenable(argument) ? argument : Promise.resolve(argument))),
); );
return settled.map((result) => (result.status === "fulfilled" ? result.value : result.reason)); return settled.map((result) => (result.status === 'fulfilled' ? result.value : result.reason));
} }
function hasThenable(arguments_: readonly unknown[]): boolean { function hasThenable(arguments_: readonly unknown[]): boolean {
@@ -219,7 +219,7 @@ function hasThenable(arguments_: readonly unknown[]): boolean {
function resolveSentrySinkOptions( function resolveSentrySinkOptions(
options: LoggerOptions, options: LoggerOptions,
): { logs: boolean; logLevel: LogLevel } | undefined { ): { logs: boolean; logLevel: LogLevel } | undefined {
if (options.environment === "development") return undefined; if (options.environment === 'development') return undefined;
const candidate = options.sentry ? options.sentry : options.sinks?.find(isSentrySinkLike); const candidate = options.sentry ? options.sentry : options.sinks?.find(isSentrySinkLike);
if (!candidate) return undefined; if (!candidate) return undefined;
if (isSentrySinkLike(candidate)) { if (isSentrySinkLike(candidate)) {
@@ -271,15 +271,15 @@ function resolveSentrySinkOptions(
*/ */
export function createLogger(options: LoggerOptions): Logger { export function createLogger(options: LoggerOptions): Logger {
const namespace = const namespace =
typeof options.namespace === "string" typeof options.namespace === 'string'
? options.namespace ? options.namespace
: options.namespace?.filter(Boolean).join(":"); : options.namespace?.filter(Boolean).join(':');
const env = resolveEnv(options.env); const env = resolveEnv(options.env);
const runtimeOverride = const runtimeOverride =
parseLoggingOverride(resolveStorage(options.sessionStorage)?.getItem("showLoggingFor")) ?? parseLoggingOverride(resolveStorage(options.sessionStorage)?.getItem('showLoggingFor')) ??
parseLoggingOverride( parseLoggingOverride(
env.MIFI_LOG_LEVEL env.MIFI_LOG_LEVEL
? `${env.MIFI_LOG_LEVEL}${env.MIFI_LOG_NAMESPACES ? `:${env.MIFI_LOG_NAMESPACES}` : ""}` ? `${env.MIFI_LOG_LEVEL}${env.MIFI_LOG_NAMESPACES ? `:${env.MIFI_LOG_NAMESPACES}` : ''}`
: undefined, : undefined,
); );
const threshold = options.level ?? runtimeOverride?.level ?? defaultLevel(options.environment); const threshold = options.level ?? runtimeOverride?.level ?? defaultLevel(options.environment);
@@ -287,26 +287,26 @@ export function createLogger(options: LoggerOptions): Logger {
const sentrySinkOptions = resolveSentrySinkOptions(options); const sentrySinkOptions = resolveSentrySinkOptions(options);
const sentryActive = sentrySinkOptions !== undefined; const sentryActive = sentrySinkOptions !== undefined;
const sinks: readonly LogSink[] = options.sinks ?? [ const sinks: readonly LogSink[] = options.sinks ?? [
...(runtime === "node" || options.environment !== "production" || !options.sentry ...(runtime === 'node' || options.environment !== 'production' || !options.sentry
? [createConsoleSink()] ? [createConsoleSink()]
: []), : []),
...(options.sentry && options.environment !== "development" ? [options.sentry] : []), ...(options.sentry && options.environment !== 'development' ? [options.sentry] : []),
]; ];
const enabled = (level: Exclude<LogLevel, "silent">) => const enabled = (level: Exclude<LogLevel, 'silent'>) =>
levelRank(level) >= levelRank(threshold) && levelRank(level) >= levelRank(threshold) &&
(!runtimeOverride?.namespaces.length || (!runtimeOverride?.namespaces.length ||
namespaceMatches(namespace, runtimeOverride.namespaces)); namespaceMatches(namespace, runtimeOverride.namespaces));
const deliver = (event: LogEvent) => { const deliver = (event: LogEvent) => {
sinks.forEach((sink) => sink.emit(event)); sinks.forEach((sink) => sink.emit(event));
}; };
const emit = (level: Exclude<LogLevel, "silent">, arguments_: readonly unknown[]) => { const emit = (level: Exclude<LogLevel, 'silent'>, arguments_: readonly unknown[]) => {
const split = splitLogCallArguments(arguments_); const split = splitLogCallArguments(arguments_);
const sendToConsole = enabled(level); const sendToConsole = enabled(level);
const sendToSentryIssue = const sendToSentryIssue =
sentryActive && level === "error" && !split.options.suppressSentry; sentryActive && level === 'error' && !split.options.suppressSentry;
const sendToSentryLogs = const sendToSentryLogs =
sentryActive && sentryActive &&
level !== "error" && level !== 'error' &&
(Boolean(split.options.sentry) || (Boolean(split.options.sentry) ||
(Boolean(sentrySinkOptions?.logs) && (Boolean(sentrySinkOptions?.logs) &&
levelRank(level) >= levelRank(sentrySinkOptions.logLevel))); levelRank(level) >= levelRank(sentrySinkOptions.logLevel)));
@@ -333,14 +333,14 @@ export function createLogger(options: LoggerOptions): Logger {
}); });
}; };
const consoleMethod = ( const consoleMethod = (
method: keyof Console | "profile" | "profileEnd", method: keyof Console | 'profile' | 'profileEnd',
level: Exclude<LogLevel, "silent">, level: Exclude<LogLevel, 'silent'>,
arguments_: readonly unknown[], arguments_: readonly unknown[],
) => { ) => {
if (!enabled(level)) return; if (!enabled(level)) return;
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];
if (typeof fn !== "function") return; if (typeof fn !== 'function') return;
(fn as (...items: unknown[]) => void).apply( (fn as (...items: unknown[]) => void).apply(
console_, console_,
Array.from(prefix(namespace, evaluate(arguments_))), Array.from(prefix(namespace, evaluate(arguments_))),
@@ -350,30 +350,30 @@ export function createLogger(options: LoggerOptions): Logger {
namespace, namespace,
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),
debug: (...items) => emit("debug", items), debug: (...items) => emit('debug', items),
log: (...items) => emit("info", items), log: (...items) => emit('info', items),
info: (...items) => emit("info", items), info: (...items) => emit('info', items),
warn: (...items) => emit("warn", items), warn: (...items) => emit('warn', items),
error: (...items) => emit("error", items), error: (...items) => emit('error', items),
assert: (condition, ...items) => { assert: (condition, ...items) => {
if (!condition) emit("error", ["Assertion failed", ...items]); if (!condition) emit('error', ['Assertion failed', ...items]);
}, },
group: (...items) => consoleMethod("group", "info", items), group: (...items) => consoleMethod('group', 'info', items),
groupCollapsed: (...items) => consoleMethod("groupCollapsed", "info", items), groupCollapsed: (...items) => consoleMethod('groupCollapsed', 'info', items),
groupEnd: () => consoleMethod("groupEnd", "info", []), groupEnd: () => consoleMethod('groupEnd', 'info', []),
dir: (item, options_) => consoleMethod("dir", "info", [item, options_]), dir: (item, options_) => consoleMethod('dir', 'info', [item, options_]),
dirxml: (...items) => consoleMethod("dirxml", "info", items), dirxml: (...items) => consoleMethod('dirxml', 'info', items),
table: (data, properties) => consoleMethod("table", "info", [data, properties]), table: (data, properties) => consoleMethod('table', 'info', [data, properties]),
clear: () => consoleMethod("clear", "info", []), clear: () => consoleMethod('clear', 'info', []),
count: (label) => consoleMethod("count", "info", [label]), count: (label) => consoleMethod('count', 'info', [label]),
countReset: (label) => consoleMethod("countReset", "info", [label]), countReset: (label) => consoleMethod('countReset', 'info', [label]),
time: (label) => consoleMethod("time", "info", [label]), time: (label) => consoleMethod('time', 'info', [label]),
timeLog: (label, ...items) => consoleMethod("timeLog", "info", [label, ...items]), timeLog: (label, ...items) => consoleMethod('timeLog', 'info', [label, ...items]),
timeEnd: (label) => consoleMethod("timeEnd", "info", [label]), timeEnd: (label) => consoleMethod('timeEnd', 'info', [label]),
timeStamp: (label) => consoleMethod("timeStamp", "info", [label]), timeStamp: (label) => consoleMethod('timeStamp', 'info', [label]),
profile: (label) => consoleMethod("profile", "info", [label]), profile: (label) => consoleMethod('profile', 'info', [label]),
profileEnd: (label) => consoleMethod("profileEnd", "info", [label]), profileEnd: (label) => consoleMethod('profileEnd', 'info', [label]),
}; };
return api; return api;
} }
@@ -386,5 +386,5 @@ export function createLogger(options: LoggerOptions): Logger {
* @returns Arguments with `[A][B]` prefix when namespaced. * @returns Arguments with `[A][B]` prefix when namespaced.
*/ */
function prefix(namespace: string | undefined, arguments_: readonly unknown[]): readonly unknown[] { function prefix(namespace: string | undefined, arguments_: readonly unknown[]): readonly unknown[] {
return namespace ? [`[${namespace.split(":").join("][")}]`, ...arguments_] : arguments_; return namespace ? [`[${namespace.split(':').join('][')}]`, ...arguments_] : arguments_;
} }
+1 -1
View File
@@ -1,2 +1,2 @@
/** Public entry for `@mifi/logger/sentry`. Implementation: `src/sinks/sentry.ts`. */ /** Public entry for `@mifi/logger/sentry`. Implementation: `src/sinks/sentry.ts`. */
export * from "./sinks/sentry"; export * from './sinks/sentry';
+19 -19
View File
@@ -1,24 +1,24 @@
import type { LogLevel, LogSink } from "../types"; import type { LogLevel, LogSink } from '../types';
const ANSI_RESET = "\u001B[0m"; const ANSI_RESET = '\u001B[0m';
const ANSI_BY_LEVEL: Record<Exclude<LogLevel, "silent">, string> = { const ANSI_BY_LEVEL: Record<Exclude<LogLevel, 'silent'>, string> = {
trace: "\u001B[90m", trace: '\u001B[90m',
debug: "\u001B[36m", debug: '\u001B[36m',
info: "\u001B[32m", info: '\u001B[32m',
warn: "\u001B[33m", warn: '\u001B[33m',
error: "\u001B[31m", error: '\u001B[31m',
}; };
const BROWSER_STYLE_BY_LEVEL: Record<Exclude<LogLevel, "silent">, string> = { const BROWSER_STYLE_BY_LEVEL: Record<Exclude<LogLevel, 'silent'>, string> = {
trace: "color: #6b7280; font-weight: 600", trace: 'color: #6b7280; font-weight: 600',
debug: "color: #0891b2; font-weight: 600", debug: 'color: #0891b2; font-weight: 600',
info: "color: #15803d; font-weight: 600", info: 'color: #15803d; font-weight: 600',
warn: "color: #a16207; font-weight: 700", warn: 'color: #a16207; font-weight: 700',
error: "color: #dc2626; font-weight: 700", error: 'color: #dc2626; font-weight: 700',
}; };
/** Whether the current global looks like a browser (`window` is defined). */ /** Whether the current global looks like a browser (`window` is defined). */
function isBrowser(): boolean { function isBrowser(): boolean {
return typeof (globalThis as { window?: unknown }).window !== "undefined"; return typeof (globalThis as { window?: unknown }).window !== 'undefined';
} }
/** Whether Node stdout is a TTY that can display ANSI colors. */ /** Whether Node stdout is a TTY that can display ANSI colors. */
@@ -52,13 +52,13 @@ export function createConsoleSink(): LogSink {
return { return {
emit: (event) => { emit: (event) => {
if (!event.sendToConsole) return; 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];
if (typeof fn !== "function") return; if (typeof fn !== 'function') return;
const labelBase = event.namespace const labelBase = event.namespace
? `[${event.namespace.split(":").join("][")}]` ? `[${event.namespace.split(':').join('][')}]`
: "[LOG]"; : '[LOG]';
const label = event.async ? `${labelBase}[async]` : labelBase; const label = event.async ? `${labelBase}[async]` : labelBase;
const arguments_ = isBrowser() const arguments_ = isBrowser()
? [`%c${label}`, BROWSER_STYLE_BY_LEVEL[event.level], ...event.arguments] ? [`%c${label}`, BROWSER_STYLE_BY_LEVEL[event.level], ...event.arguments]
+16 -16
View File
@@ -1,10 +1,10 @@
import type { LogEvent, LogLevel, LogSink } from "../types"; import type { LogEvent, LogLevel, LogSink } from '../types';
/** /**
* Sentry Issue severity levels (`@sentry/core` `SeverityLevel`). * Sentry Issue severity levels (`@sentry/core` `SeverityLevel`).
* Note: Issues use `"warning"`; Logs use `"warn"`. * Note: Issues use `"warning"`; Logs use `"warn"`.
*/ */
export type SentrySeverityLevel = "fatal" | "error" | "warning" | "log" | "info" | "debug"; export type SentrySeverityLevel = 'fatal' | 'error' | 'warning' | 'log' | 'info' | 'debug';
/** /**
* Options for {@link createSentrySink}. * Options for {@link createSentrySink}.
@@ -102,7 +102,7 @@ export interface SentryLike {
/** Sink returned by {@link createSentrySink}. */ /** Sink returned by {@link createSentrySink}. */
export interface SentrySink extends LogSink { export interface SentrySink extends LogSink {
readonly kind: "sentry"; readonly kind: 'sentry';
readonly options: SentrySinkOptions; readonly options: SentrySinkOptions;
} }
@@ -112,7 +112,7 @@ export interface SentrySink extends LogSink {
* @param sink - Any {@link LogSink}. * @param sink - Any {@link LogSink}.
*/ */
export function isSentrySink(sink: LogSink): sink is SentrySink { export function isSentrySink(sink: LogSink): sink is SentrySink {
return "kind" in sink && (sink as { kind?: unknown }).kind === "sentry"; return 'kind' in sink && (sink as { kind?: unknown }).kind === 'sentry';
} }
/** /**
@@ -124,14 +124,14 @@ export function isSentrySink(sink: LogSink): sink is SentrySink {
* *
* @param level - Logger emit level (never `silent`). * @param level - Logger emit level (never `silent`).
*/ */
export function toSentrySeverity(level: Exclude<LogLevel, "silent">): SentrySeverityLevel { export function toSentrySeverity(level: Exclude<LogLevel, 'silent'>): SentrySeverityLevel {
if (level === "warn") return "warning"; if (level === 'warn') return 'warning';
if (level === "trace") return "debug"; if (level === 'trace') return 'debug';
return level; return level;
} }
function isAttributeValue(value: unknown): value is string | number | boolean { function isAttributeValue(value: unknown): value is string | number | boolean {
return typeof value === "string" || typeof value === "number" || typeof value === "boolean"; return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean';
} }
/** /**
@@ -144,12 +144,12 @@ export function toSentryLogPayload(event: LogEvent): {
attributes: Record<string, string | number | boolean>; attributes: Record<string, string | number | boolean>;
} { } {
const message = const message =
event.arguments.filter((item) => typeof item === "string").join(" ") || "Log event"; event.arguments.filter((item) => typeof item === 'string').join(' ') || 'Log event';
const attributes: Record<string, string | number | boolean> = {}; const attributes: Record<string, string | number | boolean> = {};
if (event.namespace) attributes["logger.namespace"] = event.namespace; if (event.namespace) attributes['logger.namespace'] = event.namespace;
if (event.async) attributes.async = true; if (event.async) attributes.async = true;
for (const item of event.arguments) { for (const item of event.arguments) {
if (!item || typeof item !== "object" || item instanceof Error || Array.isArray(item)) if (!item || typeof item !== 'object' || item instanceof Error || Array.isArray(item))
continue; continue;
for (const [key, value] of Object.entries(item as Record<string, unknown>)) { for (const [key, value] of Object.entries(item as Record<string, unknown>)) {
if (isAttributeValue(value)) attributes[key] = value; if (isAttributeValue(value)) attributes[key] = value;
@@ -198,17 +198,17 @@ export function toSentryLogPayload(event: LogEvent): {
*/ */
export function createSentrySink(sentry: SentryLike, options: SentrySinkOptions = {}): SentrySink { export function createSentrySink(sentry: SentryLike, options: SentrySinkOptions = {}): SentrySink {
return { return {
kind: "sentry", kind: 'sentry',
options, options,
emit(event: LogEvent) { emit(event: LogEvent) {
if (event.environment === "development") return; if (event.environment === 'development') return;
if (event.sendToSentryIssue) { if (event.sendToSentryIssue) {
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 { message, attributes } = toSentryLogPayload(event); const { message, attributes } = toSentryLogPayload(event);
const severity = toSentrySeverity(event.level); const severity = toSentrySeverity(event.level);
sentry.withScope((scope) => { sentry.withScope((scope) => {
scope.setLevel(severity); scope.setLevel(severity);
if (event.namespace) scope.setTag("logger.namespace", event.namespace); if (event.namespace) scope.setTag('logger.namespace', event.namespace);
scope.setExtras(attributes); scope.setExtras(attributes);
if (error) sentry.captureException(error); if (error) sentry.captureException(error);
else sentry.captureMessage(message, severity); else sentry.captureMessage(message, severity);
@@ -216,9 +216,9 @@ export function createSentrySink(sentry: SentryLike, options: SentrySinkOptions
return; return;
} }
if (!event.sendToSentryLogs || !sentry.logger) return; if (!event.sendToSentryLogs || !sentry.logger) return;
const method = event.level === "trace" ? "trace" : event.level; const method = event.level === 'trace' ? 'trace' : event.level;
const log = sentry.logger[method]; const log = sentry.logger[method];
if (typeof log !== "function") return; if (typeof log !== 'function') return;
const { message, attributes } = toSentryLogPayload(event); const { message, attributes } = toSentryLogPayload(event);
log.call(sentry.logger, message, attributes); log.call(sentry.logger, message, attributes);
}, },
+4 -4
View File
@@ -1,4 +1,4 @@
import { LogLevel as LogLevelEnum, LoggerEnvironment as LoggerEnvironmentEnum } from "./constants"; import { LogLevel as LogLevelEnum, LoggerEnvironment as LoggerEnvironmentEnum } from './constants';
/** /**
* Severity threshold for log filtering, ordered from most to least verbose. * Severity threshold for log filtering, ordered from most to least verbose.
@@ -70,7 +70,7 @@ export interface LogCallOptions {
*/ */
export interface LogEvent { export interface LogEvent {
/** Severity of this event. Never `silent`. */ /** Severity of this event. Never `silent`. */
level: Exclude<LogLevel, "silent">; level: Exclude<LogLevel, 'silent'>;
/** /**
* Colon-joined namespace for this logger (e.g. `"WIDGET:Button"`). * Colon-joined namespace for this logger (e.g. `"WIDGET:Button"`).
* Omitted when the logger was created without a namespace. * Omitted when the logger was created without a namespace.
@@ -165,7 +165,7 @@ export interface LoggerOptions {
* Runtime used for the default destination policy (console vs Sentry-only * Runtime used for the default destination policy (console vs Sentry-only
* in production browsers). Inferred from `globalThis.window` when omitted. * in production browsers). Inferred from `globalThis.window` when omitted.
*/ */
runtime?: "browser" | "node"; runtime?: 'browser' | 'node';
/** /**
* Hard level override. Takes precedence over session storage, env vars, * Hard level override. Takes precedence over session storage, env vars,
* and environment defaults. * and environment defaults.
@@ -178,7 +178,7 @@ export interface LoggerOptions {
* Expected value format matches {@link parseLoggingOverride}: * Expected value format matches {@link parseLoggingOverride}:
* `debug` or `debug:WIDGET,API`. * `debug` or `debug:WIDGET,API`.
*/ */
sessionStorage?: Pick<Storage, "getItem">; sessionStorage?: Pick<Storage, 'getItem'>;
/** /**
* Node/container environment variable map. Defaults to `process.env` * Node/container environment variable map. Defaults to `process.env`
* when available. * when available.
+15 -15
View File
@@ -1,14 +1,14 @@
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from 'vitest';
import { createConsoleSink } from "../src/index"; import { createConsoleSink } from '../src/index';
import type { LogEvent } from "../src/index"; import type { LogEvent } from '../src/index';
function event(overrides: Partial<LogEvent> = {}): LogEvent { function event(overrides: Partial<LogEvent> = {}): LogEvent {
return { return {
level: "debug", level: 'debug',
namespace: "API", namespace: 'API',
arguments: ["hello"], arguments: ['hello'],
timestamp: new Date(), timestamp: new Date(),
environment: "development", environment: 'development',
sendToSentryLogs: false, sendToSentryLogs: false,
sendToSentryIssue: false, sendToSentryIssue: false,
sendToConsole: true, sendToConsole: true,
@@ -16,25 +16,25 @@ function event(overrides: Partial<LogEvent> = {}): LogEvent {
}; };
} }
describe("createConsoleSink", () => { describe('createConsoleSink', () => {
afterEach(() => { afterEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
it("appends [async] to the namespace label when event.async is set", () => { it('appends [async] to the namespace label when event.async is set', () => {
const debug = vi.spyOn(console, "debug").mockImplementation(() => undefined); const debug = vi.spyOn(console, 'debug').mockImplementation(() => undefined);
createConsoleSink().emit(event({ async: true })); createConsoleSink().emit(event({ async: true }));
expect(debug).toHaveBeenCalled(); expect(debug).toHaveBeenCalled();
const first = debug.mock.calls[0]?.[0]; const first = debug.mock.calls[0]?.[0];
expect(String(first)).toContain("[API][async]"); expect(String(first)).toContain('[API][async]');
}); });
it("omits [async] for synchronous events", () => { it('omits [async] for synchronous events', () => {
const debug = vi.spyOn(console, "debug").mockImplementation(() => undefined); const debug = vi.spyOn(console, 'debug').mockImplementation(() => undefined);
createConsoleSink().emit(event()); createConsoleSink().emit(event());
expect(debug).toHaveBeenCalled(); expect(debug).toHaveBeenCalled();
const first = debug.mock.calls[0]?.[0]; const first = debug.mock.calls[0]?.[0];
expect(String(first)).toContain("[API]"); expect(String(first)).toContain('[API]');
expect(String(first)).not.toContain("[async]"); expect(String(first)).not.toContain('[async]');
}); });
}); });
+96 -96
View File
@@ -1,127 +1,127 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from 'vitest';
import { createLogger, isLogCallOptions, parseLoggingOverride } from "../src/index"; import { createLogger, isLogCallOptions, parseLoggingOverride } from '../src/index';
import type { LogEvent, LogSink } from "../src/index"; import type { LogEvent, LogSink } from '../src/index';
import { createSentrySink } from "../src/sentry"; import { createSentrySink } from '../src/sentry';
function sink(): { sink: LogSink; events: LogEvent[] } { function sink(): { sink: LogSink; events: LogEvent[] } {
const events: LogEvent[] = []; const events: LogEvent[] = [];
return { events, sink: { emit: (event) => events.push(event) } }; return { events, sink: { emit: (event) => events.push(event) } };
} }
describe("parseLoggingOverride", () => { describe('parseLoggingOverride', () => {
it("accepts a global level and a namespace-filtered level", () => { it('accepts a global level and a namespace-filtered level', () => {
expect(parseLoggingOverride("debug")).toEqual({ level: "debug", namespaces: [] }); expect(parseLoggingOverride('debug')).toEqual({ level: 'debug', namespaces: [] });
expect(parseLoggingOverride("debug:WIDGET, API")).toEqual({ expect(parseLoggingOverride('debug:WIDGET, API')).toEqual({
level: "debug", level: 'debug',
namespaces: ["WIDGET", "API"], namespaces: ['WIDGET', 'API'],
}); });
expect(parseLoggingOverride("verbose")).toBeUndefined(); expect(parseLoggingOverride('verbose')).toBeUndefined();
}); });
}); });
describe("isLogCallOptions", () => { describe('isLogCallOptions', () => {
it("accepts only known option keys", () => { it('accepts only known option keys', () => {
expect(isLogCallOptions({ sentry: true })).toBe(true); expect(isLogCallOptions({ sentry: true })).toBe(true);
expect(isLogCallOptions({ suppressSentry: true })).toBe(true); expect(isLogCallOptions({ suppressSentry: true })).toBe(true);
expect(isLogCallOptions({ sentry: true, suppressSentry: false })).toBe(true); expect(isLogCallOptions({ sentry: true, suppressSentry: false })).toBe(true);
expect(isLogCallOptions({ requestId: "req_1" })).toBe(false); expect(isLogCallOptions({ requestId: 'req_1' })).toBe(false);
expect(isLogCallOptions({ sentry: true, requestId: "req_1" })).toBe(false); expect(isLogCallOptions({ sentry: true, requestId: 'req_1' })).toBe(false);
expect(isLogCallOptions({})).toBe(false); expect(isLogCallOptions({})).toBe(false);
}); });
}); });
describe("createLogger", () => { describe('createLogger', () => {
it("uses environment defaults and never evaluates suppressed lazy data", () => { it('uses environment defaults and never evaluates suppressed lazy data', () => {
const destination = sink(); const destination = sink();
const expensive = vi.fn(() => ({ huge: "snapshot" })); const expensive = vi.fn(() => ({ huge: 'snapshot' }));
const logger = createLogger({ environment: "production", sinks: [destination.sink] }); const logger = createLogger({ environment: 'production', sinks: [destination.sink] });
logger.debug("ignored", expensive); logger.debug('ignored', expensive);
logger.error("kept", expensive); logger.error('kept', expensive);
expect(expensive).toHaveBeenCalledTimes(1); expect(expensive).toHaveBeenCalledTimes(1);
expect(destination.events).toHaveLength(1); expect(destination.events).toHaveLength(1);
expect(destination.events[0]?.arguments).toEqual(["kept", { huge: "snapshot" }]); expect(destination.events[0]?.arguments).toEqual(['kept', { huge: 'snapshot' }]);
expect(destination.events[0]?.async).toBeUndefined(); expect(destination.events[0]?.async).toBeUndefined();
}); });
it("never starts suppressed async lazy factories", () => { it('never starts suppressed async lazy factories', () => {
const destination = sink(); const destination = sink();
const expensive = vi.fn(async () => ({ huge: "snapshot" })); const expensive = vi.fn(async () => ({ huge: 'snapshot' }));
const logger = createLogger({ environment: "production", sinks: [destination.sink] }); const logger = createLogger({ environment: 'production', sinks: [destination.sink] });
logger.debug("ignored", expensive); logger.debug('ignored', expensive);
expect(expensive).not.toHaveBeenCalled(); expect(expensive).not.toHaveBeenCalled();
expect(destination.events).toHaveLength(0); expect(destination.events).toHaveLength(0);
}); });
it("settles async lazy factories before emitting and sets async", async () => { it('settles async lazy factories before emitting and sets async', async () => {
const destination = sink(); const destination = sink();
const factory = vi.fn(async () => ({ data: 1 })); const factory = vi.fn(async () => ({ data: 1 }));
const logger = createLogger({ environment: "development", sinks: [destination.sink] }); const logger = createLogger({ environment: 'development', sinks: [destination.sink] });
logger.debug("response", factory); logger.debug('response', factory);
expect(factory).toHaveBeenCalledTimes(1); expect(factory).toHaveBeenCalledTimes(1);
expect(destination.events).toHaveLength(0); expect(destination.events).toHaveLength(0);
await vi.waitFor(() => expect(destination.events).toHaveLength(1)); await vi.waitFor(() => expect(destination.events).toHaveLength(1));
expect(destination.events[0]).toMatchObject({ expect(destination.events[0]).toMatchObject({
arguments: ["response", { data: 1 }], arguments: ['response', { data: 1 }],
async: true, async: true,
}); });
}); });
it("unwraps direct Promise arguments and keeps rejection reasons", async () => { it('unwraps direct Promise arguments and keeps rejection reasons', async () => {
const destination = sink(); const destination = sink();
const reason = new Error("boom"); const reason = new Error('boom');
const logger = createLogger({ environment: "development", sinks: [destination.sink] }); const logger = createLogger({ environment: 'development', sinks: [destination.sink] });
logger.error("failed", { id: 1 }, Promise.reject(reason)); logger.error('failed', { id: 1 }, Promise.reject(reason));
expect(destination.events).toHaveLength(0); expect(destination.events).toHaveLength(0);
await vi.waitFor(() => expect(destination.events).toHaveLength(1)); await vi.waitFor(() => expect(destination.events).toHaveLength(1));
expect(destination.events[0]).toMatchObject({ expect(destination.events[0]).toMatchObject({
arguments: ["failed", { id: 1 }, reason], arguments: ['failed', { id: 1 }, reason],
async: true, async: true,
}); });
}); });
it("applies session namespace filtering to namespace descendants", () => { it('applies session namespace filtering to namespace descendants', () => {
const destination = sink(); const destination = sink();
const storage = { getItem: () => "debug:WIDGET" }; const storage = { getItem: () => 'debug:WIDGET' };
createLogger({ createLogger({
environment: "production", environment: 'production',
namespace: "WIDGET:Button", namespace: 'WIDGET:Button',
sessionStorage: storage, sessionStorage: storage,
sinks: [destination.sink], sinks: [destination.sink],
}).debug("shown"); }).debug('shown');
createLogger({ createLogger({
environment: "production", environment: 'production',
namespace: "API", namespace: 'API',
sessionStorage: storage, sessionStorage: storage,
sinks: [destination.sink], sinks: [destination.sink],
}).debug("hidden"); }).debug('hidden');
expect(destination.events.map((event) => event.arguments[0])).toEqual(["shown"]); expect(destination.events.map((event) => event.arguments[0])).toEqual(['shown']);
}); });
it("builds immutable child namespaces and reports failed assertions as errors", () => { it('builds immutable child namespaces and reports failed assertions as errors', () => {
const destination = sink(); const destination = sink();
const root = createLogger({ const root = createLogger({
environment: "development", environment: 'development',
namespace: "WIDGET", namespace: 'WIDGET',
sinks: [destination.sink], sinks: [destination.sink],
}); });
root.child("Button").assert(false, "missing label"); root.child('Button').assert(false, 'missing label');
expect(root.namespace).toBe("WIDGET"); expect(root.namespace).toBe('WIDGET');
expect(destination.events[0]).toMatchObject({ expect(destination.events[0]).toMatchObject({
level: "error", level: 'error',
namespace: "WIDGET:Button", namespace: 'WIDGET:Button',
arguments: ["Assertion failed", "missing label"], arguments: ['Assertion failed', 'missing label'],
}); });
}); });
it("sends production browser errors to Sentry without writing to the console", () => { it('sends production browser errors to Sentry without writing to the console', () => {
const destination = sink(); const destination = sink();
const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const logger = createLogger({ const logger = createLogger({
environment: "production", environment: 'production',
runtime: "browser", runtime: 'browser',
sentry: destination.sink, sentry: destination.sink,
}); });
logger.error("captured"); logger.error('captured');
expect(destination.events).toHaveLength(1); expect(destination.events).toHaveLength(1);
expect(destination.events[0]).toMatchObject({ expect(destination.events[0]).toMatchObject({
sendToSentryIssue: true, sendToSentryIssue: true,
@@ -132,19 +132,19 @@ describe("createLogger", () => {
consoleError.mockRestore(); consoleError.mockRestore();
}); });
it("forces selected events to Sentry Logs without writing them to the console", () => { it('forces selected events to Sentry Logs without writing them to the console', () => {
const destination = sink(); const destination = sink();
const consoleInfo = vi.spyOn(console, "info").mockImplementation(() => undefined); const consoleInfo = vi.spyOn(console, 'info').mockImplementation(() => undefined);
const logger = createLogger({ const logger = createLogger({
environment: "production", environment: 'production',
runtime: "browser", runtime: 'browser',
sentry: destination.sink, sentry: destination.sink,
}); });
logger.info("Cache warmed", { sentry: true }); logger.info('Cache warmed', { sentry: true });
expect(destination.events).toHaveLength(1); expect(destination.events).toHaveLength(1);
expect(destination.events[0]).toMatchObject({ expect(destination.events[0]).toMatchObject({
level: "info", level: 'info',
arguments: ["Cache warmed"], arguments: ['Cache warmed'],
sendToSentryLogs: true, sendToSentryLogs: true,
sendToSentryIssue: false, sendToSentryIssue: false,
sendToConsole: false, sendToConsole: false,
@@ -153,42 +153,42 @@ describe("createLogger", () => {
consoleInfo.mockRestore(); consoleInfo.mockRestore();
}); });
it("strips trailing call options from event arguments and supports data plus options", () => { it('strips trailing call options from event arguments and supports data plus options', () => {
const destination = sink(); const destination = sink();
const logger = createLogger({ const logger = createLogger({
environment: "production", environment: 'production',
runtime: "browser", runtime: 'browser',
sentry: destination.sink, sentry: destination.sink,
}); });
logger.warn("slow", { ms: 1200 }, { sentry: true }); logger.warn('slow', { ms: 1200 }, { sentry: true });
expect(destination.events[0]).toMatchObject({ expect(destination.events[0]).toMatchObject({
level: "warn", level: 'warn',
arguments: ["slow", { ms: 1200 }], arguments: ['slow', { ms: 1200 }],
sendToSentryLogs: true, sendToSentryLogs: true,
sendToConsole: false, sendToConsole: false,
}); });
}); });
it("suppresses Sentry Issues when suppressSentry is set", () => { it('suppresses Sentry Issues when suppressSentry is set', () => {
const destination = sink(); const destination = sink();
const logger = createLogger({ const logger = createLogger({
environment: "production", environment: 'production',
runtime: "browser", runtime: 'browser',
sentry: destination.sink, sentry: destination.sink,
}); });
logger.error("expected", new Error("nope"), { suppressSentry: true }); logger.error('expected', new Error('nope'), { suppressSentry: true });
expect(destination.events).toHaveLength(1); expect(destination.events).toHaveLength(1);
expect(destination.events[0]).toMatchObject({ expect(destination.events[0]).toMatchObject({
sendToSentryIssue: false, sendToSentryIssue: false,
sendToSentryLogs: false, sendToSentryLogs: false,
arguments: ["expected", expect.any(Error)], arguments: ['expected', expect.any(Error)],
}); });
}); });
it("never marks events for Sentry in development", () => { it('never marks events for Sentry in development', () => {
const destination = sink(); const destination = sink();
const logger = createLogger({ const logger = createLogger({
environment: "development", environment: 'development',
sinks: [ sinks: [
destination.sink, destination.sink,
createSentrySink( createSentrySink(
@@ -213,8 +213,8 @@ describe("createLogger", () => {
), ),
], ],
}); });
logger.error("local only"); logger.error('local only');
logger.info("probe", { sentry: true }); logger.info('probe', { sentry: true });
expect(destination.events).toHaveLength(2); expect(destination.events).toHaveLength(2);
expect( expect(
destination.events.every( destination.events.every(
@@ -223,25 +223,25 @@ describe("createLogger", () => {
).toBe(true); ).toBe(true);
}); });
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);
const logger = createLogger({ const logger = createLogger({
environment: "production", environment: 'production',
runtime: "node", runtime: 'node',
sentry: destination.sink, sentry: destination.sink,
}); });
logger.error("captured"); logger.error('captured');
expect(destination.events).toHaveLength(1); expect(destination.events).toHaveLength(1);
expect(consoleError).toHaveBeenCalledTimes(1); expect(consoleError).toHaveBeenCalledTimes(1);
consoleError.mockRestore(); consoleError.mockRestore();
}); });
it("mirrors warn to Sentry Logs in production when logs are enabled on the sink", () => { it('mirrors warn to Sentry Logs in production when logs are enabled on the sink', () => {
const destination = sink(); const destination = sink();
const logger = createLogger({ const logger = createLogger({
environment: "production", environment: 'production',
runtime: "browser", runtime: 'browser',
sinks: [ sinks: [
destination.sink, destination.sink,
createSentrySink( createSentrySink(
@@ -261,21 +261,21 @@ describe("createLogger", () => {
), ),
], ],
}); });
logger.warn("noisy"); logger.warn('noisy');
logger.info("below default production logs threshold"); logger.info('below default production logs threshold');
expect(destination.events).toHaveLength(1); expect(destination.events).toHaveLength(1);
expect(destination.events[0]).toMatchObject({ expect(destination.events[0]).toMatchObject({
level: "warn", level: 'warn',
sendToSentryLogs: true, sendToSentryLogs: true,
sendToConsole: false, sendToConsole: false,
}); });
}); });
it("mirrors info to Sentry Logs in staging when logs are enabled", () => { it('mirrors info to Sentry Logs in staging when logs are enabled', () => {
const destination = sink(); const destination = sink();
const logger = createLogger({ const logger = createLogger({
environment: "staging", environment: 'staging',
runtime: "browser", runtime: 'browser',
sinks: [ sinks: [
destination.sink, destination.sink,
createSentrySink( createSentrySink(
@@ -295,10 +295,10 @@ describe("createLogger", () => {
), ),
], ],
}); });
logger.info("visible in staging logs"); logger.info('visible in staging logs');
logger.debug("still below staging logs threshold"); logger.debug('still below staging logs threshold');
expect(destination.events.map((event) => event.arguments[0])).toEqual([ expect(destination.events.map((event) => event.arguments[0])).toEqual([
"visible in staging logs", 'visible in staging logs',
]); ]);
expect(destination.events[0]).toMatchObject({ expect(destination.events[0]).toMatchObject({
sendToSentryLogs: true, sendToSentryLogs: true,
+2 -2
View File
@@ -2,8 +2,8 @@
* Compile-time regression: official Sentry SDKs must assign to {@link SentryLike} * Compile-time regression: official Sentry SDKs must assign to {@link SentryLike}
* without casts or consumer adapters. Checked by `pnpm check` (`tsc --noEmit`). * without casts or consumer adapters. Checked by `pnpm check` (`tsc --noEmit`).
*/ */
import * as Sentry from "@sentry/node"; import * as Sentry from '@sentry/node';
import { createSentrySink, type SentryLike } from "../src/sentry"; import { createSentrySink, type SentryLike } from '../src/sentry';
const _sdk: SentryLike = Sentry; const _sdk: SentryLike = Sentry;
createSentrySink(Sentry); createSentrySink(Sentry);
+69 -69
View File
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from 'vitest';
import { createLogger, type LogEvent } from "../src/index"; import { createLogger, type LogEvent } from '../src/index';
import { createSentrySink, toSentryLogPayload, toSentrySeverity } from "../src/sentry"; import { createSentrySink, toSentryLogPayload, toSentrySeverity } from '../src/sentry';
function createFakeSentry() { function createFakeSentry() {
const scope = { setLevel: vi.fn(), setTag: vi.fn(), setExtras: vi.fn() }; const scope = { setLevel: vi.fn(), setTag: vi.fn(), setExtras: vi.fn() };
@@ -23,10 +23,10 @@ function createFakeSentry() {
function issueEvent(overrides: Partial<LogEvent> = {}): LogEvent { function issueEvent(overrides: Partial<LogEvent> = {}): LogEvent {
return { return {
level: "error", level: 'error',
arguments: ["message"], arguments: ['message'],
timestamp: new Date(), timestamp: new Date(),
environment: "production", environment: 'production',
sendToSentryLogs: false, sendToSentryLogs: false,
sendToSentryIssue: true, sendToSentryIssue: true,
sendToConsole: false, sendToConsole: false,
@@ -34,46 +34,46 @@ function issueEvent(overrides: Partial<LogEvent> = {}): LogEvent {
}; };
} }
describe("toSentrySeverity", () => { describe('toSentrySeverity', () => {
it("maps logger levels to Sentry Issue severities", () => { it('maps logger levels to Sentry Issue severities', () => {
expect(toSentrySeverity("warn")).toBe("warning"); expect(toSentrySeverity('warn')).toBe('warning');
expect(toSentrySeverity("trace")).toBe("debug"); expect(toSentrySeverity('trace')).toBe('debug');
expect(toSentrySeverity("error")).toBe("error"); expect(toSentrySeverity('error')).toBe('error');
expect(toSentrySeverity("info")).toBe("info"); expect(toSentrySeverity('info')).toBe('info');
expect(toSentrySeverity("debug")).toBe("debug"); expect(toSentrySeverity('debug')).toBe('debug');
}); });
}); });
describe("toSentryLogPayload", () => { describe('toSentryLogPayload', () => {
it("joins string messages and flattens primitive object attributes", () => { it('joins string messages and flattens primitive object attributes', () => {
expect( expect(
toSentryLogPayload({ toSentryLogPayload({
level: "info", level: 'info',
namespace: "API", namespace: 'API',
arguments: ["Cache warmed", { entries: 42, ok: true, nested: { a: 1 } }], arguments: ['Cache warmed', { entries: 42, ok: true, nested: { a: 1 } }],
timestamp: new Date(), timestamp: new Date(),
environment: "production", environment: 'production',
sendToSentryLogs: true, sendToSentryLogs: true,
sendToSentryIssue: false, sendToSentryIssue: false,
sendToConsole: false, sendToConsole: false,
}), }),
).toEqual({ ).toEqual({
message: "Cache warmed", message: 'Cache warmed',
attributes: { attributes: {
"logger.namespace": "API", 'logger.namespace': 'API',
entries: 42, entries: 42,
ok: true, ok: true,
}, },
}); });
}); });
it("includes async when the event was deferred for thenables", () => { it('includes async when the event was deferred for thenables', () => {
expect( expect(
toSentryLogPayload({ toSentryLogPayload({
level: "debug", level: 'debug',
arguments: ["response"], arguments: ['response'],
timestamp: new Date(), timestamp: new Date(),
environment: "development", environment: 'development',
sendToSentryLogs: true, sendToSentryLogs: true,
sendToSentryIssue: false, sendToSentryIssue: false,
sendToConsole: true, sendToConsole: true,
@@ -83,112 +83,112 @@ describe("toSentryLogPayload", () => {
}); });
}); });
describe("createSentrySink", () => { describe('createSentrySink', () => {
it("captures errors as Issues with namespace and structured attributes", () => { it('captures errors as Issues with namespace and structured attributes', () => {
const { scope, sentry } = createFakeSentry(); const { scope, sentry } = createFakeSentry();
const logger = createLogger({ const logger = createLogger({
environment: "production", environment: 'production',
namespace: "API", namespace: 'API',
sinks: [createSentrySink(sentry)], sinks: [createSentrySink(sentry)],
}); });
const error = new Error("offline"); const error = new Error('offline');
logger.warn("ignored without logs"); logger.warn('ignored without logs');
logger.error("Request failed", error, { requestId: "req_1" }); logger.error('Request failed', error, { requestId: 'req_1' });
expect(sentry.captureException).toHaveBeenCalledWith(error); expect(sentry.captureException).toHaveBeenCalledWith(error);
expect(sentry.captureMessage).not.toHaveBeenCalled(); expect(sentry.captureMessage).not.toHaveBeenCalled();
expect(sentry.logger.info).not.toHaveBeenCalled(); expect(sentry.logger.info).not.toHaveBeenCalled();
expect(scope.setLevel).toHaveBeenCalledWith("error"); expect(scope.setLevel).toHaveBeenCalledWith('error');
expect(scope.setTag).toHaveBeenCalledWith("logger.namespace", "API"); expect(scope.setTag).toHaveBeenCalledWith('logger.namespace', 'API');
expect(scope.setExtras).toHaveBeenCalledWith({ expect(scope.setExtras).toHaveBeenCalledWith({
"logger.namespace": "API", 'logger.namespace': 'API',
requestId: "req_1", requestId: 'req_1',
}); });
}); });
it("writes non-error events to Sentry Logs when enabled, not as Issues", () => { it('writes non-error events to Sentry Logs when enabled, not as Issues', () => {
const { sentry } = createFakeSentry(); const { sentry } = createFakeSentry();
const logger = createLogger({ const logger = createLogger({
environment: "production", environment: 'production',
namespace: "API", namespace: 'API',
sinks: [createSentrySink(sentry, { logs: true })], sinks: [createSentrySink(sentry, { logs: true })],
}); });
logger.warn("slow", { ms: 1200 }); logger.warn('slow', { ms: 1200 });
logger.info("forced", { id: 1 }, { sentry: true }); logger.info('forced', { id: 1 }, { sentry: true });
expect(sentry.captureMessage).not.toHaveBeenCalled(); expect(sentry.captureMessage).not.toHaveBeenCalled();
expect(sentry.captureException).not.toHaveBeenCalled(); expect(sentry.captureException).not.toHaveBeenCalled();
expect(sentry.logger.warn).toHaveBeenCalledWith("slow", { expect(sentry.logger.warn).toHaveBeenCalledWith('slow', {
"logger.namespace": "API", 'logger.namespace': 'API',
ms: 1200, ms: 1200,
}); });
expect(sentry.logger.info).toHaveBeenCalledWith("forced", { expect(sentry.logger.info).toHaveBeenCalledWith('forced', {
"logger.namespace": "API", 'logger.namespace': 'API',
id: 1, id: 1,
}); });
}); });
it("does not create an Issue when suppressSentry is set", () => { it('does not create an Issue when suppressSentry is set', () => {
const { sentry } = createFakeSentry(); const { sentry } = createFakeSentry();
const logger = createLogger({ const logger = createLogger({
environment: "production", environment: 'production',
runtime: "node", runtime: 'node',
sinks: [createSentrySink(sentry, { logs: true })], sinks: [createSentrySink(sentry, { logs: true })],
}); });
logger.error("expected", new Error("nope"), { suppressSentry: true }); logger.error('expected', new Error('nope'), { suppressSentry: true });
expect(sentry.captureException).not.toHaveBeenCalled(); expect(sentry.captureException).not.toHaveBeenCalled();
expect(sentry.logger.error).not.toHaveBeenCalled(); expect(sentry.logger.error).not.toHaveBeenCalled();
}); });
it("never sends in development", () => { it('never sends in development', () => {
const { sentry } = createFakeSentry(); const { sentry } = createFakeSentry();
const logger = createLogger({ const logger = createLogger({
environment: "development", environment: 'development',
sinks: [createSentrySink(sentry, { logs: true })], sinks: [createSentrySink(sentry, { logs: true })],
}); });
logger.error("local", new Error("x")); logger.error('local', new Error('x'));
logger.warn("local warn", { sentry: true }); logger.warn('local warn', { sentry: true });
expect(sentry.captureException).not.toHaveBeenCalled(); expect(sentry.captureException).not.toHaveBeenCalled();
expect(sentry.logger.warn).not.toHaveBeenCalled(); expect(sentry.logger.warn).not.toHaveBeenCalled();
}); });
it("maps warn → warning on the Issues path and keeps warn on the Logs path", () => { it('maps warn → warning on the Issues path and keeps warn on the Logs path', () => {
const { scope, sentry } = createFakeSentry(); const { scope, sentry } = createFakeSentry();
const sink = createSentrySink(sentry, { logs: true }); const sink = createSentrySink(sentry, { logs: true });
sink.emit( sink.emit(
issueEvent({ issueEvent({
level: "warn", level: 'warn',
arguments: ["degraded"], arguments: ['degraded'],
}), }),
); );
expect(scope.setLevel).toHaveBeenCalledWith("warning"); expect(scope.setLevel).toHaveBeenCalledWith('warning');
expect(sentry.captureMessage).toHaveBeenCalledWith("degraded", "warning"); expect(sentry.captureMessage).toHaveBeenCalledWith('degraded', 'warning');
expect(sentry.logger.warn).not.toHaveBeenCalled(); expect(sentry.logger.warn).not.toHaveBeenCalled();
sink.emit({ sink.emit({
level: "warn", level: 'warn',
namespace: "API", namespace: 'API',
arguments: ["slow", { ms: 10 }], arguments: ['slow', { ms: 10 }],
timestamp: new Date(), timestamp: new Date(),
environment: "production", environment: 'production',
sendToSentryLogs: true, sendToSentryLogs: true,
sendToSentryIssue: false, sendToSentryIssue: false,
sendToConsole: false, sendToConsole: false,
}); });
expect(sentry.logger.warn).toHaveBeenCalledWith("slow", { expect(sentry.logger.warn).toHaveBeenCalledWith('slow', {
"logger.namespace": "API", 'logger.namespace': 'API',
ms: 10, ms: 10,
}); });
}); });
it("maps error Issue severity as error", () => { it('maps error Issue severity as error', () => {
const { scope, sentry } = createFakeSentry(); const { scope, sentry } = createFakeSentry();
createSentrySink(sentry).emit( createSentrySink(sentry).emit(
issueEvent({ issueEvent({
level: "error", level: 'error',
arguments: ["failed"], arguments: ['failed'],
}), }),
); );
expect(scope.setLevel).toHaveBeenCalledWith("error"); expect(scope.setLevel).toHaveBeenCalledWith('error');
expect(sentry.captureMessage).toHaveBeenCalledWith("failed", "error"); expect(sentry.captureMessage).toHaveBeenCalledWith('failed', 'error');
}); });
}); });
+5 -5
View File
@@ -1,16 +1,16 @@
import { defineConfig } from "tsup"; import { defineConfig } from 'tsup';
export default defineConfig({ export default defineConfig({
entry: { index: "src/index.ts", sentry: "src/sentry.ts" }, entry: { index: 'src/index.ts', sentry: 'src/sentry.ts' },
format: ["esm", "cjs"], format: ['esm', 'cjs'],
// tsup injects baseUrl for DTS; TS 6 treats that as an error until tsup stops // tsup injects baseUrl for DTS; TS 6 treats that as an error until tsup stops
// (https://github.com/egoist/tsup/issues/1388) // (https://github.com/egoist/tsup/issues/1388)
dts: { dts: {
compilerOptions: { compilerOptions: {
ignoreDeprecations: "6.0", ignoreDeprecations: '6.0',
}, },
}, },
clean: true, clean: true,
target: "es2022", target: 'es2022',
sourcemap: true, sourcemap: true,
}); });
+2 -2
View File
@@ -1,5 +1,5 @@
import { defineConfig } from "vitest/config"; import { defineConfig } from 'vitest/config';
export default defineConfig({ export default defineConfig({
test: { environment: "node", coverage: { provider: "v8", reporter: ["text", "html"] } }, test: { environment: 'node', coverage: { provider: 'v8', reporter: ['text', 'html'] } },
}); });