Skip to content
HipraxPublic

About

Fully typed Winston-based logger with rotation, timezone support, and Express middleware

Resources

Stars

2 stars

Watchers

0 watching

Forks

Repository files navigation

@hiprax/logger

Fully typed, production-grade logging toolkit for Node.js applications. Built on top of Winston with first-class TypeScript support, rotating file transports, timezone mirroring, and an HTTP middleware that outperforms traditional solutions.

npm version npm provenance License: MIT CI CodeQL codecov Node.js


Table of Contents


Features

  • Multi-target logging (console, per-module files, shared global file, custom transports). In the default pretty format, console output is colorized and omits timestamps so only rotating files capture the full timeline; with format: "json" the console writes the same JSON line as the files.
  • Daily rotation with independent retention rules for module and global files
  • Guaranteed UTC timestamps plus optional verified IANA timezone mirrors
  • Automatic log directory creation (including nested module scopes like security/failedLogins)
  • Batteries-included Express middleware with structured HTTP payloads, safe-default masking of credentials in headers, query strings and request bodies, and header filtering
  • Graceful fallback for unknown logger methods (warns once, re-routes to info())
  • Environment-aware request logging with built-in presets (dev-only, prod-only, test-only) and custom rules
  • Optional format: "json" (NDJSON) for first-class log shipper support (Datadog, Loki, ELK, Splunk)
  • Built-in createNoopLogger() singleton for libraries / SSR / tests that want a silent default
  • Zero-config ESM & CommonJS dual builds with rich IntelliSense documentation
  • Comprehensive Jest suite with genuine 100% coverage enforcement (no c8 ignore directives masking branches)

Installation

npm install @hiprax/logger

The package ships with precompiled dual builds (.mjs for ESM, .cjs for CommonJS). No transpilation is required in consuming projects.

TypeScript Module Resolution

The exports map declares per-condition type declarations so both module systems resolve a .d.ts file that actually matches their runtime shape:

"exports": {
  ".": {
    "import": { "types": "./dist/index.d.ts", "default": "./dist/index.mjs" },
    "require": { "types": "./dist/index.d.cts", "default": "./dist/index.cjs" }
  }
}

Under "moduleResolution": "node16" or "nodenext" (the TypeScript settings that mirror Node's real ESM/CJS dual-resolution behavior), require("@hiprax/logger") resolves the CommonJS-shaped dist/index.d.cts declaration and import ... from "@hiprax/logger" resolves the ESM dist/index.d.ts declaration — both are emitted by npm run build. Previously the package only published a single top-level types field pointing at the ESM declaration, which made CJS consumers under node16/nodenext fail to compile with TS1479 ("the current file is a CommonJS module... cannot be imported"). Projects using older moduleResolution settings ("node", "bundler") are unaffected — they continue to resolve via the legacy top-level types field.

exactOptionalPropertyTypes. Every optional data property of the public types accepts an explicit undefined, so a project compiled with exactOptionalPropertyTypes: true can pass a value that may be undefined (createLogger({ logDirectory: process.env.LOG_DIR })) and mount the middleware on an Express app, an Express Router or a raw node:http server without a cast. A type-level test compiles these uses with the flag on.

Quick Start

import { createLogger } from "@hiprax/logger";

const securityLogger = createLogger({
  moduleName: "security/failedLogins",
  extraTimezones: ["Europe/London"],
});

// A static message, with the request's values as metadata (see "Printf tokens and metadata" below).
securityLogger.warn("Failed login attempt", { email, ip: req.ip });

Defaults worth knowing before you ship:

  • A crash exits the process. The first logger installs process-wide uncaughtException / unhandledRejection listeners; a crash is logged once, flushed, and then the process exits with code 1, which is what Node itself does after an uncaught exception. If your application has its own crash handler, or you are writing a library, read Crash Capture first.
  • Files are written to logs/ under the working directory at import time. Pass an absolute logDirectory, or turn off includeFile / includeGlobalFile when a platform collects stdout.
  • Log metadata is not masked unless you set maskMetaKeys. (createRequestLogger is different: it masks common credential fields in request bodies, and credential headers and query parameters, by default.)

CommonJS usage:

const { createLogger } = require("@hiprax/logger");

API

createLogger(options?: LoggerOptions)

Creates a fully configured Winston logger with safe defaults, rotating files, UTC timestamps, and optional timezone mirrors. Instances are cached by the module's resolved log file (moduleName + logDirectory) — calling createLogger() with the same configuration from multiple files returns the same logger, preventing duplicate transports, file handles, and console output.

Option Type Default Description
moduleName string 'global' Label used in log lines and for module-specific files. Supports nested scopes (security/failedLogins).
logDirectory string <process.cwd()>/logs Target directory (auto-created).
level LogLevel 'info' Initial logger level. The built-in console, module file, and global file follow it, including later logger.level = ... changes. See Log levels below.
consoleLevel LogLevel level Console-specific level override. Same hierarchy as level. When passed, it pins the console: runtime logger.level changes then reach the files but not the console. When omitted, the console follows logger.level.
includeConsole boolean true Enables console logging. In the pretty format console lines are colorized and omit timestamps; with format: "json" the console writes the same JSON line as the files, timestamp included.
includeFile boolean true Enables module-specific rotating file logging. With includeGlobalFile on, module files that are the global files (same path and datePattern, see globalModuleName) are written once, through the shared transport.
includeGlobalFile boolean true Enables shared rotating file logging.
globalModuleName string 'all-logs' Label for the shared log file. With includeGlobalFile on, a logger whose module file resolves to the same path (moduleName equal to globalModuleName, or two names that sanitize alike) writes it once, with the shared transport's rotation (set by the first logger that opened it); a module datePattern that differs from it names different files, so both are kept. When one logger's private module file is another logger's shared global file (same path and datePattern), createLogger() warns once per path; give them distinct names.
extraTimezones string | string[] [] Additional IANA zones rendered beside UTC. Validity is enforced; invalid zones throw InvalidTimezoneError.
rotation RotationStrategy 20 MB / 14 days / daily Rotation config for the module file.
globalRotation RotationStrategy rotation Override rotation for the shared file. Falls back to rotation when omitted.
additionalTransports winston.transport[] [] Appends custom transports (e.g., HTTP, Kafka, Stream). Each entry is duck-type validated (must expose log and on methods) at construction time; invalid entries throw TypeError. The array is read once and defensively copied — mutating the input array after createLogger() returns has no effect. Note: if an entry sets winston's handleExceptions / handleRejections, both are cleared (with a one-time warning), which changes where crashes land — see Crash Capture.
onTransportError (err, transport) => void undefined Optional callback invoked when any transport (built-in or additionalTransports) emits an error event (rotation/disk failures, EACCES, ENOSPC, gzip errors). Invoked inside a try/catch so a throwing callback cannot crash the process; errors then fall back to console.error. When omitted, errors go directly to console.error (the bare console, never through this logger). Repeated identical messages are deduplicated (up to 10 unique messages tracked per logger).
clock () => Date () => new Date() Optional clock injection point used by the timestamp formatter. The clock is consulted at log-call time (not flush time), so async transports / queued writes / back-pressured streams cannot skew the rendered timestamp. Primarily intended for deterministic tests; production code should leave this option unset.
captureUncaught boolean true When true, registers this logger with a process-wide crash coordinator that captures uncaughtException and unhandledRejection into the log output (full stack, plus process / os / parsed trace detail). The coordinator owns exactly one listener pair for the whole process regardless of how many loggers you create, and a crash is logged once, through a single elected logger — see Crash Capture. When false, this logger does not participate and adds no process listener.
exitOnUncaught boolean true Whether a captured fatal terminates the process with exit code 1 after the crash is logged and flushed (bounded internally so a stuck transport cannot wedge the exit). Governs both uncaughtException and unhandledRejection. Set to false to log fatals without exiting (the pre-1.0.0 behavior). Only meaningful when captureUncaught is true.
colorize boolean | { message?: boolean; level?: boolean; all?: boolean } { level: true, message: true } Controls ANSI colorization of the console transport. false disables colorization entirely; true colors both the [LEVEL] token and the message body; an object honors level / message flags independently, with all: true overriding both. The message is colored after it is rendered, so the console shows the same message text as the file line (an undefined message, a BigInt, and full-depth JSON for objects and arrays included). File transports are never colorized.
maskMetaKeys string[] [] Keys whose values are replaced with [REDACTED] before serialization, matched case-insensitively and applied deeply, in BOTH file and console pipelines and in both formats. Covers metadata, object and array messages (logger.info({ user, password })), non-string stacks, and Errors inside metadata, including their cause chain. Empty by default; without a mask, output is unchanged. See Masking with maskMetaKeys for what is covered, how failures render, and what masking cannot reach.
format "pretty" | "json" "pretty" Output format for BOTH the file pipeline and the console pipeline. "pretty" (default) emits the existing human-readable printf form. "json" emits one JSON object per line (NDJSON / JSON-Lines) suitable for log shippers like Datadog, Loki, ELK, Splunk, and Vector — see JSON Output for Log Shippers below.
escapeMessageNewlines boolean false When true, replaces \n / \r in string messages and in a string stack with the visible escape sequences \\n / \\r BEFORE the printf renders the line. Mitigates log-injection forging via untrusted user input, including a payload wrapped in an Error. Collapses a multi-line stack to one line while on — see Security Notes.

Log levels

Winston uses the npm log-level hierarchy. Lower numbers are more severe; a logger only emits messages whose severity is <= the configured level:

Level Numeric
error 0
warn 1
info 2
http 3
verbose 4
debug 5
silly 6

Important: the default level: "info" swallows http, verbose, debug, and silly calls — a message logged at one of those levels is silently dropped and never reaches any transport. This does not hide createRequestLogger output by default: the request middleware emits normal request/response logs at info / warn / error (status-code-driven), all of which the default level: "info" already passes. The "http" level only becomes relevant if you override the middleware's level option to winston's "http" — then the underlying logger's level must also be >= "http":

const httpLogger = createLogger({ moduleName: "http", level: "http" });
app.use(createRequestLogger({ logger: httpLogger, level: () => "http" }));

Changing the level at runtime

level is the logger's initial level. Assigning logger.level later takes effect immediately on the console, the module file, and the global file, because those built-in transports carry no level of their own and inherit the logger's current level on every write. For every level in the hierarchy, logger.isLevelEnabled() gives the same answer as what is emitted:

const logger = createLogger({ moduleName: "api" });
logger.debug("dropped"); // default level "info"

logger.level = "debug";
logger.isLevelEnabled("debug"); // true
logger.debug("written to the console, api-<date>.log, and all-logs-<date>.log");
  • Passing consoleLevel pins the console to that level, even when it equals level: runtime changes then reach the files but not the console.
  • Loggers that share the global file keep independent levels; raising one logger's level does not change what the others write.
  • Do not remove a built-in transport and add it back (logger.remove(t) / logger.add(t), or logger.configure({ transports }) reusing it). Winston closes a transport when it is removed. For the module file that ends its stream, so a re-added one writes nothing and the next write can surface as an uncaught write after end error. For the global file it releases the logger's share, so a re-added handle keeps writing only while another logger still holds the file, and its lines are lost once the last one shuts down. The Console survives removal, but winston links a transport to its logger only on the first pipe, so a re-added Console without its own level no longer sees the logger's level and accepts every entry; set t.level (or pass consoleLevel) before re-adding it. To change destinations, create a new logger instead.
  • logger.transports[i].level reads undefined for the built-in module-file and global-file transports, and for the console unless consoleLevel was passed (then it reads that value). An additionalTransports entry created with its own level keeps it.
  • Winston does not validate the assigned value, and invalid values behave in opposite ways. A non-empty string outside the hierarchy, such as "silent", matches no level, so every inheriting transport drops every entry, errors and crash records included. An empty or missing value ("", undefined, null, typically an unset environment variable such as logger.level = process.env.LOG_LEVEL) leaves the transports with no level, so they write every entry, debug and silly included, while isLevelEnabled() reports false. Validate a configured level before assigning it, and use logger.silent = true to silence a logger.

Masking with maskMetaKeys

maskMetaKeys: ["password"] replaces the value of every password key with "[REDACTED]" before the line is serialized. Keys are matched case-insensitively, and the redaction runs in the file and console pipelines of both formats. The option is empty by default, and without a mask the output is unchanged.

const logger = createLogger({ maskMetaKeys: ["password"] });

logger.info("Login", { user: "bob", password: "topsecret" }); // "password": "[REDACTED]"
logger.info({ user: "bob", password: "topsecret" }); // masked too (object message)

What is masked:

  • Metadata: the object passed after the message (logger.info("Login", meta)), and the extra keys of a single object that has a message. The walk is deep: nested objects, arrays, the enumerable own fields of class instances, and the output of nested toJSON() methods (below).
  • Object and array messages: winston puts a single-argument payload without a truthy message into the message slot, so logger.info({ user, password }), logger.info([{ password }]), logger.info({ message: "", password }) and logger.info({ message: { password } }) are masked as well. If the message value has a toJSON() method, it is called first and its result is masked by its own keys, the way the serializer reads it, so a DTO without a message field whose toJSON() returns the secret is masked too.
  • A metadata object's own toJSON(): in the default pretty format, a metadata object with its own toJSON() method is serialized through that method, so its output is masked by its own keys. The method runs on the masked copy, so a field it reads through this stays masked. JSON mode also masks the toJSON() output of the logged entry by its own keys, but calls the method on the original object, so a masked field that the method copies from this under another key name (toJSON() { return { pw: this.password }; }) is not masked there.
  • Non-string stacks: an object or array stack is masked the same way. String and BigInt messages and string stacks, including every real Error stack, are never changed.
  • Errors inside metadata: an Error keeps its name, message and stack when one of its fields is masked. The walk also reads its cause chain (the new Error(msg, { cause }) form) and an AggregateError's errors, so a masked key inside them is redacted before anything prints it. Errors created in another realm (a vm context) are covered.
  • Nested values with their own toJSON(): a DTO, a database document, an array, or an HTTP client error that defines toJSON() (for example one whose output includes its request headers), nested anywhere, including inside an error's cause or an AggregateError, is printed through that method, so the masking walk calls it the same way and masks its output by its own keys. A class instance's toJSON() runs on the instance, so #private fields work; a plain object's own toJSON() runs on the masked copy, so a field it reads through this is already masked. A toJSON() that returns a string (a Date, a URL, a moment) renders unchanged. With a mask set, such a method runs twice per line (in the walk and again in the serializer), and so does one whose output the walk leaves as it is (a class instance or an error with nothing to mask, a Buffer); one that returns a plain object or an array runs once. Without a mask the walk never calls toJSON().

With a mask set, a message is walked like metadata: a circular reference renders as "[Circular]", own __proto__ / constructor / prototype keys are dropped, and nesting deeper than 256 levels renders as "[MaxDepth]".

Masking fails closed. When a read throws during masking, the value is replaced, never written raw:

  • A message or stack whose walk throws (a throwing getter or toJSON()) renders as the string "[RedactionFailed]", and the log call does not throw.
  • A metadata value whose getter throws renders as "[RedactionFailed]" in JSON mode. In the pretty file the metadata block is replaced by { "_redactionFailed": true }. When the getter is a top-level metadata key, the pretty console instead renders the other fields, masked, without that one; a getter that throws deeper inside a value fails the console block closed the same way.
  • If the logged entry's toJSON() throws, the JSON line keeps only level, message and _redactionFailed: true. In pretty mode an own toJSON property that throws renders the metadata block as [UNSERIALIZABLE]; a toJSON() inherited from a class is never called in pretty mode, so the instance's own fields are rendered, masked by key.
  • Inside an Error, only the affected field is replaced, and a getter on the error itself that throws is left out.
  • A nested value whose toJSON() throws, or whose toJSON property is a getter that throws, renders as "[RedactionFailed]", and the rest of the line still renders.

What masking cannot reach: masking matches keys, never text. A string is printed as it is, so an error's message and stack print exactly as the error builds them (an error class that writes its own fields into its message, such as a validation error that lists the rejected input, prints them even under a mask), and a class instance's toJSON(), which runs on the instance, prints a value it copies from a masked field under another key name or returns as its whole result. Values with no enumerable own keys (Map, Set, RegExp) and binary values (a Buffer's type and data, typed arrays) render as the serializer renders them, and a function that carries its own toJSON() is printed through it without being masked in pretty format and in the request middleware's body preview (JSON mode drops a function). A nested plain object's own toJSON() runs on the masked copy, where a nested value whose toJSON() returns a plain object or an array is already replaced by that masked output, so a toJSON() that calls a method on such a value fails and renders "[RedactionFailed]". A mask that names toJSON itself replaces a plain object's own method, so that object's own fields print. An Error with its own toJSON() is rendered through that method, so fields it leaves out stay out. Log only the fields you need from such values, or normalize them to plain objects first.

The objects you log are not rewritten. Masking, Error flattening, the module field and the timestamp all work on copies, so a plain object you log keeps its original values afterwards and stays safe to persist or return. For example, logger.error({ message: err, requestId }) leaves message the same Error, with no stack or error fields added. Apart from winston's internal Symbol slots, these are the exceptions:

  • winston itself sets level (and any logger.defaultMeta keys) on an object it uses as the log entry (logger.log("info", obj), logger.log(obj), or logger.info(obj) when obj has a truthy message; without one, winston logs { message: obj } instead), before any format runs, exactly as bare winston does.
  • An array or class instance that is the log entry gets a timestamp property written onto it, because a plain copy would lose its array shape or its prototype and toJSON().
  • A class instance whose message is an Error is still flattened in place by winston's errors() format: its message becomes the error's message string, and the error's stack and own fields are copied onto it. A plain copy would lose its toJSON(), so JSON mode would print the fields that method leaves out.
  • A Proxy whose message exists only through its get trap is flattened in place the same way, through its traps, because a copy cannot reproduce a property the object does not own.

RotationStrategy

interface RotationStrategy {
  maxSize?: string; // e.g., '20m', '200k'
  maxFiles?: string; // e.g., '14d', '30'
  datePattern?: string; // default: 'YYYY-MM-DD'
  zippedArchive?: boolean;
}

Defaults: the package exports the frozen defaultRotation constant (maxSize: "20m", maxFiles: "14d", datePattern: "YYYY-MM-DD", zippedArchive: false) plus a getDefaultRotation() helper that returns a fresh, mutable deep copy. Use either to override one or two fields without copying the literal:

import { createLogger, defaultRotation, getDefaultRotation } from "@hiprax/logger";

// Spread the frozen export to keep most defaults but bump retention.
const longRetention = createLogger({
  rotation: { ...defaultRotation, maxFiles: "30d" },
});

// Or grab a mutable copy and edit it imperatively.
const rotation = getDefaultRotation();
rotation.maxFiles = "30d";
rotation.zippedArchive = true;
const archived = createLogger({ rotation });

The exported defaultRotation is frozen — direct mutation throws under strict mode. Use getDefaultRotation() whenever you need to mutate the result.

Unknown method fallback: If you call a method that does not exist on the logger (e.g., logger.success("done")), it will log a warning once and route the message through info() instead of throwing. Loggers returned by logger.child() keep this fallback (see Child Loggers).

Instance caching: Logger instances are cached by the module's resolved log file: moduleName (each path segment sanitized, so two names that sanitize alike, such as user api and user-api, share one logger) + the resolved logDirectory. The directory is normalized via path.resolve (and on Windows lowercased for the cache key only) so "./logs", path.resolve("./logs"), and (on Windows) C:\Logs vs c:\logs collapse to a single cache entry. Symlinks are collapsed via fs.realpathSync.native whether or not the directory exists yet (when it is missing, the nearest existing ancestor is resolved and the not-yet-created tail is re-joined), so the cache key is stable across the first-call directory-creation boundary. The same call from different files returns the same instance. Use resetLoggerRegistry() to clear the cache (useful for testing or hot-reload scenarios):

import { resetLoggerRegistry } from "@hiprax/logger";

resetLoggerRegistry(); // clears all cached instances

Conflicting options on a cached key: If createLogger() is called a second time for the same moduleName + logDirectory with different level, extraTimezones, rotation, etc., the cached instance is returned and the new options are ignored. A one-time console.warn lists the divergent fields so the silent foot-gun is visible. extraTimezones is validated before the cache lookup, so passing an invalid IANA identifier always throws InvalidTimezoneError regardless of cache state. additionalTransports are compared by count only because function/class instances are not stably equal-checked. Whether consoleLevel was passed at all is compared too (consoleLevelPinned), because a pinned console ignores runtime logger.level changes even when its value equals level.

Lazy directory creation: When both includeFile and includeGlobalFile are false, createLogger() does NOT touch the filesystem — no directory is created and no error is thrown when logDirectory does not exist or is read-only. This makes the logger safe to use in tests, AWS Lambda (where the project root is read-only outside /tmp), and stream-only setups. When at least one file transport is enabled, the resolved logDirectory (and the per-transport subdirectory for nested module names like security/failedLogins) is created idempotently via fs.mkdirSync(..., { recursive: true }).


Graceful Shutdown

Production deployments (especially those running behind a load balancer or in a container orchestrator) usually receive SIGTERM shortly before the process is killed. To make sure buffered logs in the rotating-file transport are flushed before the process exits, use shutdownLogger(logger, options?) or shutdownAllLoggers(options?):

import { createLogger, shutdownAllLoggers } from "@hiprax/logger";

const logger = createLogger({ moduleName: "auth" });

const handleSignal = async (signal: NodeJS.Signals) => {
  logger.info(`Received ${signal}; flushing logs and exiting…`);
  try {
    // Walks the registry and shuts down every cached logger in parallel.
    // The timeout applies INDEPENDENTLY to each logger.
    await shutdownAllLoggers({ timeoutMs: 5000 });
  } catch (err) {
    // The shutdown timed out — at least one transport did not flush in time.
    // Falling through to `process.exit` is intentional: the user requested
    // termination, and waiting forever for a stuck transport would defeat
    // the purpose of the SIGTERM contract.
    console.error("Logger shutdown timed out:", err);
  } finally {
    process.exit(0);
  }
};

process.once("SIGTERM", handleSignal);
process.once("SIGINT", handleSignal);

shutdownLogger(logger, { timeoutMs = 5000 }) calls logger.end() (winston's flush API) and awaits the finish event on every transport. Once it resolves, the rotating file's bytes really are on disk — so it is safe to process.exit(0) immediately afterwards, as the example above does. It rejects with a shutdownLogger timed out after <timeoutMs>ms… error when any transport fails to flush in time. The function is idempotent for successful shutdowns — calling it twice returns the cached resolved promise without issuing a second logger.end(). A timed-out shutdown is retryable: the cached rejected promise is evicted so a subsequent call with a longer timeoutMs can start a fresh flush attempt.

shutdownAllLoggers(options?) is a convenience that walks the internal registry and calls shutdownLogger() on every cached logger in parallel. Both helpers unref() the timeout's setTimeout handle so a pending shutdown does not keep the event loop alive on its own.

Shutting a logger down also evicts it from the internal registry, so a later createLogger() for the same moduleName + logDirectory builds a brand-new instance with live transports instead of returning the ended one. This matters for worker recycles, dev hot-reloads, and any shutdown-then-recreate loop:

const a = createLogger({ moduleName: "auth" });
await shutdownLogger(a);

const b = createLogger({ moduleName: "auth" }); // fresh instance, NOT `a`
b.info("still logged"); // reaches disk

The eviction happens synchronously as soon as end() is issued, on the timeout path as well as the success path — a timed-out logger has already been ended, so it is no more usable than a successfully flushed one. Tearing a logger down with logger.close() or logger.end() directly evicts it too.

Two things to know: only the cache is cleared, so a reference you already hold (or one captured by a createRequestLogger middleware) stays ended; and while a timed-out shutdown is still retryable through the reference you hold, shutdownAllLoggers() iterates the registry and so will not pick it up a second time.


HTTP Request Logging Middleware (framework-agnostic)

The middleware is framework-agnostic — it consumes a request/response pair via the local LoggableRequest / LoggableResponse shapes and never imports express. Express is the canonical example below, but Fastify, Koa, raw Node http, or any adapter that produces objects matching those shapes (a method/url/headers-bearing request and an EventEmitter-style response) work the same way. The optional peerDependencies.express entry in package.json is documentation-only — it signals the most common host framework but imposes no real coupling.

import express from "express";
import { createRequestLogger, DEFAULT_MASKED_BODY_KEYS } from "@hiprax/logger";

const app = express();
// The body parser first: the request body is captured when the logger runs.
app.use(express.json());

app.use(
  createRequestLogger({
    includeHttpContext: true, // writes the structured entry below under info.http
    includeRequestBody: true,
    includeRequestHeaders: ["user-agent", "authorization"], // authorization is masked by default
    includeResponseHeaders: true,
    // The defaults already mask password, token, apiKey, ...; spread them to add your own.
    maskBodyKeys: [...DEFAULT_MASKED_BODY_KEYS, "ssn"],
    enrich: (req) => ({ tenantId: req.headers["x-tenant-id"] }),
  }),
);

Each request is logged once, with a concise message (POST /auth/login 201 12.45ms (completed)). The structured entry (RequestLogEntry) is written only with includeHttpContext: true, under info.http: that is where the captured body, headers and enrich() context go. Without it, includeRequestBody, includeRequestHeaders, includeResponseHeaders and enrich capture data nothing writes (unless a custom messageBuilder reads the entry), and createRequestLogger() warns once at creation to say so. The middleware relies on plain Node events (finish/close) with a guard against double-logging, and does not depend on on-finished.

HTTP-level visibility: the middleware emits info/warn/error for normal request/response logs (status-code-driven), so the default logger level: "info" is enough to see them. If you customize the level option to use winston's "http" level (e.g. level: () => "http"), the underlying logger's level MUST also be >= "http" (e.g. "http", "verbose", "debug", "silly") — otherwise the entries are silently dropped per the npm-level hierarchy above. Pass an explicit logger option pointed at a createLogger({ level: "http" }) instance when using "http".

Mounting position. The middleware does two things when its (req, res, next) => runs: it starts the responseTimeMs clock, and (with includeRequestBody) it captures req.body. It also only logs requests that reach it. So where you mount it is a trade-off:

  • First in the stack: every request is logged, including the ones a later body parser, authentication or rate limiter rejects, and responseTimeMs covers the whole stack. But req.body is not parsed yet, so no request body is logged:

    app.use(createRequestLogger({ includeHttpContext: true, includeRequestHeaders: ["user-agent"] }));
    app.use(express.json()); // after the logger: bodies are parsed too late for it
  • After the body parser (needed for includeRequestBody): bodies are logged, but a request the parser rejects (malformed JSON, 413 too large, 415 unsupported) never reaches the middleware and is not logged by it, so log those from your error handler. Keep responseTimeMs end-to-end by setting the start time in your first middleware with the exported REQUEST_START_SYMBOL:

import express from "express";
import { createRequestLogger, REQUEST_START_SYMBOL } from "@hiprax/logger";

const app = express();

// First piece of middleware — captures the earliest possible timestamp.
app.use((req, _res, next) => {
  (req as any)[REQUEST_START_SYMBOL] = process.hrtime.bigint();
  next();
});

// The body parser (and anything else slow) before the logger, so the body is parsed when it runs.
app.use(express.json());

// Logger picks up the start time from the symbol.
app.use(createRequestLogger({ includeHttpContext: true, includeRequestBody: true }));

The override MUST be a bigint produced by process.hrtime.bigint(). Any other value (a number, a Date, etc.) is silently ignored and the middleware falls back to capturing its own start at entry time. (Requires Node 10.7+ for process.hrtime.bigint() — well below the package's engines.node: >=18.0.0 floor.)

Aborted vs completed classification: the middleware listens on both res.once("finish") and res.once("close"). A close event is classified as event === "aborted" ONLY when res.writableEnded is falsy — i.e. the response body was NOT fully written before the socket closed. A close after a normal finish (HTTP/1 keep-alive socket teardown, HTTP/2 stream end) reports event === "completed". The structured payload also surfaces responseWritableEnded, responseDestroyed, and requestAborted (when available) so downstream consumers can distinguish abort causes.

Body snapshot timing: req.body is captured at middleware ENTRY time (before next() runs), which is why the middleware must be mounted after the body parser to log bodies (see Mounting position above). Handler-time mutation of req.body (e.g. req.body = { redacted: true }) does NOT affect what gets logged. The snapshot is a shallow reference, not a deep clone — handlers that mutate properties INSIDE the body object should redact those keys via maskBodyKeys / redactPaths. Deep cloning was rejected as a default because (a) it adds non-trivial per-request cost and (b) the common mutation pattern is whole-pointer reassignment, which the shallow reference already isolates against.

Option Type Default Description
logger winston.Logger Scoped http logger Provide your own logger or use the auto-created one: createLogger({ moduleName: "http" }) (or http/<label>), created once with every other createLogger default, so it writes files under <cwd>/logs and installs crash capture that exits on a fatal (see Crash Capture).
level LogLevel | (status: number) => LogLevel Auto (info/warn/error) Override severity. The default maps 5xx to error, 4xx to warn, rest to info.
label string — Included in the auto-generated logger name (http/<label>).
messageBuilder (entry) => string "METHOD URL status latency (event)" Customize the final message string.
skip (req, res) => boolean — Return true to skip logging for specific requests.
enrich (req, res, durationMs) => Record<string, unknown> | null | undefined — Inject extra context (e.g., tenant, user). Attached under entry.context, which is written only with includeHttpContext (or read by a custom messageBuilder). Returning null or undefined is treated as "no extra context": entry.context is left unset.
includeRequestHeaders boolean | string[] false true for all headers, or an array of allowed header names. Written only with includeHttpContext.
includeResponseHeaders boolean | string[] false Same as above for response headers. Written only with includeHttpContext.
includeRequestBody boolean false Logs the parsed request body, masked (maskBodyKeys, redactPaths) and capped (maxBodyLength). Captured when the middleware runs, so mount it after your body parser (see Mounting position). Written only with includeHttpContext.
maskBodyKeys string[] | false safe defaults (see below) Body keys replaced with [REDACTED], deeply and case-insensitively. Defaults to the exported DEFAULT_MASKED_BODY_KEYS (common credential field names); an array replaces the defaults, so extend them with [...DEFAULT_MASKED_BODY_KEYS, "ssn"]; false (or []) turns body masking off. Applies to arrays, the enumerable own fields of class instances, Errors (their own fields, cause chain and AggregateError members, keeping name, message and stack) and a value's own toJSON() output. A toJSON() returning a string (Date, URL) and values with no enumerable keys (Map, Set) pass through unchanged; use redactPaths for those. See Security Notes for the list and what a mask changes.
maskHeaderKeys string[] | false safe defaults (see below) Header values to redact in BOTH request and response headers (case-insensitive). Pass false to opt out.
maskQueryKeys string[] | false safe defaults (see below) Query-string param values to redact in req.url / req.originalUrl (case-insensitive). Pass false to opt out.
redactPaths string[] [] Dot-paths into the resolved entry for surgical redaction (e.g. ["body.user.password"]). Must be an array of strings; a non-array (e.g. the bare string "body.password") throws RequestLoggerOptionError({ code: "INVALID_MASK" }).
maxBodyLength number 3000 Caps serialized body size to prevent log floods. String bodies are truncated to exactly maxBodyLength characters with a trailing …. Object/array bodies whose JSON form exceeds the limit return a structured envelope { _truncated: true, _originalLength, _preview } so the field's shape never flips between an object and a string mid-truncation. Must be a positive number or Infinity (unlimited); NaN, 0, negatives, and non-numbers throw RequestLoggerOptionError({ code: "INVALID_BODY_LIMIT" }).
includeHttpContext boolean false Attaches the structured RequestLogEntry payload under info.http. This is the only place the body, headers and enrich() context are written (besides a custom messageBuilder); without it only the message line is logged.
loggingEnabled boolean true Hard enable/disable switch.
loggingMode RequestLoggingMode 'always' Environment-aware control. See below.

Environment-Aware Request Logging

The loggingMode option controls when request logging is active, based on environment variables.

Built-in presets:

Mode Matched env values (case-insensitive) Env sources checked (in order)
'always' Always enabled —
'never' Always disabled —
'dev-only' dev, development, local NODE_ENV, APP_ENV, ENV
'prod-only' prod, production, live NODE_ENV, APP_ENV, ENV
'test-only' test, testing, qa, staging NODE_ENV, APP_ENV, ENV

Custom configuration:

createRequestLogger({
  loggingMode: {
    sources: ["DEPLOYMENT_STAGE"], // env vars to check (priority order)
    allow: ["staging", "qa"], // values that enable logging
    fallback: false, // behavior when no env source is found
  },
});

Tips:

  • Pair loggingEnabled with boolean expressions for one-line toggles: loggingEnabled: process.env.FEATURE_LOGS === "on"
  • The loggingMode decision is evaluated once at middleware creation time, not per-request
  • Keep log output lightweight by enabling includeHttpContext only when you need the full structured payload

Timezone Handling

  • UTC is always logged in file output.
  • Additional zones must be valid IANA identifiers (validated by moment-timezone). Invalid entries throw InvalidTimezoneError.
  • Provide a single string or an array:
createLogger({
  extraTimezones: ["Europe/London", "America/New_York"],
});
  • Duplicate timezone entries are automatically deduplicated.

Custom Transports

You can disable all built-in transports and use only custom ones:

import { PassThrough } from "node:stream";
import winston from "winston";
import { createLogger } from "@hiprax/logger";

const capture = new PassThrough();
const logger = createLogger({
  moduleName: "audit",
  includeConsole: false,
  includeFile: false,
  includeGlobalFile: false,
  additionalTransports: [new winston.transports.Stream({ stream: capture })],
});

JSON Output for Log Shippers

Set format: "json" to emit one JSON object per log line (newline-delimited JSON, also called NDJSON / JSON-Lines). This is the wire format expected by every mainstream log shipper — Datadog Agent, Grafana Loki / Promtail, Logstash / Filebeat (the ELK / Elastic Stack), Splunk Universal Forwarder, Vector, Fluent Bit, AWS CloudWatch Agent, and so on. The format option applies uniformly to BOTH the file transports (module + global) and the console transport, so a process that ships stdout to a sidecar (the standard pattern in Kubernetes / ECS / Cloud Run) gets byte-identical JSON on every transport.

import { createLogger } from "@hiprax/logger";

const logger = createLogger({
  moduleName: "api",
  format: "json",
  // The maskMetaKeys redaction continues to apply BEFORE serialization,
  // so secrets never reach the JSON line.
  maskMetaKeys: ["password", "authorization", "apiKey"],
});

logger.info("Login", {
  userId: 42,
  email: "u@example.com",
  password: "topsecret",
  durationMs: 12.4,
});

Each call writes a single line such as:

{
  "level": "info",
  "message": "Login",
  "timestamp": "2026-05-04 14:30:22",
  "module": "api",
  "userId": 42,
  "email": "u@example.com",
  "password": "[REDACTED]",
  "durationMs": 12.4
}

Canonical fields:

  • level — winston npm log level (error, warn, info, http, verbose, debug, silly).
  • message — the rendered message string.
  • timestamp — captured at log-call time as a UTC "YYYY-MM-DD HH:mm:ss" string (matches the pretty pipeline's UTC: line).
  • module — the module identifier, so a line in the shared global (all-logs) file can be attributed to the module that produced it. It carries the same display label the pretty pipeline renders as (…): your moduleName, or the literal GLOBAL for the default moduleName: "global". If your metadata already includes a module key, your value is kept — the field is never silently overwritten. Two narrow log forms carry no module field: a single-object payload that defines its own toJSON() (its toJSON output owns the whole line) and a top-level array payload (it serializes as an array).
  • stack — populated by winston.format.errors({ stack: true }) whenever the logged value is an Error.
  • Caller metadata — every key/value pair passed in the metadata object (e.g. logger.info("msg", { ... })) is merged onto the top-level JSON object.

Datadog ingestion example. Wire your Datadog Agent to tail the rotating file (or set includeConsole: true and let Datadog scrape the container stdout):

# /etc/datadog-agent/conf.d/api.d/conf.yaml
logs:
  - type: file
    path: /var/log/myapp/api-*.log
    service: api
    source: nodejs
    sourcecategory: hiprax-logger
    log_processing_rules:
      - type: multi_line
        name: ndjson_per_line
        pattern: \{

Datadog auto-parses each line as JSON and surfaces every metadata key as a searchable facet (@userId:42, @email:u@example.com, @durationMs:>10). The level field maps to the Datadog log status; timestamp sets the event time precisely.

Grafana Loki / Promtail example. Promtail forwards each line to Loki and parses the JSON into label/index pairs:

# /etc/promtail/config.yml
scrape_configs:
  - job_name: api
    static_configs:
      - targets: [localhost]
        labels:
          job: api
          __path__: /var/log/myapp/api-*.log
    pipeline_stages:
      - json:
          expressions:
            level: level
            timestamp: timestamp
            userId: userId
      - timestamp:
          source: timestamp
          format: 2006-01-02 15:04:05
          location: UTC
      - labels:
          level:

LogQL queries can then filter by metadata fields ({job="api", level="error"} | json | userId="42").

ELK / Elastic Stack example. Filebeat tails the file and ships to Logstash or directly to Elasticsearch; the decode_json_fields processor flattens the line into top-level fields:

# /etc/filebeat/filebeat.yml
filebeat.inputs:
  - type: filestream
    paths:
      - /var/log/myapp/api-*.log
    parsers:
      - ndjson:
          target: ""
          add_error_key: true
processors:
  - timestamp:
      field: timestamp
      layouts:
        - "2006-01-02 15:04:05"
      timezone: UTC
output.elasticsearch:
  hosts: ["localhost:9200"]

Notes on the JSON branch:

  • The extraTimezones mirror lines (e.g. "Europe/London: …") are NOT emitted in JSON mode — only the canonical UTC timestamp field is present. Most log shippers convert UTC to the viewer's locale at presentation time, so the secondary timezones are redundant in structured pipelines.
  • Console output in JSON mode is the SAME raw JSON (no colorize, no extra header lines). This is intentional: a sidecar shipper that scrapes stdout must see the same wire format the file does.
  • maskMetaKeys redaction continues to apply, walking nested objects, arrays, errors and the output of nested toJSON() methods before JSON.stringify. Secret values are replaced with the literal string "[REDACTED]" before the line is serialized.
  • An Error inside metadata is written as an object with its name, message, stack, own fields, cause and errors (see Errors in log lines).

Child Loggers

logger.child(meta) returns a logger that adds meta to every entry it writes. A child is a view over the logger it came from (its root): it owns its default metadata and nothing else, and it keeps the root's logger-level safety net (the unknown-method fallback, the safe toJSON, the non-thenable guards); see the winston boundary below for what differs.

const logger = createLogger({ moduleName: "api" });
const requestLog = logger.child({ requestId: "r1" });

requestLog.info("Charging card"); // carries requestId: "r1"
requestLog.child({ step: "capture" }).info("Captured"); // carries requestId and step
  • Metadata: a grandchild carries both metadata sets; the nearer one wins on a shared key, and a key in the logged payload wins over both. maskMetaKeys applies to child metadata like any other metadata. A child can be passed to createRequestLogger({ logger }).
  • Logger-level safety net: an unknown method (child.success("ok")) warns once and logs at info with the child's metadata (one warning per method name for the root and all its children); JSON.stringify(child) returns the root's safe summary, never the child's metadata; a child is not thenable.
  • Shared transports and lifetime: shutdownLogger(child), child.close() and child.end() act on the root. shutdownLogger(child) and shutdownLogger(root) return the same promise, and both close() and end() drop the root from the cache so the next createLogger() builds a fresh logger; close() also leaves crash capture and releases the shared global file. child.end(entry) writes entry with the child's metadata before ending the root. child.add(), remove(), clear(), configure(), pipe() and unpipe() change the root's transports, exactly as calling them on the root does. Every method that returns the logger (the level methods, log(), close(), end(), add(), remove(), clear(), unpipe(), on() and the other event-emitter methods, and the unknown-method fallback) returns the logger it was called on, so a chain such as child.info("a").success("b") or logger.info("a").child(meta) stays inside the package's wrapper. A child of a logger that was shut down stays shut down; derive children again from the new logger.
  • Levels: output is gated by the root's level. child.level = "debug" sets a property on the child only: it changes nothing that is written (it only changes that child's isLevelEnabled() answers). Change the level on the root.
  • winston's child boundary: winston merges the child metadata by copying each entry (Object.assign({}, meta, entry)) before any of this package's formats run. So, only through a child: a getter on the logged object that throws, or a Proxy payload whose traps throw, is thrown out of the log call instead of degrading the line; a class instance is logged as a plain copy, so its own toJSON() is not used and every enumerable field is written (log a plain object, or add the field to maskMetaKeys); an array payload is written as an object keyed by index; and a logged Error always carries a cause key, so in pretty format an Error without a cause gets an empty {} metadata line.

Silent / No-op Logger for Libraries & SSR

Libraries that consume @hiprax/logger and want to be silent by default — or test harnesses / SSR pipelines that want a stub logger without emitting any output — can use createNoopLogger(). The returned logger drops every call on the floor, has no transports, registers no exception handlers, never touches the filesystem, and emits no Winston Attempt to write logs with no transports warning.

import { createLogger, createNoopLogger } from "@hiprax/logger";

// Library-side default — caller can override.
export interface InitOptions {
  logger?: import("winston").Logger;
}

export const init = ({ logger = createNoopLogger() }: InitOptions = {}) => {
  logger.info("Library initialized");
  // ... library setup that may emit log lines ...
};

// Application-side opt-in to real logging.
init({ logger: createLogger({ moduleName: "my-app" }) });

Surface guarantees:

  • Every npm log-level method (error, warn, info, http, verbose, debug, silly) plus the generic log() method is a no-op. Calls accept any arguments (Error instances, metadata objects, format strings) and return the logger for chainability.

  • end(), close(), on(), once(), removeListener() (and the rest of the EventEmitter lifecycle methods) are no-ops returning the logger so subscriber and shutdown code continues to work.

  • transports is a frozen empty array; level is the literal string "silent".

  • Returns the SAME singleton instance on every call — the no-op logger is stateless and safe to share across consumers. It is NOT registered with the internal logger registry, so resetLoggerRegistry() does not affect it.

  • Drop-in compatible with the request-logging middleware's logger option:

    import { createNoopLogger, createRequestLogger } from "@hiprax/logger";
    
    app.use(createRequestLogger({ logger: createNoopLogger() }));

Crash Capture

With captureUncaught: true (the default), a logger registers with a process-wide crash coordinator. Two guarantees follow:

One listener pair, however many loggers you create. Creating a logger per module is the normal pattern, and it stays cheap:

// auth.ts, db.ts, cache.ts, … — one logger per module
const logger = createLogger({ moduleName: "auth" });
process.listenerCount("uncaughtException"); // 1  (was 1-per-logger before v1.0.0)
process.listenerCount("unhandledRejection"); // 1

Before v1.0.0 each logger installed its own pair, so around eleven modules tripped Node's default limit:

(node:18524) MaxListenersExceededWarning: Possible EventEmitter memory leak detected.
  11 uncaughtException listeners added to [process]. MaxListeners is 10.

That warning is gone, and shutting every logger down (shutdownLogger / shutdownAllLoggers / resetLoggerRegistry) returns the process to zero package-owned listeners.

One crash record. A fatal is logged once, through a single elected logger — not once per logger into the shared all-logs file. The elected logger is the first still-registered one that writes to a file (so the trace is persisted across restarts), falling back to the first that has any transport at all. The entry carries winston's full diagnostic payload (stack, parsed trace, process / os blocks) and is marked with a crash field:

{ "crash": "uncaughtException", "level": "error", "message": "uncaughtException: …" }

The record is written at error through the elected logger's transports, so each of them gates it like any other entry: it survives any level in the hierarchy, but after logger.level = "silent" on the elected logger only a console pinned with consoleLevel (or an additionalTransports entry with its own level) still records it (see Changing the level at runtime).

By default the process then flushes and exits with code 1, matching Node's standard crash behavior. Opt out per logger:

createLogger({ moduleName: "auth", exitOnUncaught: false }); // log the fatal, keep running
createLogger({ moduleName: "auth", captureUncaught: false }); // don't participate at all

A process has a single lifetime, so the exit decision is a consensus: the process exits only when every registered logger allows it. A single exitOnUncaught: false on any logger therefore keeps the whole process running after a captured fatal, regardless of which logger records the crash or the order the loggers were created in. (The crash is still recorded once, through the elected file-backed logger — recording where a crash lands and deciding whether to exit are independent.) A component that wants no say in process lifetime, and no crash recording either, should use captureUncaught: false so it never participates.

Your own crash handlers and --unhandled-rejections

The listeners are process-wide, so they interact with everything else in your process that handles crashes:

  • The loggers that register. Every logger created with the default captureUncaught: true joins the coordinator, including the logger createRequestLogger() creates when you pass no logger. A library should not create loggers of its own: accept one from the caller and default to createNoopLogger(), or pass captureUncaught: false.

  • Your own uncaughtException / unhandledRejection handler, or an error reporter's. Process listeners run in the order they were added. After recording the crash, the logger flushes its transports for up to 3 seconds and calls process.exit(1), which cuts short any asynchronous work your handler started (a report upload, a graceful shutdown). To let your handler decide when to exit, set exitOnUncaught: false on any one logger (the consensus above) and exit from your handler once it is done. A handler of yours that rethrows a rejection as an exception can make the logger record both events.

  • Node's --unhandled-rejections flag. Once any listener is installed, Node no longer crashes on its own, so the logger's decision applies in every mode:

    Mode Node alone With the logger (exitOnUncaught: true)
    throw (default) exits with code 1 recorded once, exits with code 1
    strict exits with code 1 recorded once (as uncaughtException), exits
    warn, none, warn-with-error-code keeps running (the last sets exit code 1 for later) recorded once, exits with code 1

    With exitOnUncaught: false the process keeps running in every mode. Under strict, Node reports one rejection through both events; the logger records it a single time, immediately, as the uncaught exception Node raises for it, and skips the rejection event that follows. A rejection whose value is not an Error is then known only through Node's wrapper error, whose message holds a string of the value (for example #<Object>), so reject with Errors when you need the details. An error while an ES-module entry point loads is reported the same way by Node and recorded as an uncaughtException.

Custom transports. The guarantee holds unconditionally, so an additionalTransports entry that sets winston's handleExceptions / handleRejections has both flags cleared (with a one-time warning). Those flags are what make winston install a listener per logger, and leaving them set would both re-create the warning above and log every crash twice.

This does change where crashes land, so it is worth stating plainly: because a crash is recorded once, through the elected logger, a transport receives crashes only if it belongs to that logger. If you have a dedicated crash sink (Sentry, an HTTP transport) that previously relied on handleExceptions to receive crashes from its own module's logger, attach it to the elected logger instead — otherwise it will stop seeing them. To opt a logger out of crash capture entirely, use captureUncaught: false.

The coordinator is a singleton per module instance. Loading both the ESM and CJS builds into one process yields two coordinators (one pair each) — the same inherent limitation the logger registry has.


Log Output Format

For the Quick Start line, securityLogger.warn("Failed login attempt", { email, ip }):

File output (with timestamps and extra timezones):

UTC: 2025-06-15 14:30:22
Europe/London: 2025-06-15 15:30:22
[WARN] (security/failedLogins)
Failed login attempt
{
  "email": "user@example.com",
  "ip": "192.168.1.1"
}

Console output (colorized, no timestamps):

[WARN] (security/failedLogins)
Failed login attempt
{
  "email": "user@example.com",
  "ip": "192.168.1.1"
}

The console line comes from the same formatter as the file line, without the timestamp lines. Colors are added afterwards, to the [LEVEL] token and to the rendered message, so a non-string message (an object, an array, a BigInt, undefined) shows the same text on the console as in the file.

Request middleware default message:

POST /auth/login 201 12.45ms (completed)

When includeHttpContext is enabled, the structured RequestLogEntry is attached under info.http:

{
  "event": "completed",
  "method": "POST",
  "url": "/auth/login",
  "statusCode": 201,
  "responseTimeMs": 12.45,
  "contentLength": 256,
  "ip": "127.0.0.1",
  "userAgent": "Mozilla/5.0...",
  "requestId": "req-abc-123"
}

contentLength is the response's declared byte count, taken from the response Content-Length header. It is a response-side figure only: a chunked or streamed response carries no Content-Length, and an aborted request sends nothing, so contentLength is undefined in both cases rather than reporting the uploaded request-body size. If you need the request body size, read it from requestHeaders["content-length"] (enable includeRequestHeaders).

Errors in log lines

A logged Error (logger.error(err)) renders its message and its stack, and its own enumerable fields (such as code) are rendered as metadata. winston's errors() format copies only those, so a cause set through the new Error(msg, { cause }) option (a non-enumerable property) is not rendered, while one assigned afterwards on an error created without that option (err.cause = inner, an enumerable property) is rendered as metadata. To log the chain either way, pass the error as metadata: logger.error("Payment failed", { err }).

An Error inside metadata renders as an object with name, message, stack, its own enumerable fields, its cause (rendered the same way, down the chain) and, for an AggregateError, its errors. Earlier versions wrote only the error's own enumerable fields, so a plain error rendered as "err": {}. An error from another realm (a vm context) is covered, and an error with its own toJSON() renders through that method.

const logger = createLogger({ moduleName: "payments" });
const cause = new Error("insufficient funds");
logger.error("Payment failed", { err: new Error("card declined", { cause }), orderId: 7 });

The pretty file line lists the fields in the order above:

UTC: 2026-05-04 14:30:22
[ERROR] (payments)
Payment failed
{
  "err": {
    "name": "Error",
    "message": "card declined",
    "stack": "Error: card declined\n    at …",
    "cause": {
      "name": "Error",
      "message": "insufficient funds",
      "stack": "Error: insufficient funds\n    at …"
    }
  },
  "orderId": 7
}

With format: "json", keys are sorted as always:

{"err":{"cause":{"message":"insufficient funds","name":"Error","stack":"Error: insufficient funds\n    at …"},"message":"card declined","name":"Error","stack":"Error: card declined\n    at …"},"level":"error","message":"Payment failed","module":"payments","orderId":7,"timestamp":"2026-05-04 14:30:22"}
  • A cause that points back at its own error, set non-enumerably as the new Error(msg, { cause }) option creates it, renders as "[Circular]" in JSON mode. In pretty format the whole metadata block is rendered as in earlier versions: that error, and any other error in the same block, renders as {}, and the other fields stay intact. An error that references itself through an enumerable property (err.self = err, or err.cause = err on an error created without the cause option) renders "[Circular]" in JSON mode, while the pretty metadata block renders [UNSERIALIZABLE], as before. With maskMetaKeys set, a self-referencing cause renders the error's fields and "[Circular]" in both formats.
  • The error field of a crash record renders the same way, and so do errors in the request middleware's enrich() context and request body (a redactPaths entry such as context.err.stack can target their fields).
  • With maskMetaKeys, masked keys inside an error, its cause chain and AggregateError members are redacted, including inside the output of a toJSON() method there; see Masking with maskMetaKeys for what masking cannot reach.

Printf tokens and metadata (no string interpolation)

This package composes no winston.format.splat(), matching winston's own default format. Two consequences follow, and the second is a deliberate trade:

1. Printf-style interpolation is not performed. A message keeps its tokens literally:

logger.info("User %s logged in", "u-42");
// message: "User %s logged in"   — the "u-42" argument is not substituted

2. A message containing a printf token causes winston to drop a trailing metadata object. When the message matches %s %c %d %j %i %f %o %O %%, winston routes the metadata into its internal SPLAT slot, which nothing in these chains reads:

logger.info("route /users", { requestId: "r1" }); // requestId logged
logger.info("route /a%d", { requestId: "r1" }); // requestId NOT logged

This is identical to bare winston.createLogger() with no format option — it is winston's documented default, where interpolation "requires winston.format.splat()" — not behavior this package adds.

The message itself is always rendered verbatim and is never rewritten. That is the reason splat() is deliberately not composed in. Adding it would not recover the metadata above (winston consumes the trailing object as the %d argument, so the metadata is dropped either way) and it would let a % token inside message text alter the recorded text. The trigger is not exotic: the characters c, d and f are hex digits, so lowercase percent-encoded URL octets (%c3, %d0, %f0) are printf tokens.

// With splat() composed in, this URL would be REWRITTEN in the log:
logger.info("route /caf%c3%a9", { ip }); // -> "route /caf3%a9"

An absent field is visibly absent; a silently rewritten URL is a plausible fabrication. Rendering the message verbatim is the stronger guarantee, so it is the one this package keeps.

Recommendation — put dynamic values in metadata, not in the message. This avoids the drop entirely:

// Avoid — a printf token in `url` (e.g. "%d", or the "%c3" of an encoded
// path) silently drops `ip`:
logger.info(`Unknown route ${url}`, { ip });

// Prefer — a static message with everything structured:
logger.info("Unknown route", { url, ip });

Beyond avoiding the drop, this keeps messages greppable and aggregatable, which is standard practice for structured logging. If you need interpolation, format the string yourself before logging it.

createRequestLogger is unaffected, so an attacker-chosen URL containing %d cannot strip the context from the HTTP entry recording that request. Note the reason, because it is not the recommendation above: the middleware does put the request URL into its message. It is immune because it logs through winston's single-object form (logger.log(entry)), which never populates the SPLAT slot and so never reaches the drop.

Migration to v1.0.0

v1.0.0 removes the MaxListenersExceededWarning that appeared once an app created ~11 module loggers. Fixing it correctly meant this package takes ownership of crash capture instead of delegating to winston's per-logger handlers, which settles three behaviors that could not change without a major version.

Most applications need no code change. Review the five items below only if they describe you.

# Change You are affected if… Keep the old behavior
1 A captured fatal now exits the process with code 1 after logging. Previously the error was logged and the process kept running in a possibly inconsistent state. You relied on the process surviving an uncaughtException / unhandledRejection. createLogger({ exitOnUncaught: false })
2 A crash is logged once, through one elected logger — not once per logger. It also reaches the console now, not only the file transports. You expected a copy of the crash in every module's file, or parsed the N duplicate traces in all-logs. No opt-out; the duplicates were the bug.
3 The crash payload's exception: true / rejection: true is replaced by crash: "uncaughtException" | "unhandledRejection". You grep or alert on "exception":true. Match on "crash": instead.
4 logger.transports holds a forwarding handle for the global log, not a DailyRotateFile. The module-scoped file is still a real DailyRotateFile. You introspect logger.transports with instanceof DailyRotateFile. Filter for the module file only; expect one match, not two.
5 An additionalTransports entry that sets handleExceptions / handleRejections has both flags cleared (with a warning). Since crashes go only to the elected logger, a crash sink on a non-elected logger stops receiving them. You attached a crash sink (Sentry, HTTP) to a module logger and relied on those flags to deliver crashes to it. Attach that transport to the elected logger — the first capture-enabled logger that has a file transport, falling back to the first with any transport.

Why #3 is not optional: exception / rejection are winston-reserved routing flags. winston-transport drops an { exception: true } record from any transport without handleExceptions — the very flag whose removal fixes the listener leak — so keeping the marker would have made every crash silently vanish from the logs. Everything else in the payload is unchanged.

If you previously worked around the warning with process.setMaxListeners(n), you can remove it.


Scripts

Command Description
npm run build Generates dual ESM/CJS bundles plus type declarations.
npm test Runs Jest with 100% coverage enforcement.
npm run lint Runs ESLint across source and test files.
npm run type-check Runs the TypeScript compiler in check-only mode.
npm run format:check Verifies Prettier formatting is clean.
npm run audit:runtime Runs npm audit against runtime dependencies only (fails on high or above).
npm run verify Runs all five pre-completion gates locally with a clean per-check summary.
npm run branch -- <prefix> <slug> Creates a conventionally-named feature branch from a fresh origin/main.
npm run sync Fast-forwards local main and prunes branches whose remote tracking branch was deleted.
npm run release:prepare -- <bump> Bumps version, promotes CHANGELOG [Unreleased], commits, pushes a release/vX.Y.Z branch.
npm run release:tag Creates and pushes the vX.Y.Z tag — triggers the release workflow (publish + GitHub Release).

Testing & Coverage

npm test
  • ts-jest compiles TypeScript on the fly with ESM support.
  • Coverage thresholds are locked at 100% for branches, functions, lines, and statements — and there are no c8 ignore directives in the source tree, so the threshold is honest rather than masked.
  • Tests run in band (--runInBand) for deterministic behavior.
  • tests/consumer-types.spec.ts compiles type-only consumer fixtures (tests/fixtures/types/) with exactOptionalPropertyTypes on, so the public types keep working for projects that enable it.
  • The run fails if any test creates, changes or deletes a file inside the repository: a Jest global setup / teardown pair snapshots the tree before and after the run (skipping .git, node_modules, the coverage and cache directories and top-level dot entries) and lists every changed path. Under npm run test:watch the list is printed instead of failing the run.

Security Notes

The request middleware ships with safe defaults for the most common secret-leak vectors: request bodies (maskBodyKeys), headers (maskHeaderKeys) and URL query strings (maskQueryKeys) are masked unless you set the option to false (for example to see raw values while debugging). redactPaths is the one opt-in API, because it targets paths you name. The logger itself masks nothing by default: set maskMetaKeys for your own log metadata.

  • All filesystem interactions are sandboxed to the configured log directory.

  • Module names are sanitized to prevent path traversal (dangerous characters are replaced with hyphens).

  • Timezones are validated against the Moment timezone database before use.

  • Body redaction (maskBodyKeys): on by default when includeRequestBody is on. The default list, exported as DEFAULT_MASKED_BODY_KEYS, names common credential fields, matched exactly and case-insensitively (the list is lowercase, so accessToken matches accesstoken):

    • passwords: password, passwd, pwd, passphrase, password1, password2, and the new / old / current / confirm / confirmation variants in camelCase and snake_case;
    • secrets and keys: secret, secretKey, clientSecret, apiKey, privateKey (each also in snake_case);
    • tokens: token, accessToken, refreshToken, idToken, authToken, sessionToken, codeVerifier (each also in snake_case);
    • sessions, one-time and card codes: sessionId / session_id, credential, credentials, otp, cvv, cvc;
    • credential-bearing header names found inside bodies: authorization, proxy-authorization, cookie, set-cookie, x-api-key.

    An array replaces the list (["ssn"] masks only ssn); extend it with [...DEFAULT_MASKED_BODY_KEYS, "ssn"]. false (or []) turns body masking off. The list is a floor, not a guarantee: userPassword or pin are not matched, and code and key, which are in the query-string list, are deliberately not in the body list because in JSON bodies they are usually not secret (error, country and promo codes; { key, value } pairs); add them on an OAuth code-exchange or OTP route. A mask changes more than the matched keys: with any non-empty list, the default included, a nested value's own toJSON() is called and its output masked, so a body Error holding such a value (an HTTP-client error as its cause, for example) is logged as a plain copy of its fields instead of as your Error instance (the serialized line is the same), and a value whose toJSON() throws renders "[RedactionFailed]" for that value only. A body parsed from JSON has none of these values, so for it only the masked keys differ.

    The walk replaces matching keys with [REDACTED] recursively in nested objects and arrays, including the enumerable own fields of class instances and the fields of an Error (its own fields, its cause chain and AggregateError members; name, message and stack are kept). Matched case-insensitively. Circular references are safely handled (replaced with [Circular]) — only a value that is its own ancestor (a true self-reference or an indirect/mutual cycle) triggers this; the same non-circular object reachable from two different keys, array entries, or nesting depths is fully redacted at every occurrence instead of collapsing after the first. A value with its own toJSON() (a DTO, an HTTP client error, also inside an error's cause) is masked through that method's output, exactly as the serializer will print it. Redaction boundary: masking matches keys, never text, so a toJSON() returning a string (Date, URL) renders unchanged, and values with zero enumerable keys (Map, Set, RegExp) are passed through by the serializer untouched — use redactPaths to target those surgically.

  • Header redaction (maskHeaderKeys): applied to BOTH request and response headers AFTER the includeRequestHeaders / includeResponseHeaders allow-list filter. Header names are lowercased first; a header named __proto__, constructor or prototype, in any letter case, is left out of the logged headers. Default mask list, exported as DEFAULT_MASKED_HEADER_KEYS (an array replaces it, so extend it with [...DEFAULT_MASKED_HEADER_KEYS, "x-session"]; opt out by passing false):

    • authorization
    • cookie
    • set-cookie
    • x-api-key
    • proxy-authorization
  • URL query redaction (maskQueryKeys): edits the logged req.originalUrl / req.url query string in place — it locates each key=value pair in the raw query and replaces only a matched parameter's value with the literal [REDACTED], leaving every other byte untouched. Sibling-parameter encoding (%20, %5B/%5D, %2B), the //host authority of protocol-relative URLs, the scheme/host of absolute URLs, and the URL fragment are all preserved exactly; parameters are never re-ordered or re-encoded, and the function never throws. Handles both absolute and relative URLs, including a URL whose only ? appears inside the fragment (e.g. a hash-router path like "/dash#/r?token=abc"), which has no real query component and is returned unchanged. Default mask list, exported as DEFAULT_MASKED_QUERY_KEYS (an array replaces it, so extend it with [...DEFAULT_MASKED_QUERY_KEYS, "sig"]; opt out by passing false):

    • token, access_token
    • api_key, apikey, key
    • code (covers OAuth callback codes)
    • secret, password
  • Surgical path redaction (redactPaths): opt-in array of dot-notation paths into the resolved entry (e.g. ["body.user.password"]). The leading body segment is rewritten to requestBody to match the public field name. Missing intermediate keys are a graceful no-op — we never create new sub-paths just to write [REDACTED]. Body-scoped paths (body.* / requestBody.*) are applied to the object graph BEFORE the maxBodyLength truncation decision, so a secret targeted only by redactPaths (with no corresponding maskBodyKeys entry) cannot survive inside the _preview field of an over-limit body's truncation envelope. The request body, the enrich() context, and any captured request/response headers are all redacted on internal owned copies — even when the body or context is a class instance that defines its own toJSON() (a Mongoose document, a DTO, req.user) — and a path only ever steps through and writes into those package-owned plain objects and arrays, following their own data properties (never an inherited property, and never a getter, which is not run). So your original req.body / req.session / req.user / req.headers objects (and the live response-header values behind res.getHeaders()) are never mutated by a redactPaths write, and no path can reach Object.prototype: __proto__, constructor and prototype segments are refused. A header value the entry keeps by reference (a Date, a class instance with its own toJSON(), a Buffer, a Map) ends the path, because everything behind it is your application's state; to hide such a header, list it in maskHeaderKeys. A multi-segment path starts at body / requestBody, requestHeaders, responseHeaders or context; a single segment such as url redacts that field. Paths you add to the array after creating the middleware apply too; a non-string entry is ignored. Path redaction is also best-effort and never throws: a path that targets an array's length, a frozen or getter-only property, or any other slot it cannot safely write is skipped rather than allowed to drop the entire log entry.

  • Body size guard (maxBodyLength): prevents log-flood attacks. Object/array bodies whose JSON form exceeds the limit return the envelope { _truncated: true, _originalLength, _preview } (preserving JSON shape for downstream log shippers). String bodies are truncated to maxBodyLength Unicode code points (handles emoji surrogate pairs correctly) with a trailing ….

  • Log injection (escapeMessageNewlines): untrusted user input concatenated into a log message — for example logger.info(req.body.username) where the request body carries username = "alice\n[ERROR] (admin)\nfake critical event" — writes three lines into the log file by default. The second and third lines are byte-for-byte indistinguishable from real [ERROR] entries the application itself produced and can mislead a SOC analyst, log parser, or incident responder into trusting forged data. Set escapeMessageNewlines: true on the createLogger() options to rewrite embedded \n / \r in string messages as the visible literal sequences \n / \r BEFORE the printf renders the line. Defaults to false for back-compat with consumers that intentionally emit multi-line messages; the recommendation for new applications is to opt in.

    const logger = createLogger({ escapeMessageNewlines: true });
    logger.info("alice\n[ERROR] (admin)\nfake event");
    // Rendered:
    //   [INFO] (GLOBAL)
    //   alice\n[ERROR] (admin)\nfake event
    // (one log entry, with the literal "\n" sequences visible in the body.)

    The escape covers the rendered stack line as well as the message, and needs to: winston.format.errors({ stack: true }) flattens a logged Error into a string message plus a string stack whose first line repeats that message, so escaping only the message would let logger.error(new Error(username)) re-inject the payload's raw newlines one line lower and forge the entry anyway. Trade-off: with the option on, a genuine multi-line stack trace renders as a single line whose frame separators show as the literal \n sequence. Every frame is still present and greppable, and nothing changes at the false default — a real stack frame and a forged log line are the same bytes to a log parser, so newlines from caller-supplied data cannot be trusted selectively. Leave the option off if your pipeline depends on multi-line stack rendering.

    The request logger's own error-path line is always escaped. When logging a request fails, the middleware writes one line to stderr (@hiprax/logger request logger failed while logging <method> <url>: <reason>), with query secrets masked exactly as on the happy path. The method, URL and error message in it are written JSON-escaped without the quotes (\r, \n, ", \ and every other C0 control character), and every other Unicode control, format (bidirectional overrides and isolates, zero-width characters, the BOM) or line and paragraph separator character is written as a \uXXXX escape, so none of them can split that line, forge another one, or reach your terminal as a control sequence. escapeMessageNewlines does not affect that line. For the logger's own lines: pretty lines escape only \n / \r, and only with the option on; json lines are JSON-encoded, so C0 control characters are always escaped there, while C1, format and separator characters are not.

  • Unreadable payloads: a payload whose keys or prototype cannot be read (a Proxy whose ownKeys or getPrototypeOf trap throws), logged as the entry itself (logger.info(proxy) when it has a truthy message, or logger.log("info", proxy)), does not throw out of the log call. The line is written with the level, the message when it is a string (otherwise [UNSERIALIZABLE]) and _unserializable: true; the rest of the payload is dropped. This does not hold through a child logger, whose entry copy runs inside winston before any of this package's formats (see Child Loggers). A logged Error with an own enumerable getter that throws is written as a degraded line marked _errorFlattenFailed: true instead of throwing.

  • A frozen Object.prototype: freezing Object.prototype (Object.freeze(Object.prototype), a common prototype-pollution mitigation) is supported once @hiprax/logger has loaded, so freeze it after your imports (in an ES module entry the module body already runs after its imports). Freezing it before the package loads makes the import itself throw, inside its moment-timezone dependency (moment assigns toString / valueOf on its own prototype while loading). The masked copies of request bodies, request and response headers, maskMetaKeys metadata, Error fields and toJSON() output define their keys instead of assigning them, so a key named after an Object.prototype member (toString, valueOf, hasOwnProperty) is logged like any other key, and no accessor on Object.prototype receives a value from them. The limits come from winston, which copies entries by assignment: a top-level key of that kind still degrades the line to its level and message (marked _unserializable: true) when a transport carries its own format (the pretty console, or an additionalTransports entry with a format, in either mode); logger.info(message, meta) with such a key at the top of meta, and a child logger, throw from winston itself; and a logged Error that owns such a key is written as the _errorFlattenFailed line. Those same winston copies also still hand such a top-level key's value to an accessor defined on Object.prototype (only code already running in your process can define one). Nested keys, and top-level keys when no transport carries its own format (json mode's built-in console carries none), are logged in full.

Contributing

  1. Clone the repo and install dependencies (npm ci).
  2. Create a branch using the convention <prefix>/<slug> (e.g. feat/add-thing, fix/headers-leak, ci/bump-actions). The helper npm run branch -- feat add-thing does this from a fresh origin/main.
  3. Make your changes and add or update tests. Coverage is enforced at 100% on every metric.
  4. Run npm run verify before opening a PR — it runs the same five gates CI runs.
  5. Update CHANGELOG.md under ## [Unreleased] with a dated entry describing what changed.
  6. Open a PR; the template enumerates the checklist that must pass.

Release process

Releases are tag-triggered: pushing a vX.Y.Z tag fires .github/workflows/release.yml, which re-runs every gate, verifies the tag matches package.json version, publishes to npm with provenance, and creates a GitHub Release whose body is the matching CHANGELOG.md section.

Publishing uses npm trusted publishing (OIDC): the workflow authenticates by exchanging a short-lived GitHub Actions identity token for a registry token at publish time, so there is no npm token stored in the repository to leak, expire, or rotate. The trust relationship is configured on the npm package itself (Settings → Trusted Publisher → GitHub Actions), pinned to this repository and the release.yml workflow filename. Because trusted publishing requires npm >= 11.5.1, the release job runs on Node 24.x and asserts the npm floor before any npm work.

The two-step flow:

npm run release:prepare -- patch   # or minor / major
# Open the printed PR URL, wait for CI green, squash-and-merge.
npm run sync                       # pull main, drop merged branches
npm run release:tag                # creates + pushes vX.Y.Z (triggers publish)

License

MIT License - see LICENSE file for details.

Links


About

Fully typed Winston-based logger with rotation, timezone support, and Express middleware

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages