@pawells/logger
v4.1.0
Published
Structured logging library with configurable transports, log levels, and contextual metadata
Maintainers
Readme
@pawells/logger
Description
Structured logging library for TypeScript/Node.js with multiple transports, composable predicate-based filters, pluggable formatters, and zero runtime dependencies. ESM-only, targeting Node.js 22+.
Requirements
- Node.js >= 22.0.0
Installation
npm install @pawells/logger
# or
yarn add @pawells/loggerQuick Start
import {
Logger,
LogManager,
ConsoleTransport,
LogLevelFilter,
LogLevels,
} from '@pawells/logger';
// Optional: set application-level context and metadata included in every entry
LogManager.Context = 'my-app';
LogManager.Metadata = { version: '4.0.0', environment: 'production' };
// Create a transport and register it to start receiving entries
const transport = new ConsoleTransport({
filters: [LogLevelFilter(LogLevels.INFO)],
});
transport.Register();
// Create a logger with an optional context identifier
const logger = new Logger('user-service');
// Log messages at any severity level
logger.verbose('Trace-level detail', { step: 'validation-start' });
logger.debug('Processing request', { requestId: 'req-123' });
logger.info('Application started', { port: 3000 });
logger.warn('Memory usage is high', { usage: 85 });
logger.error('Request failed', new Error('Connection refused'));
logger.fatal('System critical error', { errno: 'EACCES' });
// Flush async transports before shutdown
process.on('SIGTERM', async () => {
await LogManager.Flush();
process.exit(0);
});API Reference
Architecture
Logger constructs a TLogEntry and posts it via the static LogManager. LogManager is a singleton event bus that distributes entries to all registered LogTransport instances. Each transport maintains an independent filter chain; all predicates in the chain must return true (AND logic, short-circuit) for an entry to reach the transport's OnPosted handler.
ConsoleTransport, StreamTransport, and MemoryTransport require explicit registration via transport.Register() or LogManager.RegisterTransport(). They do not self-register. The @pawells/logger-transport-file sibling package provides a FileTransport that self-registers in its constructor.
Classes
| Class | Import | Description |
|---|---|---|
| Logger | @pawells/logger | High-level interface for emitting log entries at any severity level |
| LogManager | @pawells/logger | Static singleton event bus; distributes entries to transports |
| LogManagerInstance | @pawells/logger | Instance-scoped equivalent of LogManager; useful for test isolation and multi-tenant use |
| LogTransport | @pawells/logger | Abstract base class for custom transport implementations |
| ConsoleTransport | @pawells/logger | Outputs entries to the console (stdout/stderr by level) |
| StreamTransport | @pawells/logger | Writes entries to any writable stream (defaults to process.stderr) |
| MemoryTransport | @pawells/logger | Captures entries in memory; designed for unit testing |
| LogFormatter | @pawells/logger | Abstract base class for custom formatter implementations |
| TextLogFormatter | @pawells/logger | Formats entries as human-readable text |
| JSONLogFormatter | @pawells/logger | Formats entries as single-line JSON |
Logger
High-level interface for emitting log entries. Each instance carries an optional context string array that is merged into every entry it produces.
Constructor
constructor(context?: string | string[], metadata?: Record<string, unknown>)context— optional label(s) for this logger instance. Merged withLogManager.Contextwhen entries are posted.metadata— optional default metadata merged into all entries from this logger.
Log methods
logger.verbose(message: string, metadata?: unknown): void
logger.debug(message: string, metadata?: unknown): void
logger.info(message: string, metadata?: unknown): void
logger.warn(message: string, metadata?: unknown): void
logger.error(message: string, metadata?: unknown): void // errors are suppressed internally
logger.fatal(message: string, metadata?: unknown): void // errors are suppressed internallymetadata is passed through NormalizeMetadata before being stored in the entry. See Metadata normalization.
Caveat: if metadata is a plain object with a top-level context or metadata key, it is instead interpreted as a per-call { context?, metadata? } override (the inner metadata value is what gets normalized and stored, not the wrapper object). Use logger.Post() for unambiguous control if your metadata payload legitimately has a top-level context or metadata field.
Context and metadata methods
logger.SetContext(context: string | string[]): void
logger.GetContext(): string[]
logger.SetMetadata(metadata: Record<string, unknown>, options?: { merge?: boolean }): void
logger.GetMetadata(): Readonly<Record<string, unknown>>SetMetadata replaces existing metadata by default. Pass { merge: true } to merge with existing values.
Post
logger.Post(entry: TLogEntry): voidPosts a fully-formed TLogEntry directly. The logger merges its own context and metadata before forwarding to LogManager. The caller's entry object is not mutated.
LogManager
Static singleton event bus. Maintains application-level context and metadata that are prepended and merged into every log entry before distribution.
Properties
LogManager.Context = context: string | string[] // setter
LogManager.Context // getter → readonly string[]Application-level context prepended to every entry's context array. Accepts a string or array; the getter always returns an array.
LogManager.Metadata = metadata: Record<string, unknown> | (() => Record<string, unknown> | undefined) // setter
LogManager.Metadata // getter → Readonly<Record<string, unknown>>Application-level metadata merged into every entry's metadata. To merge non-destructively:
LogManager.Metadata = { ...LogManager.Metadata, newKey: 'value' };The setter also accepts a callback, invoked fresh on every Post() call, for metadata that must be computed per-entry (e.g. from AsyncLocalStorage or an active OpenTelemetry span) without the library depending on those packages:
LogManager.Metadata = () => {
const span = trace.getActiveSpan();
return span ? { traceId: span.spanContext().traceId } : undefined;
};Keep the callback cheap and synchronous — it runs on every Post(). Errors thrown by the callback are suppressed (logged to process.stderr); the affected entry falls back to an empty object rather than crashing the caller. While a callback is set, the Metadata getter returns {} — the value is only computed at Post() time, not cached.
Static methods
LogManager.Post(entry: TLogEntry): voidDistributes an entry to all registered transports. Creates an internal clone with merged context and metadata — the caller's entry object is not mutated.
LogManager.RegisterTransport(transport: LogTransport, name?: string): void
LogManager.UnregisterTransport(transport: LogTransport): voidRegister or unregister a transport. Prefer calling transport.Register(name?) on the instance — it delegates here. Unregistration is idempotent; if the transport is not found, the call returns silently.
LogManager.GetTransport<T>(name?: string): T | undefined
LogManager.GetTransports<T>(name?: string): T[]Retrieve registered transports by optional name. GetTransport returns the first match or undefined; GetTransports returns all matches or an empty array.
LogManager.Flush(): Promise<void>Awaits Flush() on every registered transport. Use before application shutdown to ensure async transports (such as FileTransport) have written all pending entries. Errors from individual transports are suppressed so one failure does not block others.
LogManager.Reset(): voidClears application-level context and metadata and unregisters all transports. Intended for test teardown only; do not call in production code.
LogManagerInstance
An instance-scoped alternative to the static LogManager. Maintains its own context, metadata, and transport registry, making it suitable for test isolation or multi-tenant scenarios where independent logging pipelines must not share state.
The API mirrors LogManager exactly, but as instance methods rather than static methods.
import { LogManagerInstance, ConsoleTransport } from '@pawells/logger';
const logManager = new LogManagerInstance();
logManager.Context = 'tenant-a';
logManager.Metadata = { version: '1.0.0' };
const transport = new ConsoleTransport();
logManager.RegisterTransport(transport, 'console');
logManager.Post(entry);
await logManager.Flush();
logManager.Reset(); // clean up in afterEachInstance methods: Post, RegisterTransport, UnregisterTransport, GetTransport<T>, GetTransports<T>, Flush, Reset.
LogLevels Enum
enum LogLevels {
VERBOSE = 'verbose', // severity 5 — highly detailed trace diagnostics
DEBUG = 'debug', // severity 10
INFO = 'info', // severity 20
WARN = 'warn', // severity 30
ERROR = 'error', // severity 40
FATAL = 'fatal', // severity 50
SILENT = 'silent', // severity Infinity — suppresses all output when used as threshold
}Severity order (lowest to highest): VERBOSE (5) < DEBUG (10) < INFO (20) < WARN (30) < ERROR (40) < FATAL (50) < SILENT (Infinity).
Transports
All built-in transports must be explicitly registered after construction. None self-register.
ConsoleTransport
Routes entries to the console. DEBUG/INFO go to console.log, WARN to console.warn, ERROR/FATAL to console.error. Defaults to TextLogFormatter.
import { ConsoleTransport, JSONLogFormatter, LogLevelFilter, LogLevels } from '@pawells/logger';
// All entries, default text format
const transport = new ConsoleTransport();
transport.Register();
// Warn and above, JSON format
const jsonTransport = new ConsoleTransport({
formatter: new JSONLogFormatter(),
filters: [LogLevelFilter(LogLevels.WARN)],
});
jsonTransport.Register('console-json');IConsoleTransportOptions:
| Field | Type | Default | Description |
|---|---|---|---|
| formatter | LogFormatter | TextLogFormatter | Formatter instance to use |
| filters | LogEntryPredicate[] | none | Filter predicates; all must pass (AND logic) |
StreamTransport
Writes formatted entries to any writable stream. Defaults to process.stderr. Useful for servers that reserve stdout for protocol output (MCP, JSON-RPC, LSP, etc.).
import { StreamTransport, LogLevelFilter, LogLevels } from '@pawells/logger';
const transport = new StreamTransport({
filters: [LogLevelFilter(LogLevels.WARN)],
});
transport.Register();
// Custom writable stream
const streamTransport = new StreamTransport({ stream: myWritableStream });
streamTransport.Register('my-stream');IStreamTransportOptions:
| Field | Type | Default | Description |
|---|---|---|---|
| formatter | LogFormatter | TextLogFormatter | Formatter instance to use |
| stream | IWritableStream | process.stderr | Writable stream to write to |
| filters | LogEntryPredicate[] | none | Filter predicates; all must pass (AND logic) |
MemoryTransport
Captures entries in memory without any I/O. Designed for unit testing. See Testing for a full example.
import { MemoryTransport } from '@pawells/logger';
const transport = new MemoryTransport();
transport.Register();Methods:
| Method | Returns | Description |
|---|---|---|
| GetLogs() | readonly IMemoryLogEntry[] | All captured entries |
| GetEntryCount() | number | Count of captured entries |
| Clear() | void | Discards all captured entries |
IMemoryTransportOptions:
| Field | Type | Default | Description |
|---|---|---|---|
| formatter | LogFormatter | TextLogFormatter | Formatter instance to use |
| filters | LogEntryPredicate[] | none | Filter predicates; all must pass (AND logic) |
| maxEntries | number | 10000 | Maximum number of entries to retain. When exceeded, the oldest entry is evicted (FIFO). Must be a positive integer when provided. |
LogTransport Abstract Class
Extend this to create a custom transport. Do not call Register() inside the constructor — leave registration to the caller.
import {
LogTransport,
ILogTransportOptions,
type TLogEntry,
LogLevelFilter,
LogLevels,
LogManager,
Logger,
} from '@pawells/logger';
interface IWebhookTransportOptions extends ILogTransportOptions {
endpoint: string;
}
class WebhookTransport extends LogTransport<IWebhookTransportOptions> {
constructor(options: IWebhookTransportOptions) {
super(options);
// Do not call Register() here — the caller registers after construction
}
public async OnPosted(entry: TLogEntry): Promise<void> {
await fetch(this.Options.endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ level: entry.level, message: entry.message }),
});
}
}
const webhook = new WebhookTransport({
endpoint: 'https://example.com/logs',
filters: [LogLevelFilter(LogLevels.ERROR)],
});
webhook.Register('webhook');
const logger = new Logger('api');
logger.error('Unhandled exception', new Error('Disk full'));Runtime-mutable options:
Transport options can be updated after construction via the Options setter. Read the current configuration with the getter, then assign through the setter so subclass validation is not bypassed:
const current = transport.Options;
transport.Options = { ...current, filters: [LogLevelFilter(LogLevels.ERROR)] };Flush() hook:
Override Flush(): Promise<void> if your transport buffers entries and needs to drain before shutdown. The default implementation is a no-op.
Formatters
TextLogFormatter
Formats entries as human-readable text:
[2026-05-01T12:00:00.000Z] [my-app] [INFO] [user-service] User authenticatedimport { TextLogFormatter } from '@pawells/logger';
const formatter = new TextLogFormatter({
useColor: true, // apply ANSI color codes to the level field (default: false)
includeMetadata: true, // append JSON-serialized metadata (default: false)
});ITextLogFormatterOptions:
| Field | Type | Default | Description |
|---|---|---|---|
| useColor | boolean | false | Apply ANSI color codes to the level field |
| includeMetadata | boolean | false | Append serialized metadata to the output line |
JSONLogFormatter
Formats entries as a single JSON line:
{"timestamp":"2026-05-01T12:00:00.000Z","level":"info","context":["my-app","user-service"],"message":"User authenticated","metadata":{"userId":"123"}}import { JSONLogFormatter } from '@pawells/logger';
const formatter = new JSONLogFormatter({ includeMetadata: true });IJSONLogFormatterOptions:
| Field | Type | Default | Description |
|---|---|---|---|
| includeMetadata | boolean | true | Include the metadata field in output |
LogFormatter Abstract Class
Extend this to create a custom formatter. Implement the single Format(entry: TLogEntry): string method.
Filters and Predicates
LogEntryPredicate
type LogEntryPredicate = (entry: TLogEntry) => boolean;A function that receives a TLogEntry and returns true to allow the entry to proceed to the transport, or false to drop it. Multiple predicates on a transport are evaluated in order with AND logic and short-circuit on the first false.
// Filter by context
const contextFilter: LogEntryPredicate = (entry) =>
entry.context.includes('payment');
// Filter by metadata field
const envFilter: LogEntryPredicate = (entry) =>
entry.metadata['environment'] === 'production';LogLevelFilter
function LogLevelFilter(minLevel: LogLevels): LogEntryPredicateCreates a predicate that passes entries at or above minLevel. This is the standard way to apply a minimum severity threshold to a transport.
import { LogLevelFilter, LogLevels, ConsoleTransport } from '@pawells/logger';
const transport = new ConsoleTransport({
filters: [LogLevelFilter(LogLevels.WARN)],
});
transport.Register();
// Only WARN, ERROR, and FATAL entries reach this transportTLogEntry
The canonical log record passed to all transports. A Zod-inferred type alias (not a hand-written interface).
type TLogEntry = {
timestamp: Date; // time the entry was created
level: LogLevels; // severity level
context: string[]; // merged context chain (LogManager + Logger)
message: string; // log message text
metadata: Record<string, unknown>; // merged structured metadata
};TLogEntry is derived from LOG_ENTRY_SCHEMA via z.infer<typeof LOG_ENTRY_SCHEMA>. Use the schema directly when you need runtime validation of untrusted input:
import { LOG_ENTRY_SCHEMA } from '@pawells/logger';
const result = LOG_ENTRY_SCHEMA.safeParse(untrustedInput);
if (result.success) {
// result.data is TLogEntry
}Metadata normalization
NormalizeMetadata(metadata: unknown): Record<string, unknown> | undefined
Logger log methods accept any value as metadata and apply the following rules before including it in the entry:
| Input | Result |
|---|---|
| null / undefined | omitted (field absent) |
| Empty plain object {} | omitted (field absent) |
| Error instance | { error: message, name, stack } |
| Array or primitive | { value: metadata } |
| Plain object with Error values | Error fields normalized in-place; other fields passed through |
| Non-empty plain object | passed through as-is |
NormalizeMetadata is also exported for use in custom transports and formatters.
Level utilities
| Function | Signature | Description |
|---|---|---|
| LogLevelsFromString | (level: string) => LogLevels | Parses a case-insensitive string to LogLevels; throws on unknown input |
| LogLevelsToString | (level: LogLevels) => string | Returns the string value of a LogLevels enum member |
| LogLevelToNumber | (level: LogLevels) => number | Maps a level to its numeric severity (VERBOSE=5 … SILENT=Infinity) |
Validation utilities
These exported functions are useful when building custom transports or integrating with validation pipelines.
| Function | Throws | Description |
|---|---|---|
| AssertLogEntryPredicate(value) | TypeError | Asserts value is a function (valid LogEntryPredicate) |
| AssertLogTransportOptions(value) | TypeError | Asserts value is a valid ILogTransportOptions object |
| ValidateLogTransportOptions(value) | — | Non-throwing alternative; returns boolean |
| AssertConsoleTransportOptions(value) | TypeError | Asserts value is valid IConsoleTransportOptions |
| ValidateConsoleTransportOptions(value) | — | Non-throwing alternative; returns boolean |
| AssertStreamTransportOptions(value) | TypeError | Asserts value is valid IStreamTransportOptions |
| ValidateStreamTransportOptions(value) | — | Non-throwing alternative; returns boolean |
| AssertMemoryTransportOptions(value) | TypeError | Asserts value is valid IMemoryTransportOptions |
| ValidateMemoryTransportOptions(value) | — | Non-throwing alternative; returns boolean |
All exported types
import {
// Core classes
Logger,
LogManager,
LogManagerInstance,
LogTransport,
// Transports
ConsoleTransport,
StreamTransport,
MemoryTransport,
// Formatters
LogFormatter,
TextLogFormatter,
JSONLogFormatter,
// Types
type TLogEntry,
type LogEntryPredicate,
type ILogTransportOptions,
type IConsoleTransportOptions,
type IStreamTransportOptions,
type IWritableStream,
type IMemoryLogEntry,
type IMemoryTransportOptions,
type ITextLogFormatterOptions,
type IJSONLogFormatterOptions,
type ILogPostedEvent,
type TLogPostedEventHandler,
// Schema
LOG_ENTRY_SCHEMA,
// Enum
LogLevels,
// Functions
LogLevelFilter,
NormalizeMetadata,
LogLevelsFromString,
LogLevelsToString,
LogLevelToNumber,
// Validation guards
AssertLogEntryPredicate,
AssertLogTransportOptions,
ValidateLogTransportOptions,
AssertConsoleTransportOptions,
ValidateConsoleTransportOptions,
AssertStreamTransportOptions,
ValidateStreamTransportOptions,
AssertMemoryTransportOptions,
ValidateMemoryTransportOptions,
} from '@pawells/logger';Testing
MemoryTransport is the recommended approach for unit tests. It captures all entries without any I/O, supports the same filter system as production transports, and gives you direct access to both the raw TLogEntry and the formatted string output.
Always unregister the transport in afterEach to prevent accumulation across tests. Use LogManager.Reset() to clear context and metadata in a single call.
import {
MemoryTransport,
Logger,
LogManager,
LogLevels,
} from '@pawells/logger';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
describe('user-service', () => {
let transport: MemoryTransport;
beforeEach(() => {
transport = new MemoryTransport();
transport.Register();
});
afterEach(() => {
LogManager.UnregisterTransport(transport);
LogManager.Context = [];
LogManager.Metadata = {};
});
it('logs the expected message at INFO level', () => {
const logger = new Logger('user-service');
logger.info('User authenticated', { userId: '42' });
expect(transport.GetEntryCount()).toBe(1);
const [log] = transport.GetLogs();
expect(log.entry.message).toBe('User authenticated');
expect(log.entry.level).toBe(LogLevels.INFO);
expect(log.entry.metadata).toEqual({ userId: '42' });
});
it('normalizes Error metadata', () => {
const logger = new Logger('user-service');
logger.error('Auth failed', new Error('Token expired'));
const [log] = transport.GetLogs();
expect(log.entry.metadata).toHaveProperty('error', 'Token expired');
expect(log.entry.metadata).toHaveProperty('name', 'Error');
});
});Tip: Use LogManagerInstance in tests that need fully isolated logging state:
const logManager = new LogManagerInstance();
const transport = new MemoryTransport();
logManager.RegisterTransport(transport);
// logManager posts do not appear in global LogManager transports
afterEach(() => logManager.Reset());Development
Run these commands from the repository root:
yarn nx run @pawells/logger:typecheck # type check
yarn nx run @pawells/logger:lint # lint
yarn nx run @pawells/logger:lint -- --fix # lint with auto-fix
yarn nx run @pawells/logger:test # run tests
yarn nx run @pawells/logger:test -- --coverage # tests with coverage report
yarn nx run @pawells/logger:build # compile TypeScript -> dist/Compiled output is written to packages/logger/dist/. The dist/ directory is gitignored and included in the npm files array.
License
MIT — see LICENSE for details.
