@zudojs/logger
Structured logging with transports, formatters, context propagation, child loggers, and lifecycle management. Every log entry is a typed, serialized object flowing through a configurable pipeline.
INSTALLATION
@zudojs/logger depends on @zudojs/errors for its error hierarchy (LoggingError, LoggerTransportError, etc.).
WHAT IT DOES
@zudojs/logger is the structured logging infrastructure for Zudojs. It provides:
- → Structured logging — every log entry is a typed
LoggerEntryobject, not a plain string - → Six log levels — FATAL, ERROR, WARN, INFO, DEBUG, TRACE with numeric severity ordering
- → Multiple transports — console, buffered, conditional, and multi-transport composition
- → Formatters — JSON, text, compact, structured, development, and production formatters
- → Context propagation — correlationId, requestId, traceId, spanId, userId, tenantId, sessionId, jobId, moduleId, operationId
- → Child loggers — inherit parent configuration and merge metadata
- → Factory pattern —
LoggerFactorymanages logger creation, caching, and lifecycle - → Lifecycle management — enable/disable, flush, close, and dispose operations
- → Buffered transport — batch entries before forwarding with configurable size and flush interval
- → Conditional transport — forward entries only when a predicate passes
- → Async logging — optional asynchronous dispatch with configurable transport timeout
WHERE IT SITS
@zudojs/logger sits in the infrastructure layer. Application code and feature packages create loggers. The logger formats entries and dispatches them to transports. Its only internal dependency is @zudojs/errors for the error hierarchy.
DEPENDENCIES
| Package | Version | Purpose |
|---|---|---|
| @zudojs/errors | Same release (workspace:*) |
Error hierarchy (LoggingError, LoggerTransportError, LoggerFormatterError, etc.) |
| Dev dependencies: typescript 7.x, vitest | ||
workspace:*, always — including on main. They are never hand-pinned to an exact version. At publish time pnpm rewrites each workspace:* to the exact version of that package in the same release, so a published tarball carries real ranges. Releases go out through publish-all.sh, which runs pnpm -r publish — it rewrites the ranges and publishes in dependency order. Plain npm publish does not understand the workspace: protocol and would ship a literal workspace:* to the registry.
LOG LEVELS
Six severity levels with numeric ordering. Lower values represent more severe messages. The logger compares messageLevel <= threshold to decide whether to emit.
Enum: LoggerLevel
Type: LoggerLevelName
Type: LoggerLevelLike
Wherever you configure a level (createLogger({ level }), setLevel(), child({ level })), you can pass the enum value or its name in any case. "warning" and "information" also work, as aliases of "warn" and "info".
Utility Functions
| Function | Signature | Returns |
|---|---|---|
| loggerLevelToName | (level: LoggerLevel) => LoggerLevelName | Converts numeric level to canonical string name |
| loggerLevelFromName | (name: string) => LoggerLevel | Converts string name to enum value (accepts "warning", "information") |
| resolveLoggerLevel | (level: LoggerLevelLike) => LoggerLevel | Turns an enum value or a name in any case into the enum value; throws InvalidLoggerLevelError for anything else |
| shouldLog | (threshold, messageLevel) => boolean | True if message should be emitted at the given threshold |
| getLoggerLevels | () => readonly LoggerLevel[] | Returns all six canonical levels |
| getLoggerLevelNames | () => readonly LoggerLevelName[] | Returns all six canonical level names |
LoggerLevel.WARN (2) emits FATAL (0), ERROR (1), and WARN (2). It suppresses INFO (3), DEBUG (4), and TRACE (5).
Setting a level by name
Levels often come from an environment variable such as LOG_LEVEL=debug, so a name is accepted anywhere a level is. The logger stores the enum value, which is why logger.level prints a number.
createLogger() and child() throw InvalidLoggerLevelError; setLevel() throws LoggerConfigurationError. A typo such as LOG_LEVEL=verbose now fails at startup rather than leaving you with an empty log.
LOGGER INTERFACE
The core contract every logger implementation satisfies. Application code depends on this interface, not concrete classes.
Interface: Logger
Methods
| Method | Description |
|---|---|
| fatal / error / warn / info / debug / trace | Log at the corresponding severity level with optional metadata. To log a caught Error with its stack, see Logging errors |
| log(level, message, options?) | Dynamic level logging with extended options (error, metadata, source, context). The typed way to log an Error together with metadata |
| child(options?) | Creates a child logger inheriting configuration and merging metadata; its level may also be a name |
| withContext(context) | Returns a scoped logger that injects context into every entry |
| setLevel(level) | Changes the log level threshold at runtime. Takes LoggerLevel.DEBUG or a name such as "debug" / "DEBUG" |
| enable() / disable() | Toggle the logger on/off without destroying it |
| flush() | Waits for all in-flight writes (including child loggers') and flushes every transport; one failing transport does not stop the others |
| close() | Flushes and releases all transport resources |
CREATING LOGGERS
Function: createLogger
Interface: LoggerOptions
| Option | Type | Default | Description |
|---|---|---|---|
| name | string | "zudojs" | Logger identifier in log entries |
| level | LoggerLevelLike | INFO (3) | Minimum severity threshold: LoggerLevel.WARN, or a name in any case such as "warn". An unknown value throws |
| environment | string | undefined | Environment name (development, production, etc.) |
| metadata | LoggerContextData | {} | Default metadata merged into every entry |
| formatter | LoggerFormatterLike | "text" | Formatter for converting entries to output |
| transports | LoggerTransportLike[] | [] (writes to the console) | Destinations for formatted log entries. An empty or missing list does not silence the logger: it writes through a console transport. To discard output, pass a transport that does nothing, or call disable() |
| enabled | boolean | true | Whether the logger starts enabled |
| throwTransportErrors | boolean | false | Whether to throw on transport failures; failures from asynchronous transports are rethrown by the next flush()/close() |
| asynchronous | boolean | false | Enable async transport dispatch |
| transportTimeout | number | 10000 | Timeout in ms for async transport operations |
| inheritContext | boolean | true | Whether child loggers inherit parent context |
| mutable | boolean | true | Whether the logger configuration can be changed at runtime |
| redact | LoggerRedactionOptions | {} | Secret redaction for metadata and context: enabled (true), keys, pattern, replacement ("[REDACTED]") |
Constants & Helpers
| Export | Description |
|---|---|
| DEFAULT_LOGGER_OPTIONS | Default values for boolean/numeric logger options |
| resolveLoggerOptions(options) | Validates and normalizes partial options into a full LoggerConfiguration |
| validateLoggerOptions(options) | Throws TypeError/RangeError if options are invalid |
| mergeLoggerOptions(base, override) | Merges two option objects (override wins) |
Example: Basic Logger
With no transports the logger writes to the console, one formatted line per call, using the default text formatter:
LOGGING ERRORS
When something throws, you usually want two things in the log: the error itself, with its stack trace (the list of function calls that led to it), and a few facts about what you were doing, such as an order id. The level methods take a message and a metadata object, so the typed way to send both is logger.log(level, message, { error, metadata }).
What you should see: the formatted line, then the stack on the lines below it (the file's directory is shortened to … here; yours shows the full path).
The level methods keep a single signature, logger.error(message, metadata?), so every custom Logger implementation and every structural logger type ({ warn(message, context?) }) still matches. JavaScript, or loosely typed TypeScript, often passes the caught error straight in as the second argument: logger.error("payment failed", err). Since 1.4.0 that call logs err as the entry's error, stack included, exactly like the log() call above (minus the metadata). It used to be read as metadata, and because an Error has no enumerable fields it vanished from the log without a trace.
log(LoggerLevel.ERROR, ..., { error, metadata }). It type-checks, and it keeps the error and your metadata in one entry. Putting the error inside metadata ({ err }) also works, but then it is serialized as a metadata field ({ name, message, stack }) rather than recorded as the entry's error.
Hiding stack traces
Stack frames contain absolute file paths from the machine the code runs on, which you may not want in logs that leave the server. Build the text formatter with includeStackTrace: false and every error is printed as its name and message only: the entry's own error, and any Error you put inside metadata.
Changed in 1.4.0: the option used to drop only the stack printed under the line; the fallback still wrote the stack, paths included, and an Error in metadata always carried its stack. With the default (true) nothing changes: the stack follows the line, and a metadata error prints as {"name":…,"message":…,"stack":…}.
CHILD LOGGERS
Child loggers inherit parent configuration (transports, formatter, level) and merge metadata. Every entry from a child logger includes both parent and child metadata.
Interface: ChildLoggerOptions
ChildLoggerOptions keeps the enum, so a custom Logger implementation written against it still compiles. Such an implementation may now be handed a name like "debug"; pass options.level through resolveLoggerLevel() to convert it.
Example: Child Loggers
Class: ContextLogger
A logger wrapper that provides scoped context. Every log call runs within the stored context.
LOG CONTEXT
A context is a set of correlation identifiers (request id, trace id, user id…) plus metadata that you want on every entry while handling one piece of work. Build one with createLoggerContext and attach it with logger.withContext(context), which returns a scoped logger; every entry that logger writes carries the context.
Interface: LoggerContext
Interface: LoggerContextIdentifiers
Interface: LoggerContextData
Interface: LoggerContextOptions
Context Functions
| Function | Description |
|---|---|
| createLoggerContext(options?) | Creates a frozen LoggerContext from LoggerContextOptions; parent values are inherited and overridden |
| mergeLoggerContexts(base, override) | Merges two contexts (override identifiers and metadata win) |
| withLoggerContext(logger, context, callback) | Calls callback(scoped) with logger.withContext(context) and returns its result |
| isLoggerContext(value) | Type guard checking for identifiers and metadata properties |
LOG ENTRY
Every log call produces a LoggerEntry — a frozen, typed object containing all data for that log event.
Interface: LoggerEntry
Interface: LoggerEntryInput
Related Types
| Type | Description |
|---|---|
| LogMetadata | Readonly record of string keys to LogValue |
| LogValue | string, number, boolean, bigint, null, undefined, Date, Error, arrays, objects. Map and Set are handled at runtime but are not named in the union, so TypeScript needs a cast to put one in metadata |
| LoggerSource | Service, component, module, file, function, line |
| LoggerEntryContext | correlationId, requestId, traceId, spanId, userId, tenantId, metadata |
Metadata Serialization
Metadata and context go through two passes. Redaction runs when the entry is created, before it is frozen, so no formatter and no transport can ever see an unmasked secret. Serialization runs inside the JSON and structured formatters, turning the values that are left into JSON-safe ones. Both walks descend through nested objects, arrays, Map, Set and getters, and both share the same cycle guard.
| Value | Becomes | Pass |
|---|---|---|
| Map | A plain object, one property per entry; a secret-named key is redacted | redaction |
| Set | An array of its members | redaction |
| bigint | Its decimal string | serialization |
| Date | An ISO-8601 string | serialization |
| Error | { name, message, stack }; without stack when the text formatter has includeStackTrace: false |
serialization |
| function | "[Function name]", or "[Function anonymous]" | serialization |
| symbol | "Symbol(description)" | serialization |
| A secret-named field | "[REDACTED]" (LOGGER_REDACTION_TOKEN) |
redaction |
| A getter that throws | "[Unreadable]" (LOGGER_UNREADABLE_TOKEN) |
both |
| A back-edge to an enclosing object | "[Circular]" | both |
Changed in 1.3.0.
- →
MapandSetkeep their contents. They used to collapse to{}, so aheadersMap or atagsSet reached the transport empty. - → The cycle guard tracks the ancestor path only, instead of every object it has ever seen.
{ actor: user, target: user }now logsuserin both fields; only a genuine back-edge — an object that contains itself — becomes"[Circular]". This applies to redaction, to serialization and to the JSON formatter alike. - → A metadata getter that throws no longer propagates out of
logger.info(...)and aborts your call. The field becomes"[Unreadable]", the entry is still logged, and the read failure is reported like any other infrastructure failure — dropped by default, rethrown whenthrowTransportErrorsis on.
If you write your own formatter, the same serializers are exported. Both take an optional flag to leave stacks out: serializeLoggerError(error, false) returns { name, message }, and serializeLoggerValue(value, undefined, false) strips the stack from every Error it finds inside value.
Creating Entries
FORMATTERS
Formatters convert a LoggerEntry into output — either a string or a structured object. A transport always receives a LoggerEntry; the formatter's result travels with it in entry.formatted, while entry.message stays the message you logged.
Interface: LoggerFormatter
Interface: LoggerFormatterContext
Built-in Formatters
| Function | Output | Description |
|---|---|---|
| createJsonLoggerFormatter() | string | JSON output with optional pretty-printing |
| createTextLoggerFormatter() | string | Human-readable text with timestamps, context, metadata |
| createCompactLoggerFormatter() | string | Minimal output: LEVEL logger: message |
| createDevelopmentLoggerFormatter() | string | Full details: timestamp, logger, message, context, source, stack |
| createProductionLoggerFormatter() | string | JSON output (alias for JSON formatter) |
| createStructuredLoggerFormatter() | Record | Returns the serialized entry as an object, merged over the entry the transport receives; the console transport prints it as one JSON line |
Formatter Output
| The formatter returns | What the transport receives |
|---|---|
| a string | The entry, with that string in formatted. message is unchanged |
| a plain object | The entry with the returned fields merged over it (same-named fields win), and formatted set to the object as one JSON line |
| anything else (array, null, a primitive) | The entry, unchanged |
createStructuredLoggerFormatter() and any hand-written object formatter had no visible effect at all. Object returns now reach the transport.
entry.message, so a transport could no longer see the message you logged. message is now always the raw message and the formatted line is in the new entry.formatted. If your custom transport printed entry.message to get the formatted line, print entry.formatted ?? entry.message instead, or call formatTransportLine(entry) (see Writing a transport).
timestamp prints as string because the structured formatter's ISO-8601 value is merged over the entry's Date. Before 1.3.0 the same transport saw the raw entry and printed object.
Options Interfaces
TRANSPORTS
Transports receive formatted entries and deliver them to their destination — console, file, network, or any custom sink. A logger can have multiple transports.
Interface: LoggerTransport
Built-in Transports
| Function | Description |
|---|---|
| createConsoleLoggerTransport() | Writes to console.error / console.warn / console.info / console.debug based on level, one line per entry: entry.formatted (the text line, or the JSON line of a structured record), or the whole entry as one JSON line when there is none |
| createConditionalLoggerTransport(transport, predicate) | Forwards entries only when the predicate returns true. Forwards flush()/close() to the wrapped transport. |
| createMultiLoggerTransport(transports) | Fans out entries to every transport (a failing sink does not stop the others; failures are rethrown afterwards). Forwards flush()/close(). |
| createBufferedLoggerTransport(transport, options?) | Batches entries in memory and flushes by size or interval; each entry is written independently and a timer-flush failure is rethrown by the next flush()/close() |
Console Output
With the default text formatter the console transport prints one line per entry: timestamp, level, logger name, message, then the metadata as key=value pairs. When the entry carries an error and the formatter includes stack traces, the stack follows on the next lines, with no trailing space left on the first line.
With createStructuredLoggerFormatter() each entry is one line of JSON, which is what log collectors expect. Values JSON cannot hold are made safe: a cycle becomes "[Circular]" and a bigint becomes a string.
Writing a transport
A transport can be a plain function. It receives the entry: entry.message is the message as you logged it, and entry.formatted is the line the logger's formatter produced. A transport that writes lines (a file, a socket, a stream) should print formatTransportLine(entry), which returns entry.formatted when it is set and otherwise the entry as one JSON line.
Transport Helpers
| Function | Description |
|---|---|
| createLoggerTransport(transport, options?) | Wraps a transport or function into a RegisteredLoggerTransport |
| enableLoggerTransport(transport) | Returns a new registered transport with enabled=true |
| disableLoggerTransport(transport) | Returns a new registered transport with enabled=false |
| formatTransportLine(entry) | The line a line-oriented transport should print: entry.formatted, or the entry as one JSON line when there is none. The console transport uses it |
| toJsonLogLine(record) | Serializes any value to a single JSON line: cycles become "[Circular]", a bigint a string, and U+2028/U+2029 are escaped |
Buffered Transport Options
LOGGER FACTORY
LoggerFactory manages logger creation, caching, and lifecycle. It ensures consistent configuration across the application and prevents duplicate loggers.
Class: LoggerFactory
Factory Functions
| Function | Description |
|---|---|
| createLoggerFactory(defaults?) | Creates a new LoggerFactory with optional default options |
| createFactoryLogger(factory, name, options?) | Creates a logger through a factory |
| getFactoryLogger(factory, name) | Gets or creates a logger from a factory |
Example: Factory Usage
register(logger, name?) puts an existing logger into the factory's registry under its own name, or under the name you pass. From then on it is covered by flushAll(), disposeAll(), getAll(), has() and size exactly like one the factory built itself. Without it, a hand-made logger is invisible to the factory and its transports are never flushed or closed on shutdown.
LOGGER MANAGER
LoggerManager provides application-wide logger management with initialization, lifecycle, and a default application logger.
Class: LoggerManager
Manager Functions
| Function | Description |
|---|---|
| createLoggerManager(options?) | Creates an uninitialized LoggerManager |
| initializeLoggerManager(options?) | Creates and initializes a LoggerManager in one call |
| createManagedDefaultLogger(name?) | Creates a standalone default logger (no manager) |
| createLoggerManagerFromLogger(logger) | Creates a manager that adopts an existing logger as its default |
Example: Manager Usage
Adopting an existing logger
adopt(logger) (new in 1.3.0) makes a logger you built yourself the manager's default and registers it with the manager's factory, so flush(), close(), getAll() and size all reach it. It throws LoggerDisposedError if the manager is already closed.
createLoggerManagerFromLogger() is built on adopt(). Before 1.3.0 it only assigned the logger to a private field, leaving the factory registry empty: manager.size reported 0, getAll() returned nothing, and flush() / close() were no-ops for the only logger the manager owned — so buffered and file transports were never drained on shutdown.
ERROR HIERARCHY
All error types extend LoggingError from @zudojs/errors. The base LoggerError adds a loggerCode string for fine-grained classification.
| Error Class | Code | When Thrown |
|---|---|---|
| LoggerError | LOGGER_ERROR | Base error for all logger failures |
| LoggerConfigurationError | LOGGER_CONFIGURATION_ERROR | Invalid logger options or configuration |
| LoggerDisposedError | LOGGER_DISPOSED | Operation on a disposed logger |
| LoggerTransportError | LOGGER_TRANSPORT_ERROR | A transport write/flush/close failed |
| LoggerFormatterError | LOGGER_FORMATTER_ERROR | A formatter threw during format |
| InvalidLoggerEntryError | INVALID_LOGGER_ENTRY | A log entry is malformed |
| InvalidLoggerLevelError | INVALID_LOGGER_LEVEL | An invalid level value was supplied |
| LoggerTimeoutError | LOGGER_TRANSPORT_ERROR | Transport exceeded its timeout |
| LoggerTransportClosedError | LOGGER_TRANSPORT_ERROR | Operation on a closed transport |
| LoggerFormatterNotFoundError | LOGGER_FORMATTER_NOT_FOUND | Named formatter doesn't exist |
| LoggerTransportNotFoundError | LOGGER_TRANSPORT_NOT_FOUND | Named transport doesn't exist |
Error or a RangeError now throw the typed error above — a write past transportTimeout raises LoggerTimeoutError (carrying transportName and timeout), any other write failure a LoggerTransportError with transportName set, a formatter failure a LoggerFormatterError with formatterName set, a call on a closed LoggerManager a LoggerDisposedError, an unknown level an InvalidLoggerLevelError, an invalid entry timestamp an InvalidLoggerEntryError, an unresolved string formatter id a LoggerFormatterNotFoundError, and a write to a closed buffered transport a LoggerTransportClosedError. If you match on RangeError or on message text from any of these paths, update the check.
FULL INTEGRATION EXAMPLE
Complete working example: factory, transports, formatters, context, and child loggers.
COMPLETE EXPORT INDEX
Every name @zudojs/logger exports from its package root at v1.4.0 — 153 in total, generated from the package’s own entry point rather than written by hand. The sections above explain the ones you reach for most; this is the exhaustive list, so nothing shipped is undocumented. Names not covered above are typically internal helpers and supporting types.
Show all 153 exports
ContextLogger InvalidLoggerEntryError InvalidLoggerLevelError LoggerConfigurationError LoggerDisposedError LoggerError LoggerFactory LoggerFormatterError LoggerFormatterNotFoundError LoggerManager LoggerTimeoutError LoggerTransportClosedError LoggerTransportError LoggerTransportNotFoundError ZudojsLoggerassertActive assertMutable childLogger closeLogger closeLoggerTransport createBufferedLoggerTransport createChildLogger createChildLoggerOptions createCompactLoggerFormatter createConditionalLoggerTransport createConsoleLoggerTransport createDefaultLogger createDefaultSecretFieldMatcher createDevelopmentLoggerFormatter createEntry createErrorLoggerEntry createFactoryLogger createJsonLoggerFormatter createLogger createLoggerContext createLoggerEntry createLoggerEntryId createLoggerFactory createLoggerFormatter createLoggerFormatterError createLoggerFormatterId createLoggerManager createLoggerManagerFromLogger createLoggerTransport createLoggerTransportError createLoggerTransportId createLogMethods createManagedDefaultLogger createMultiLoggerTransport createProductionLoggerFormatter createSecretMatcher createStructuredLoggerFormatter createTextLoggerFormatter disableLogger disableLoggerTransport dispatchEntry dispatchEntrySync enableLogger enableLoggerTransport escapeLogText flushLogger flushLoggerTransport formatLoggerEntry formatTransportLine getFactoryLogger getLoggerEnabled getLoggerErrorCause getLoggerLevel getLoggerLevelNames getLoggerLevels getLoggerName handleInfrastructureError hasLogControlCharacters initializeLoggerManager isLoggerContext isLoggerError isLoggerFormatter isLoggerFormatterFunction isLoggerFormatterObject isLoggerLevel isLoggerLevelName isLoggerTransport isLoggerTransportFunction isLoggerTransportObject levelOptions logAtLevel logError loggerLevelFromName loggerLevelNameFallback loggerLevelToName mergeLoggerContexts mergeLoggerOptions normalizeConfiguration normalizeLogMetadata redactLogValue resolveLoggerLevel resolveLoggerOptions resolveManagedLogger serializeLoggerEntry serializeLoggerError serializeLoggerValue serializeTransportEntry setLoggerLevel settleAllOrThrow shouldLog throwCollectedFailures toJsonLogLine toLoggerError validateLoggerOptions withContextLogger withLoggerContext writeLoggerTransportChildLoggerOptions ChildLoggerOptionsInput JsonLoggerFormatterOptions Logger LoggerBufferedTransportOptions LoggerConfiguration LoggerContext LoggerContextData LoggerContextIdentifiers LoggerContextOptions LoggerEntry LoggerEntryContext LoggerEntryInput LoggerFormatter LoggerFormatterContext LoggerFormatterOptions LoggerOptions LoggerRedactionOptions LoggerSource LoggerTransport LoggerTransportContext LoggerTransportOptions LogOptions RegisteredLoggerTransport TextLoggerFormatterOptions ZudojsLoggerContextLoggerFormattedOutput LoggerFormatterFunction LoggerFormatterLike LoggerLevelLike LoggerLevelName LoggerTransportFunction LoggerTransportLike LogMetadata LogValueDEFAULT_LOGGER_OPTIONS DEFAULT_LOGGER_SECRET_FIELDS DEFAULT_LOGGER_SECRET_PATTERN LOGGER_REDACTION_TOKEN LOGGER_UNREADABLE_TOKENLoggerLevel