@studnicky/logger
Pluggable logging interface with transport architecture, child loggers, and metadata support for Node.js.
Install
pnpm add @studnicky/loggerRequires @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:
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:
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:
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:
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.
| Hook | Class | When it fires | Args |
|---|---|---|---|
onFieldSet | BaseLogEntryBuilder | After each fluent setter call | field: string, value: unknown |
onBuild | BaseLogEntryBuilder | After build() assembles the frozen result | result: LogBodyDataType | LogFaultDataType |
onBuildError | BaseLogEntryBuilder | When a required field is missing and build() is about to throw | field: string |
onLog | Logger | After a record is assembled, before fan-out to transports | level: LogLevelType, record: LogRecordType |
onDropped | Logger | When a record is below the logger's level floor and is discarded | level: LogLevelType |
onChildCreate | Logger | After a child logger is created via .child() | bindings: LogMetadataType |
onTransportError | Logger | When a transport's write() throws | transport: TransportInterface, record: LogRecordType, error: unknown |
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
| Subpath | Contents |
|---|---|
@studnicky/logger | Logger, LogBody, LogFault, ConsoleTransport, FunctionTransport, MemoryTransport, NoOpTransport, LoggerOptionsEntity, parseLogLevel, ResolveMinLevel, safeStringify |
@studnicky/logger/builders | Builder classes |
@studnicky/logger/constants | Log level constants |
@studnicky/logger/errors | Logger error classes |
@studnicky/logger/interfaces | Interface types |
@studnicky/logger/transports | Transport classes and TransportInterface |
@studnicky/logger/types | LogStatusType 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.
/** 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');
Lifecycle hooks
Each log call fires onLog, filtered calls fire onDropped, and transport failures surface via onTransportError — all without modifying Logger's public API.
/** 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');