@studnicky/logger
Pluggable logging interface with portable transport architecture, child loggers, and metadata support.
Install
pnpm add @studnicky/loggerRequires @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:
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:
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:
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.
| Hook | Class | When it fires | Args |
|---|---|---|---|
onLog | Logger | After a record is assembled, before fan-out to transports | level: LogLevelEntity.Type, record: LogRecordEntity.Type |
onDropped | Logger | When a record is below the logger's level floor and is discarded | level: LogLevelEntity.Type |
onChildCreate | Logger | After a child logger is created via .child() | bindings: LogMetadataInterface |
onTransportError | Logger | When a transport's write() throws | transport: TransportInterface, record: LogRecordEntity.Type, error: unknown |
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.
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.
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
| Symbol | Purpose | Import path |
|---|---|---|
Logger | Creates loggers and emits structured entries. | @studnicky/logger |
LogBody | Creates validated immutable non-fault log entries. | @studnicky/logger |
LogFault | Creates validated immutable fault log entries. | @studnicky/logger |
ConsoleTransport | Writes log records to the console. | @studnicky/logger |
FunctionTransport | Delivers log records to a supplied function. | @studnicky/logger |
MemoryTransport | Captures log records in memory. | @studnicky/logger |
NoOpTransport | Discards log records. | @studnicky/logger |
TransportInterface | Defines the contract for custom transports. | @studnicky/logger |
ParseLogLevel | Normalizes named and numeric log levels. | @studnicky/logger |
EVENT_COMPONENTS | Provides supported event-component values. | @studnicky/logger |
LOG_LEVEL | Provides numeric log-level values. | @studnicky/logger |
LOG_STATUS | Provides supported log-status values. | @studnicky/logger |
STATUS_CATEGORIES | Groups log statuses for result filtering. | @studnicky/logger |
CircularReferenceError | Represents circular-reference serialization failures. | @studnicky/logger |
ConfigurationError | Represents invalid logger configuration. | @studnicky/logger |
FileDestinationError | Represents file transport destination failures. | @studnicky/logger |
InvalidLogLevelError | Represents invalid log-level configuration. | @studnicky/logger |
LogBuildError | Represents invalid log-entry construction. | @studnicky/logger |
LogStatusEntity | Provides the schema and type for structured log statuses. | @studnicky/logger |
LoggerError | Base 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.