@studnicky/logger
Pluggable logging interface with portable transport architecture, child loggers, and metadata support.
What it is
A composable structured-logging primitive with immutable records, pluggable transports, child bindings, and protected observation hooks. It supplies log mechanics and contracts without selecting an observability vendor or application telemetry policy.
What it is for
Northstar Books uses it to attach consistent component, operation, and order context to catalogue and fulfilment events before sending them to application-selected transports. Consumers choose retention, destinations, levels, and privacy policy.
Northstar Books examples
The runnable lifecycle-hooks example emits and filters structured records while observing fan-out and transport errors. It maps to Northstar Books auditing a fulfilment operation, proving that instrumentation can see logging behavior without altering the logger's public API or coupling it to a specific collector.
Public entrypoints
| Import path | Use it when |
|---|---|
@studnicky/logger/node | A Northstar Books server creates structured logs and selects server-side transports. |
@studnicky/logger/browser | A storefront browser writes portable structured records through browser-safe transports. |
@studnicky/logger/entities | An adapter validates serializable log records, bodies, faults, levels, and statuses. |
@studnicky/logger/interfaces | TypeScript code shares logger, metadata, transport, and request contracts. |
Install
pnpm add @studnicky/loggerRequires @studnicky:registry=https://npm.pkg.github.com in .npmrc.
Usage
A logger is only as useful as the questions it can answer later, so Northstar wants every entry to carry the same shape: a component, an operation, a status, a message. This example builds a Logger with an in-memory transport, writes one successful LogBody entry and one LogFault entry, clears the buffer, then creates a child logger and confirms its own metadata (service: 'auth') rides along on every record it writes — all without the component code ever knowing where the logs actually end up:
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 /browser entrypoint in browser applications and the /node entrypoint in Node.js applications. 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 from the selected runtime entrypoint.
Fan-out and level filtering
One request might need a full debug log and only its warnings and errors sent to an alerting channel — Logger doesn't force a choice between the two. This example attaches two transports with different level floors to one logger, sends an info that only the permissive transport keeps, then a warn that both keep, and finally logs through a child logger to confirm its metadata still reaches every transport the parent has, not just the one it was created from:
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
Not every destination wants one record at a time — a transport shipping logs over the network usually wants to batch them first. This example writes a small BufferedTransport that implements TransportInterface directly, uses ParseLogLevel.parse() to accept its minimum level as a plain string, and only calls its sink once two records have accumulated. The assertions confirm the first log alone doesn't trigger a flush, but the second one does:
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
The point of a hook is that nothing needs to touch Logger itself to see what it's doing — subclass it, override one of the four protected methods below, and there's a front-row seat to every record assembled, every record dropped, every child created, and every transport that throws.
| 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 } from '../src/interfaces/index.js';
import { FunctionTransport, LogBody, Logger } from '../src/index.js';
// ---------------------------------------------------------------------------
// ObservedLogger — records every Logger lifecycle event
// ---------------------------------------------------------------------------
interface LogEventInterface {
readonly 'bindings'?: LogRecordEntity.Type['metadata'];
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: LogRecordEntity.Type['metadata']): 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/node |
LogBody | Creates validated immutable non-fault log entries. | @studnicky/logger/node |
LogFault | Creates validated immutable fault log entries. | @studnicky/logger/node |
ConsoleTransport | Writes log records to the console. | @studnicky/logger/node |
FunctionTransport | Delivers log records to a supplied function. | @studnicky/logger/node |
MemoryTransport | Captures log records in memory. | @studnicky/logger/node |
NoOpTransport | Discards log records. | @studnicky/logger/node |
TransportInterface | Defines the contract for custom transports. | @studnicky/logger/interfaces |
ParseLogLevel | Normalizes named and numeric log levels. | @studnicky/logger/node |
EVENT_COMPONENTS | Provides supported event-component values. | @studnicky/logger/node |
LOG_LEVEL | Provides numeric log-level values. | @studnicky/logger/node |
LOG_STATUS | Provides supported log-status values. | @studnicky/logger/node |
STATUS_CATEGORIES | Groups log statuses for result filtering. | @studnicky/logger/node |
CircularReferenceError | Represents circular-reference serialization failures. | @studnicky/logger/node |
ConfigurationError | Represents invalid logger configuration. | @studnicky/logger/node |
FileDestinationError | Represents file transport destination failures. | @studnicky/logger/node |
InvalidLogLevelError | Represents invalid log-level configuration. | @studnicky/logger/node |
LogBuildError | Represents invalid log-entry construction. | @studnicky/logger/node |
LogSerializationError | Represents a value the platform JSON serializer rejects; the platform error is the cause. | @studnicky/logger/node |
LogStatusEntity | Provides the schema and type for structured log statuses. | @studnicky/logger/entities |
LoggerError | Base error for logger failures. | @studnicky/logger/node |
Try it
The examples below run in the browser via the embedded playground.
Lifecycle hooks
Run this one to watch a logger instrument itself in real time: an info call fires onLog, a debug call below the configured floor fires onDropped instead, creating a child logger fires onChildCreate, and — because the only transport attached here throws on every write — onTransportError fires too, catching the failure rather than letting it take down the rest of the call. None of this required touching Logger's public surface.