Skip to content

@studnicky/logger

Pluggable logging interface with transport architecture, child loggers, and metadata support for Node.js.

Install

bash
pnpm add @studnicky/logger

Requires @studnicky:registry=https://npm.pkg.github.com in .npmrc.

Usage

Build a Logger instance with Logger.create, attach transports, then pass structured LogBody or LogFault entries to the log methods:

ts
import { LogBody, LogFault, Logger, MemoryTransport } from '../src/index.js';

const memory = MemoryTransport.create();
const logger = Logger.create({
  'level': 'trace',
  'transports': [memory]
});

const body = LogBody.create()
  .component('example')
  .operation('run')
  .status('success')
  .message('Example executed')
  .context({ 'step': 1 })
  .build();

logger.info(body);

console.log('records after info:', memory.records().length);
console.log('captured message:', memory.records()[0]?.data.message);

// Error entry via LogFault
const fault = LogFault.create()
  .component('example')
  .operation('run')
  .status('failed')
  .name('ExampleError')
  .message('Something went wrong')
  .context({ 'code': 'E001' })
  .build();

logger.error(fault);

console.log('records after error:', memory.records().length);

// clear() empties the buffer
memory.clear();

console.log('records after clear:', memory.records().length);

// Child logger shares transports with parent
const child = logger.child({ 'requestId': 'req-001', 'service': 'auth' });

child.info(body);

console.log('records after child write:', memory.records().length);
console.log('child metadata.service:', memory.records()[0]?.metadata.service);

LogBody and LogFault builders

LogBody and LogFault are fluent builders that enforce required fields (component, operation, status, message, context) at build time. LogFault adds name and a fromError() convenience. Missing required fields throw LogBuildError:

ts
import { LogBody, LogFault, Logger, MemoryTransport } from '../src/index.js';

const memory = MemoryTransport.create();
const logger = Logger.create({ 'level': 'trace', 'transports': [memory] });

// LogBody — all required fields
const body = LogBody.create()
  .component('pipeline')
  .operation('process')
  .status('success')
  .message('Batch processed')
  .context({ 'batchSize': 100 })
  .duration(230)
  .build();

console.log('body.event:', body.event);
console.log('body.status:', body.status);
console.log('body.durationMs:', body.durationMs);

logger.info(body);

console.log('logged message:', memory.records()[0]?.data.message);

// LogFault — fromError() convenience
const originalError = new Error('Connection reset');
const fault = LogFault.create()
  .component('pipeline')
  .operation('process')
  .status('failed')
  .fromError(originalError)
  .context({ 'batchId': 'b-42' })
  .build();

console.log('fault.name:', fault.name);
console.log('fault.message:', fault.message);

logger.error(fault);

memory.clear();

// LogBody build() throws on missing required fields
let buildError: Error | undefined;
try {
  LogBody.create().build();
} catch (err) {
  buildError = err as Error;
}

console.log('build error type:', buildError?.constructor.name);

Fan-out and level filtering

Pass multiple transports to Logger.create. Each transport has its own level floor; entries below the floor are silently dropped. Child loggers share all parent transports and merge their metadata into every record:

ts
import { LogBody, Logger, MemoryTransport } from '../src/index.js';

const memoryAll = MemoryTransport.create({ 'level': 'trace' });
const memoryWarn = MemoryTransport.create({ 'level': 'warn' });

const logger = Logger.create({
  'level': 'trace',
  'transports': [memoryAll, memoryWarn]
});

const body = LogBody.create()
  .component('router')
  .operation('handle')
  .status('success')
  .message('Request routed')
  .context({ 'route': '/api/users' })
  .build();

logger.info(body);

console.log('memoryAll after info:', memoryAll.records().length);
console.log('memoryWarn after info:', memoryWarn.records().length);

logger.warn(body);

console.log('memoryAll after warn:', memoryAll.records().length);
console.log('memoryWarn after warn:', memoryWarn.records().length);

// Child logger reaches all transports
const child = logger.child({ 'requestId': 'req-999' });

child.error(body);

console.log('memoryAll after child error:', memoryAll.records().length);
console.log('memoryWarn after child error:', memoryWarn.records().length);
console.log('child metadata.requestId:', memoryAll.records()[2]?.metadata.requestId);

Custom transports

Implement TransportInterface directly for a custom sink. ResolveMinLevel.from() resolves the same level-floor behavior the built-in transports use, so a custom transport's constructor validates and defaults level the same way:

ts
import { LogBody, Logger, ResolveMinLevel } from '../src/index.js';

class BufferedTransport implements TransportInterface {
  readonly #batch: LogRecordEntity.Type[] = [];
  readonly #batchSize: number;
  readonly #minLevel: number;
  readonly #sink: (batch: readonly LogRecordEntity.Type[]) => void;

  constructor(sink: (batch: readonly LogRecordEntity.Type[]) => void, options: { 'batchSize'?: number; 'level'?: string } = {}) {
    this.#sink = sink;
    this.#batchSize = options.batchSize ?? 2;
    this.#minLevel = ResolveMinLevel.from(options);
  }

  write(record: LogRecordEntity.Type): void {
    if (record.level < this.#minLevel) {
      return;
    }
    this.#batch.push(record);
    if (this.#batch.length >= this.#batchSize) {
      this.flush();
    }
  }

  flush(): void {
    if (this.#batch.length === 0) {
      return;
    }
    this.#sink(this.#batch.splice(0, this.#batch.length));
  }
}

const flushed: LogRecordEntity.Type[][] = [];
const buffered = new BufferedTransport((batch) => { const result = flushed.push([...batch]); return result; }, { 'batchSize': 2, 'level': 'info' });

const logger = Logger.create({
  'level': 'trace',
  'transports': [buffered]
});

const body = LogBody.create()
  .component('worker')
  .operation('process')
  .status('success')
  .message('Job processed')
  .context({ 'jobId': 'j-1' })
  .build();

logger.info(body);

console.log('flushed batches after one record:', flushed.length);

logger.info(body);

console.log('flushed batches after two records:', flushed.length);
console.log('records in first flushed batch:', flushed[0]?.length);

Observability hooks

Subclass Logger, LogBody, or LogFault and override any of the protected hooks below to inject tracing, metrics, or debug logging without modifying the class itself.

HookClassWhen it firesArgs
onFieldSetBaseLogEntryBuilderAfter each fluent setter callfield: string, value: unknown
onBuildBaseLogEntryBuilderAfter build() assembles the frozen resultresult: LogBodyDataType | LogFaultDataType
onBuildErrorBaseLogEntryBuilderWhen a required field is missing and build() is about to throwfield: string
onLogLoggerAfter a record is assembled, before fan-out to transportslevel: LogLevelType, record: LogRecordType
onDroppedLoggerWhen a record is below the logger's level floor and is discardedlevel: LogLevelType
onChildCreateLoggerAfter a child logger is created via .child()bindings: LogMetadataType
onTransportErrorLoggerWhen a transport's write() throwstransport: TransportInterface, record: LogRecordType, error: unknown
ts
import type { LogBodyDataEntity } from '../src/entities/LogBodyDataEntity.js';
import type { LogFaultDataEntity } from '../src/entities/LogFaultDataEntity.js';
import type { LogRecordEntity } from '../src/entities/LogRecordEntity.js';
import type { LoggerOptionsInterface } from '../src/interfaces/LoggerOptionsInterface.js';
import type { TransportInterface } from '../src/transports/TransportInterface.js';
import type { LogLevelType } from '../src/types/LogLevelType.js';
import type { LogMetadataType } from '../src/types/LogMetadataType.js';

import { LogBody } from '../src/modules/LogBody.js';
import { Logger } from '../src/modules/Logger.js';
import { FunctionTransport } from '../src/transports/FunctionTransport.js';

// ---------------------------------------------------------------------------
// ObservedLogger — records every Logger lifecycle event
// ---------------------------------------------------------------------------

type LogEvent =
  | { 'error': unknown; 'kind': 'transportError' }
  | { 'bindings': LogMetadataType; 'kind': 'childCreate' }
  | { 'kind': 'dropped'; 'level': LogLevelType }
  | { 'kind': 'log'; 'level': LogLevelType; 'message': string };

class ObservedLogger extends Logger {
  constructor(config: LoggerOptionsInterface = {}) {
    super(config);
  }

  readonly #recorder = new EventRecorder<LogEvent>();

  get events(): LogEvent[] { return this.#recorder.events; }

  protected override onLog(level: LogLevelType, record: LogRecordEntity.Type): void {
    this.#recorder.record(
      { 'kind': 'log', 'level': level, 'message': String(record.data.message) },
      `[logger] onLog level=${level} msg=${String(record.data.message)}`
    );
  }

  protected override onDropped(level: LogLevelType): void {
    this.#recorder.record({ 'kind': 'dropped', 'level': level }, `[logger] onDropped level=${level}`);
  }

  protected override onChildCreate(bindings: LogMetadataType): void {
    this.#recorder.record(
      { 'bindings': bindings, 'kind': 'childCreate' },
      `[logger] onChildCreate bindings=${JSON.stringify(bindings)}`
    );
  }

  protected override onTransportError(_transport: TransportInterface, _record: LogRecordEntity.Type, error: unknown): void {
    this.#recorder.record(
      { 'error': error, 'kind': 'transportError' },
      `[logger] onTransportError error=${String(error instanceof Error ? error.message : error)}`
    );
  }
}

// ---------------------------------------------------------------------------
// ObservedLogBody — records every builder lifecycle event
// ---------------------------------------------------------------------------

type BuilderEvent =
  | { 'field': string; 'kind': 'buildError' }
  | { 'kind': 'build'; 'result': LogBodyDataEntity.Type | LogFaultDataEntity.Type };

class ObservedLogBody extends LogBody {
  constructor() { super(); }

  readonly #recorder = new EventRecorder<BuilderEvent>();

  get builderEvents(): BuilderEvent[] { return this.#recorder.events; }

  protected override onBuild(result: LogBodyDataEntity.Type | LogFaultDataEntity.Type): void {
    this.#recorder.record({ 'kind': 'build', 'result': result }, `[logger] onBuild event=${result.event}`);
  }

  protected override onBuildError(field: string): void {
    this.#recorder.record({ 'field': field, 'kind': 'buildError' }, `[logger] onBuildError field=${field}`);
  }
}

// ---------------------------------------------------------------------------
// Scenario
// ---------------------------------------------------------------------------

const throwingTransport = FunctionTransport.create(() => {
  throw new Error('transport failure');
});

const logger = new ObservedLogger({
  'level': 'info',
  'metadata': { 'service': 'observed-demo' },
  'transports': [throwingTransport]
});

// onLog — fires for an info record (at or above INFO floor)
const infoBody = LogBody.create()
  .component('demo')
  .operation('run')
  .status('success')
  .message('Hello from observed logger')
  .context({})
  .build();

logger.info(infoBody);

// onDropped — fires because debug is below INFO floor
const debugBody = LogBody.create()
  .component('demo')
  .operation('debug-probe')
  .status('success')
  .message('This is below the floor')
  .context({})
  .build();

logger.debug(debugBody);

// onChildCreate — fires when child() is called
const child = logger.child({ 'requestId': 'req-abc' });

// onTransportError already fired above (throwingTransport); verify via events

// onBuild — fire via ObservedLogBody
const observedBuilder = new ObservedLogBody();
observedBuilder
  .component('builder')
  .operation('construct')
  .status('success')
  .message('Built payload')
  .context({ 'n': 42 })
  .build();

// onBuildError — fire via missing field
const errorBuilder = new ObservedLogBody();
try {
  errorBuilder
    .operation('construct')
    .status('success')
    .message('Missing component')
    .context({})
    .build();
} catch {
  // expected — onBuildError fired before throw
}

console.log('Logger events:', JSON.stringify(logger.events));
console.log('Builder events:', JSON.stringify(observedBuilder.builderEvents));
console.log('Builder error events:', JSON.stringify(errorBuilder.builderEvents));

The base class never calls any logger or metrics library. All hooks are no-ops by default.

Logger composes a plain HookInvoker with no override, so a throwing onLog, onDropped, or onChildCreate propagates the default HookInvocationError to the caller rather than being recorded. onTransportError is the one hook Logger itself guards: a throwing override is caught and recorded instead of aborting fan-out to the remaining transports — inspect it via hookErrorCount/getHookErrors().

Subpath exports

SubpathContents
@studnicky/loggerLogger, LogBody, LogFault, ConsoleTransport, FunctionTransport, MemoryTransport, NoOpTransport, LoggerOptionsEntity, parseLogLevel, ResolveMinLevel, safeStringify
@studnicky/logger/buildersBuilder classes
@studnicky/logger/constantsLog level constants
@studnicky/logger/errorsLogger error classes
@studnicky/logger/interfacesInterface types
@studnicky/logger/transportsTransport classes and TransportInterface
@studnicky/logger/typesLogStatusType and other type aliases

Try it

The examples below run in the browser via the embedded playground.

Builder

The builder wires level, metadata, and transports before calling build() — the resulting logger is already configured for fan-out and level filtering.

Logger builder
/** builder-logger — construct a Logger via the fluent builder API. Run: npx tsx packages/logger/examples/builder-logger.ts */

import assert from 'node:assert/strict';

// #region usage
import { LogBody } from '../src/modules/LogBody.js';
import { LogFault } from '../src/modules/LogFault.js';
import { Logger } from '../src/modules/Logger.js';
import { MemoryTransport } from '../src/transports/MemoryTransport.js';

// ---------------------------------------------------------------------------
// Build a Logger via the fluent builder
// ---------------------------------------------------------------------------

const memory = MemoryTransport.create();

const logger = Logger.builder()
  .withLevel('info')
  .withMetadata({ 'service': 'demo' })
  .withTransports([memory])
  .build();

console.log('[builder-logger] Logger constructed via builder');

// ---------------------------------------------------------------------------
// Log a structured info entry via LogBody
// ---------------------------------------------------------------------------

const infoBody = LogBody.create()
  .component('auth')
  .operation('login')
  .status('success')
  .message('User logged in')
  .context({ 'userId': 'u-001' })
  .duration(12)
  .build();

logger.info(infoBody);
console.log(`[builder-logger] info record captured — event=${infoBody.event} message=${infoBody.message}`);

// ---------------------------------------------------------------------------
// Log a fault entry via LogFault.create().fromError()
// ---------------------------------------------------------------------------

const cause = new Error('database connection refused');

const fault = LogFault.create()
  .component('db')
  .operation('connect')
  .status('failed')
  .fromError(cause)
  .context({ 'host': 'localhost', 'port': 5432 })
  .build();

logger.error(fault);
console.log(`[builder-logger] error record captured — event=${fault.event} name=${fault.name}`);

// ---------------------------------------------------------------------------
// Debug record is dropped because the logger floor is 'info'
// ---------------------------------------------------------------------------

const debugBody = LogBody.create()
  .component('auth')
  .operation('token-check')
  .status('success')
  .message('Token validated')
  .context({})
  .build();

logger.debug(debugBody);
console.log('[builder-logger] debug record dropped (below info floor)');

// #endregion usage

// Verify that the info and error records were captured
assert.strictEqual(memory.records().length, 2, 'expected 2 records (info + error)');

// Verify the first record is the info entry
const [infoRecord, errorRecord] = memory.records();
assert.ok(infoRecord !== undefined);
assert.strictEqual(infoRecord.data.event, 'auth.login');
assert.strictEqual(infoRecord.data.message, 'User logged in');
assert.deepStrictEqual(infoRecord.metadata, { 'service': 'demo' });

// Verify the second record is the fault entry
assert.ok(errorRecord !== undefined);
assert.strictEqual(errorRecord.data.event, 'db.connect');
assert.strictEqual(errorRecord.data.name, 'Error');

console.log('builder-logger: all assertions passed');
Output
Press Execute to run this example against the real library.

Lifecycle hooks

Each log call fires onLog, filtered calls fire onDropped, and transport failures surface via onTransportError — all without modifying Logger's public API.

Logger lifecycle hooks
/** observedLogger — subclass hook overrides that emit console.log trace lines for every logger lifecycle stage. Run: npx tsx examples/observedLogger.ts */

import { EventRecorder } from '@studnicky/errors/observers';
import assert from 'node:assert/strict';

// #region usage
import type { LogBodyDataEntity } from '../src/entities/LogBodyDataEntity.js';
import type { LogFaultDataEntity } from '../src/entities/LogFaultDataEntity.js';
import type { LogRecordEntity } from '../src/entities/LogRecordEntity.js';
import type { LoggerOptionsInterface } from '../src/interfaces/LoggerOptionsInterface.js';
import type { TransportInterface } from '../src/transports/TransportInterface.js';
import type { LogLevelType } from '../src/types/LogLevelType.js';
import type { LogMetadataType } from '../src/types/LogMetadataType.js';

import { LogBody } from '../src/modules/LogBody.js';
import { Logger } from '../src/modules/Logger.js';
import { FunctionTransport } from '../src/transports/FunctionTransport.js';

// ---------------------------------------------------------------------------
// ObservedLogger — records every Logger lifecycle event
// ---------------------------------------------------------------------------

type LogEvent =
  | { 'error': unknown; 'kind': 'transportError' }
  | { 'bindings': LogMetadataType; 'kind': 'childCreate' }
  | { 'kind': 'dropped'; 'level': LogLevelType }
  | { 'kind': 'log'; 'level': LogLevelType; 'message': string };

class ObservedLogger extends Logger {
  constructor(config: LoggerOptionsInterface = {}) {
    super(config);
  }

  readonly #recorder = new EventRecorder<LogEvent>();

  get events(): LogEvent[] { return this.#recorder.events; }

  protected override onLog(level: LogLevelType, record: LogRecordEntity.Type): void {
    this.#recorder.record(
      { 'kind': 'log', 'level': level, 'message': String(record.data.message) },
      `[logger] onLog level=${level} msg=${String(record.data.message)}`
    );
  }

  protected override onDropped(level: LogLevelType): void {
    this.#recorder.record({ 'kind': 'dropped', 'level': level }, `[logger] onDropped level=${level}`);
  }

  protected override onChildCreate(bindings: LogMetadataType): void {
    this.#recorder.record(
      { 'bindings': bindings, 'kind': 'childCreate' },
      `[logger] onChildCreate bindings=${JSON.stringify(bindings)}`
    );
  }

  protected override onTransportError(_transport: TransportInterface, _record: LogRecordEntity.Type, error: unknown): void {
    this.#recorder.record(
      { 'error': error, 'kind': 'transportError' },
      `[logger] onTransportError error=${String(error instanceof Error ? error.message : error)}`
    );
  }
}

// ---------------------------------------------------------------------------
// ObservedLogBody — records every builder lifecycle event
// ---------------------------------------------------------------------------

type BuilderEvent =
  | { 'field': string; 'kind': 'buildError' }
  | { 'kind': 'build'; 'result': LogBodyDataEntity.Type | LogFaultDataEntity.Type };

class ObservedLogBody extends LogBody {
  constructor() { super(); }

  readonly #recorder = new EventRecorder<BuilderEvent>();

  get builderEvents(): BuilderEvent[] { return this.#recorder.events; }

  protected override onBuild(result: LogBodyDataEntity.Type | LogFaultDataEntity.Type): void {
    this.#recorder.record({ 'kind': 'build', 'result': result }, `[logger] onBuild event=${result.event}`);
  }

  protected override onBuildError(field: string): void {
    this.#recorder.record({ 'field': field, 'kind': 'buildError' }, `[logger] onBuildError field=${field}`);
  }
}

// ---------------------------------------------------------------------------
// Scenario
// ---------------------------------------------------------------------------

const throwingTransport = FunctionTransport.create(() => {
  throw new Error('transport failure');
});

const logger = new ObservedLogger({
  'level': 'info',
  'metadata': { 'service': 'observed-demo' },
  'transports': [throwingTransport]
});

// onLog — fires for an info record (at or above INFO floor)
const infoBody = LogBody.create()
  .component('demo')
  .operation('run')
  .status('success')
  .message('Hello from observed logger')
  .context({})
  .build();

logger.info(infoBody);

// onDropped — fires because debug is below INFO floor
const debugBody = LogBody.create()
  .component('demo')
  .operation('debug-probe')
  .status('success')
  .message('This is below the floor')
  .context({})
  .build();

logger.debug(debugBody);

// onChildCreate — fires when child() is called
const child = logger.child({ 'requestId': 'req-abc' });

// onTransportError already fired above (throwingTransport); verify via events

// onBuild — fire via ObservedLogBody
const observedBuilder = new ObservedLogBody();
observedBuilder
  .component('builder')
  .operation('construct')
  .status('success')
  .message('Built payload')
  .context({ 'n': 42 })
  .build();

// onBuildError — fire via missing field
const errorBuilder = new ObservedLogBody();
try {
  errorBuilder
    .operation('construct')
    .status('success')
    .message('Missing component')
    .context({})
    .build();
} catch {
  // expected — onBuildError fired before throw
}

console.log('Logger events:', JSON.stringify(logger.events));
console.log('Builder events:', JSON.stringify(observedBuilder.builderEvents));
console.log('Builder error events:', JSON.stringify(errorBuilder.builderEvents));
// #endregion usage

// Verify onLog fired for the info call
{
  const logEvents = logger.events.filter((e) => { return e.kind === 'log'; });
  assert.strictEqual(logEvents.length, 1);
  const [firstLog] = logEvents;
  assert.ok(firstLog?.kind === 'log');
  assert.strictEqual(firstLog.message, 'Hello from observed logger');
}

// Verify onDropped fired for the debug call
const droppedEvents = logger.events.filter((e) => { return e.kind === 'dropped'; });
assert.strictEqual(droppedEvents.length, 1);

// Verify onChildCreate fired
{
  const childEvents = logger.events.filter((e) => { return e.kind === 'childCreate'; });
  assert.strictEqual(childEvents.length, 1);
  const [firstChild] = childEvents;
  assert.ok(firstChild?.kind === 'childCreate');
  assert.deepStrictEqual(firstChild.bindings, { 'requestId': 'req-abc' });
}

// Verify onTransportError fired (throwing transport)
{
  const transportErrorEvents = logger.events.filter((e) => { return e.kind === 'transportError'; });
  assert.strictEqual(transportErrorEvents.length, 1);
  const [firstTransportError] = transportErrorEvents;
  assert.ok(firstTransportError?.kind === 'transportError');
  assert.ok(firstTransportError.error instanceof Error);
}

// Verify onBuild fired
assert.strictEqual(observedBuilder.builderEvents.filter((e) => { return e.kind === 'build'; }).length, 1);

// Verify onBuildError fired
{
  const buildErrorEvents = errorBuilder.builderEvents.filter((e) => { return e.kind === 'buildError'; });
  assert.strictEqual(buildErrorEvents.length, 1);
  const [firstBuildError] = buildErrorEvents;
  assert.ok(firstBuildError?.kind === 'buildError');
  assert.strictEqual(firstBuildError.field, 'component');
}

// child is exercised: its info method is invoked to confirm the child is a usable Logger
child.info(infoBody);
assert.ok(typeof child.info === 'function');

console.log('observedLogger: all assertions passed');
Output
Press Execute to run this example against the real library.

Source on GitHub