Skip to content

@studnicky/logger

Pluggable logging interface with portable transport architecture, child loggers, and metadata support.

Install

bash
pnpm add @studnicky/logger

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

Usage

Create a Logger with one configuration object, 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',
  'context': { 'step': 1 },
  'message': 'Example executed',
  'operation': 'run',
  'status': 'success'
});

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',
  'context': { 'code': 'E001' },
  'message': 'Something went wrong',
  'name': 'ExampleError',
  'operation': 'run',
  'status': 'failed'
});

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);

Browser console transport

Logger and ConsoleTransport use the portable root entrypoint because their public behavior is the same in browser and server environments. The console transport dispatches records to the native browser console without importing a server runtime.

Immutable LogBody and LogFault configuration

LogBody.create(config) and LogFault.create(config) validate one readonly configuration object and return an immutable normalized entry. Both require component, operation, status, message, and context; faults also require name. Missing required fields throw LogBuildError. The usage example above exercises both factories directly from the package root.

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',
  'context': { 'route': '/api/users' },
  'message': 'Request routed',
  'operation': 'handle',
  'status': 'success'
});

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. Use the root-exported ParseLogLevel.parse() when the transport accepts named or numeric level configuration:

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

class BufferedTransport implements TransportInterface {
  readonly #batch: LogRecordEntity.Type[] = [];
  readonly #batchSize: number;
  readonly #minimumLevel: 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.#minimumLevel = ParseLogLevel.parse(options.level ?? 'trace');
  }

  write(record: LogRecordEntity.Type): void {
    if (record.level < this.#minimumLevel) {
      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',
  'context': { 'jobId': 'j-1' },
  'message': 'Job processed',
  'operation': 'process',
  'status': 'success'
});

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 and override the protected hooks below to inject tracing, metrics, or debug logging without modifying the class itself.

HookClassWhen it firesArgs
onLogLoggerAfter a record is assembled, before fan-out to transportslevel: LogLevelEntity.Type, record: LogRecordEntity.Type
onDroppedLoggerWhen a record is below the logger's level floor and is discardedlevel: LogLevelEntity.Type
onChildCreateLoggerAfter a child logger is created via .child()bindings: LogMetadataInterface
onTransportErrorLoggerWhen a transport's write() throwstransport: TransportInterface, record: LogRecordEntity.Type, error: unknown
ts
import type {
  LogBodyDataEntity,
  LoggerHookEventShapeEntity,
  LogLevelEntity,
  LogRecordEntity
} from '../src/entities/index.js';
import type { TransportInterface } from '../src/index.js';
import type { LoggerOptionsInterface, LogMetadataInterface } from '../src/interfaces/index.js';

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

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

interface LogEventInterface {
  readonly 'bindings'?: LogMetadataInterface;
  readonly 'error'?: Error;
  readonly 'level'?: LogLevelEntity.Type;
  readonly 'message'?: LogBodyDataEntity.Type['message'];
  readonly 'shape': LoggerHookEventShapeEntity.Type;
}

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

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

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

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

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

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

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

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

const throwingTransport = FunctionTransport.create(() => {
  throw RuntimeError.create('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',
  'context': {},
  'message': 'Hello from observed logger',
  'operation': 'run',
  'status': 'success'
});

logger.info(infoBody);

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

logger.debug(debugBody);

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

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

console.log('Logger events:', JSON.stringify(logger.events));

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().

Entities

@studnicky/logger/entities exports every schema namespace in src/entities, including serializable log records, bodies, faults, levels, statuses, and transport options.

typescript
import { LogRecordEntity } from '@studnicky/logger/entities';

Interfaces

@studnicky/logger/interfaces exports every TypeScript interface in src/interfaces, including logger configuration, metadata, schema, and request contracts.

typescript
import type { LoggerOptionsInterface } from '@studnicky/logger/interfaces';

Entity source files import JSONSchema and FromSchema directly from json-schema-to-ts and ValidateFunction directly from ajv. Both dependencies are declared directly by @studnicky/logger; dependency-owned types are not proxy-exported.

Exports

SymbolPurposeImport path
LoggerCreates loggers and emits structured entries.@studnicky/logger
LogBodyCreates validated immutable non-fault log entries.@studnicky/logger
LogFaultCreates validated immutable fault log entries.@studnicky/logger
ConsoleTransportWrites log records to the console.@studnicky/logger
FunctionTransportDelivers log records to a supplied function.@studnicky/logger
MemoryTransportCaptures log records in memory.@studnicky/logger
NoOpTransportDiscards log records.@studnicky/logger
TransportInterfaceDefines the contract for custom transports.@studnicky/logger
ParseLogLevelNormalizes named and numeric log levels.@studnicky/logger
EVENT_COMPONENTSProvides supported event-component values.@studnicky/logger
LOG_LEVELProvides numeric log-level values.@studnicky/logger
LOG_STATUSProvides supported log-status values.@studnicky/logger
STATUS_CATEGORIESGroups log statuses for result filtering.@studnicky/logger
CircularReferenceErrorRepresents circular-reference serialization failures.@studnicky/logger
ConfigurationErrorRepresents invalid logger configuration.@studnicky/logger
FileDestinationErrorRepresents file transport destination failures.@studnicky/logger
InvalidLogLevelErrorRepresents invalid log-level configuration.@studnicky/logger
LogBuildErrorRepresents invalid log-entry construction.@studnicky/logger
LogStatusEntityProvides the schema and type for structured log statuses.@studnicky/logger
LoggerErrorBase error for logger failures.@studnicky/logger

Try it

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

Lifecycle hooks

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

Loading example…

Source on GitHub