log

js/log.ts

fino:log — topic-based structured logging.

This module provides the application-facing logging API for fino backend code. Loggers publish structured LogRecord objects onto fino:context/topic channels; output is deliberately sink-driven. Creating a logger does not install global stdout/stderr behavior, so libraries can safely log without surprising their host application.

The default transport topic is "fino:log". Additional per-level and per-logger topics are also published for callers that want narrower subscriptions:

- fino:log - fino:log:<level> - fino:log:<logger-name>

Context is propagated with fino:context, so request IDs and similar fields survive await boundaries. If an OpenTelemetry span is active, trace/span identifiers are copied onto the record and can be forwarded with createOtelSink().

import { createJsonSink, createLogger, withLogContext } from 'fino:log';

const sink = createJsonSink();
const log = createLogger({ name: 'api', level: 'info' });

{
  using scope = withLogContext({ requestId: 'req-1' });
  log.info('request started', { route: '/health' });
}

sink.dispose();

Types

type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal'

Normalized severity level accepted by loggers and sinks.

Levels are ordered from most verbose to most severe. Logger thresholds and sink thresholds both use this ordering when deciding whether to emit or forward a record.

import type { LogLevel } from 'fino:log';

const level: LogLevel = 'info';

type Fields = Record<string, unknown>

Structured fields attached to log records.

Values should be JSON-friendly when records are sent to JSON or telemetry sinks, but the type intentionally accepts any value so custom sinks can decide how to render richer runtime objects.

import type { Fields } from 'fino:log';

const fields: Fields = { requestId: 'req-1' };

type LogContextScope = ContextScope

Disposable scope returned by withLogContext().

Dispose happens automatically when used with a using declaration, restoring the previous async log context for the current execution branch. Capture the scope explicitly only when you need to dispose it manually outside of a using block.

import { withLogContext, type LogContextScope } from 'fino:log';

const scope: LogContextScope = withLogContext({ requestId: 'req-1' });
try {
  // ... work that should log with requestId attached ...
} finally {
  scope[Symbol.dispose]();
}

Interfaces

interface LogRecord {

Structured event emitted by Logger and consumed by sinks/subscribers.

Records are immutable by convention once published. Sinks receive the same record shape regardless of whether they render JSON, text, or OpenTelemetry log events.

import { createLogger, subscribeLogs, type LogRecord } from 'fino:log';

const events: LogRecord[] = [];
const sub = subscribeLogs((record) => events.push(record));
createLogger({ name: 'api' }).info('ready');
sub.dispose();

Properties

timestamp: string

ISO-8601 timestamp generated when the record is emitted.

const emittedAt = new Date(record.timestamp);
level: LogLevel

Normalized severity level.

if (record.level === 'error') {
  // route to stderr
}
logger: string

Logger name, usually a dotted subsystem path.

const subsystem = record.logger;
message: string

Human-readable event message.

console.log(record.message);
fields: Fields

Per-call structured fields.

const route = record.fields.route;
context: Fields

Async-scoped and logger-scoped fields merged together.

const requestId = record.context.requestId;
error?: { name: string; message: string; stack?: string; }

Normalized error details when the message or fields.error is an Error.

if (record.error) {
  console.error(record.error.message);
}
traceId?: string

OpenTelemetry trace id copied from the active span, when present.

const traceId = record.traceId ?? 'none';
spanId?: string

OpenTelemetry span id copied from the active span, when present.

const spanId = record.spanId ?? 'none';
traceFlags?: number

OpenTelemetry trace flags copied from the active span, when present.

const sampled = (record.traceFlags ?? 0) & 1;

interface LoggerOptions {

Options for constructing a Logger.

A logger name is required. The level controls which records are emitted, and context fields are attached to every record from that logger and its descendants.

import { Logger, type LoggerOptions } from 'fino:log';

const options: LoggerOptions = {
  name: 'api',
  level: 'info',
  context: { service: 'users' },
};
const log = new Logger(options);

Properties

name: string

Non-empty logger name.

const options = { name: 'api' };
level?: LogLevel

Minimum emitted level. Defaults to trace.

const options = { name: 'api', level: 'warn' as const };
context?: Fields

Fields attached to every record emitted by this logger.

const options = { name: 'api', context: { service: 'users' } };

interface SinkOptions {

Shared options for log subscriptions and sinks.

The level threshold is applied by subscribers after records are published. It does not change logger-level filtering.

import { subscribeLogs, type SinkOptions } from 'fino:log';

const options: SinkOptions = { level: 'warn' };
const sub = subscribeLogs(() => {}, options);
sub.dispose();

Properties

level?: LogLevel

Minimum level accepted by the subscription. Defaults to all levels.

const options = { level: 'error' as const };

interface WriteSinkOptions extends SinkOptions {

Options for line-oriented sinks such as text and JSON output.

Provide write to capture lines in tests, send them to a custom stream, or adapt records to a host logging system.

import { createJsonSink, type WriteSinkOptions } from 'fino:log';

const lines: string[] = [];
const options: WriteSinkOptions = { write: (line) => lines.push(line) };
const sink = createJsonSink(options);
sink.dispose();

Properties

write?: (line: string, record: LogRecord) => void

Receives each rendered line. Defaults to direct stdout/stderr writes.

const options = { write: (line: string) => console.log(line) };

Functions

function getLogContext(): Fields

Return the current async-scoped log context.

The returned object is a shallow copy, so mutating it does not alter the active context.

import { getLogContext, withLogContext } from 'fino:log';

{
  using scope = withLogContext({ requestId: 'req-1' });
  getLogContext().requestId; // 'req-1'
}

function withLogContext(context: Fields): LogContextScope

Enter a disposable structured log context scope.

The provided fields are shallow-merged over any existing context and are visible to logger calls made while the scope is active, including async continuations scheduled before the scope is disposed. Disposing restores the previous log context for the current execution branch.

import { createLogger, withLogContext } from 'fino:log';

const log = createLogger({ name: 'api' });
{
  using scope = withLogContext({ requestId: 'req-1' });
  await Promise.resolve();
  log.info('handled request');
}

function runWithLogContext<R>(context: Fields, fn: () => R): R

Run fn with additional structured log context.

Prefer using scope = withLogContext(...) for new code. This callback helper remains as a compatibility wrapper.

import { createLogger, runWithLogContext } from 'fino:log';

const log = createLogger({ name: 'api' });
await runWithLogContext({ requestId: 'req-1' }, async () => {
  log.info('handled request');
});

function createLogger(options: LoggerOptions): Logger

Convenience factory for new Logger(options).

Use this when a factory reads more naturally than a constructor. It performs the same validation and returns the same Logger class.

import { createLogger } from 'fino:log';

const log = createLogger({ name: 'api', level: 'info' });
log.info('ready');

function subscribeLogs(fn: (record: LogRecord) => void, options: SinkOptions = {})

Subscribe to structured log records.

Returns a disposable subscription handle. This is the lowest-level sink API; higher-level sinks are thin wrappers over this function.

import { createLogger, subscribeLogs } from 'fino:log';

const sub = subscribeLogs((record) => {
  console.log(record.level, record.message);
}, { level: 'info' });
createLogger({ name: 'api' }).info('ready');
sub.dispose();

function createJsonSink(options: WriteSinkOptions = {})

Subscribe a JSON-lines sink.

Without a custom write, records are written directly to stdout/stderr with one JSON object per line. The sink is opt-in; importing fino:log never installs it automatically.

import { createJsonSink, createLogger } from 'fino:log';

const sink = createJsonSink({ level: 'info' });
createLogger({ name: 'api' }).info('ready');
sink.dispose();

function createConsoleSink(options: WriteSinkOptions = {})

Subscribe a human-readable console sink.

This is intended for local development. Production ingestion should normally use createJsonSink() or createOtelSink().

import { createConsoleSink, createLogger } from 'fino:log';

const sink = createConsoleSink();
createLogger({ name: 'api' }).warn('slow request');
sink.dispose();

function createOtelSink(options: SinkOptions = {})

Subscribe an OpenTelemetry sink.

Records are forwarded through the active/default OTel LoggerProvider. Context and fields become log attributes, and normalized errors become exception.* attributes.

import { createLogger, createOtelSink } from 'fino:log';

const sink = createOtelSink({ level: 'info' });
createLogger({ name: 'api' }).info('exported to OpenTelemetry');
sink.dispose();

Classes

class Logger {

Emits structured log records onto the fino log topics.

A logger filters records by its configured level, merges logger context with async log context, and publishes matching records to broad and narrow topics.

import { Logger, createConsoleSink } from 'fino:log';

const sink = createConsoleSink({ level: 'info' });
const log = new Logger({ name: 'api', level: 'debug' });
log.info('ready', { port: 3000 });
sink.dispose();

Constructors

constructor(options: LoggerOptions)

Create a logger with a required non-empty name.

The constructor validates the level and shallow-copies context fields so later mutations of the options object do not affect emitted records.

import { Logger } from 'fino:log';

const log = new Logger({ name: 'api', level: 'info' });

Getters

get name(): string

Logger name included on every emitted record.

import { createLogger } from 'fino:log';

createLogger({ name: 'api' }).name; // 'api'
get level(): LogLevel

Minimum level this logger emits.

import { createLogger } from 'fino:log';

createLogger({ name: 'api', level: 'warn' }).level; // 'warn'

Methods

child(options: Fields | (Partial<LoggerOptions> & { name?: string; }) = {}): Logger

Create a child logger.

If options.name is provided, it is appended to the parent name with a dot. Other fields become logger context unless provided under options.context.

import { createLogger } from 'fino:log';

const root = createLogger({ name: 'api', context: { service: 'users' } });
const requests = root.child({ name: 'requests', route: '/users' });
requests.info('started');
log(level: LogLevel, message: unknown, fields: Fields = {}): void

Emit a record at an arbitrary level.

Prefer the level-specific helpers for normal use. Passing an Error as message, or as fields.error, attaches normalized error details.

import { createLogger } from 'fino:log';

const log = createLogger({ name: 'api' });
log.log('warn', 'slow request', { durationMs: 1200 });
trace(message: unknown, fields: Fields = {}): void

Emit a trace-level record.

createLogger({ name: 'api' }).trace('cache lookup');
debug(message: unknown, fields: Fields = {}): void

Emit a debug-level record.

createLogger({ name: 'api' }).debug('cache miss', { key: 'user:1' });
info(message: unknown, fields: Fields = {}): void

Emit an info-level record.

createLogger({ name: 'api' }).info('request started');
warn(message: unknown, fields: Fields = {}): void

Emit a warn-level record.

createLogger({ name: 'api' }).warn('rate limit near capacity');
error(message: unknown, fields: Fields = {}): void

Emit an error-level record.

createLogger({ name: 'api' }).error(new Error('database unavailable'));
fatal(message: unknown, fields: Fields = {}): void

Emit a fatal-level record.

createLogger({ name: 'api' }).fatal('process cannot continue');