A developer-first Node.js logger: beautiful local output, structured production JSON, automatic request context, safe redaction, extensible transports — and a dependency-free core.
22:41:03 INFO Server started
22:41:03 SUCCESS Database connected
22:41:03 WARN Using development configuration
22:41:03 ERROR Something went wrong
22:41:03 FATAL Application cannot continue
Most Node.js projects end up choosing between console.log (no structure, no levels, no request correlation) and a heavyweight logging framework with a large dependency tree and a steep configuration surface. @rajlabs/logger aims for the middle ground: a small, zero-dependency library that looks great in your terminal during development, produces clean structured JSON in production, and grows with you — automatic request context, redaction, custom transports — without ever requiring a build step or a config file.
- Zero runtime dependencies, works as both CommonJS and ESM, no build step.
- Readable, colorized output in development, clean structured JSON in production —
"auto"config picks the right mode for you. - Request correlation out of the box. Cryptographic request IDs and
AsyncLocalStorage-based ambient context that survivesawait, timers, and nested async calls. - Redaction built in, covering bare keys, dotted paths, and a single-segment wildcard, applied recursively across metadata, context, and Errors.
- Safe Error serialization — messages, stacks,
causechains, and custom own properties, with sensitive Error properties scrubbed by default. - Timers, transports, and hooks for measuring durations, shipping logs anywhere, and mutating or cancelling entries before they're written.
- Custom levels and deterministic sampling for high-volume logs, plus opt-in source locations when you need them.
- Fast on the common path. Level filtering happens before any redaction, cloning, or formatting, so disabled log calls stay cheap.
- Never crashes your app. Caller data is never mutated, circular references are handled safely, and hook/transport failures are isolated from your process.
npm install @rajlabs/loggerconst { createLogger } = require("@rajlabs/logger");
const log = createLogger();
log.debug("Debug information");
log.info("Server started");
log.success("Database connected");
log.warn("Using development configuration");
log.error("Something went wrong", new Error("example error"));
log.fatal("Application cannot continue");ESM works too:
import { createLogger } from "@rajlabs/logger";
const log = createLogger();
log.info("Server started");createLogger(options) accepts:
| Option | Type | Default | Description |
|---|---|---|---|
name |
string |
"" |
Namespace shown next to each message, e.g. [API]. |
level |
level name | "auto" |
"debug" |
Minimum level emitted. See Logging levels and Environment-aware config. |
levels |
object |
built-ins | Custom/additional levels. See Custom levels. |
timestamp |
boolean |
true |
Whether to include a timestamp on each line. |
colors |
boolean | "auto" |
(auto) | Force colors on/off, or resolve automatically from TTY status and env vars. |
format |
"pretty" | "json" | "auto" |
"pretty" |
Output format. See JSON mode and Environment-aware config. |
redact |
boolean | string[] | { paths, replacement } |
(off) | Redact sensitive fields. See Redaction. |
redactErrorProps |
boolean |
true |
Scrub known-sensitive names from a logged Error's custom properties. See Security. |
sampling |
{ [level]: rate } |
(off) | Deterministic per-level sampling. See Sampling. |
source |
boolean |
false |
Attach a [file:line] source location to each entry. See Source location. |
transports |
Array<{ log }> |
console output | Custom output destinations. See Transports. |
hooks |
{ before?, after? } |
(none) | Lifecycle hooks. See Hooks. |
stdout / stderr |
writable stream | process.std* |
Override the underlying streams (mainly for testing). |
An invalid level or format string never throws — it falls back to the default, matching v1.0 behavior (see Configuration validation for exactly which options throw vs. fall back).
const log = createLogger({
name: "API",
level: "info",
timestamp: true,
colors: true,
format: "pretty",
});Colors also respect the NO_COLOR and FORCE_COLOR environment variables.
Pass "auto" for level, format, or colors and the logger picks a sensible value from the environment — conservatively, so you're never surprised:
const log = createLogger({ level: "auto", format: "auto", colors: "auto" });| Setting | Production or CI | Real TTY | Piped / non-interactive |
|---|---|---|---|
level |
"info" |
"debug" |
"debug" |
format |
"json" |
"pretty" |
"json" |
colors: "auto" is equivalent to leaving colors unset: colors are enabled only on a real TTY, and always respect NO_COLOR/FORCE_COLOR. "Production" means NODE_ENV=production; "CI" means the CI environment variable is set to a truthy value.
From least to most severe: debug, info, success, warn, error, fatal. Setting level filters out anything less severe than the configured level:
const log = createLogger({ level: "error" });
log.warn("this is hidden");
log.error("this is shown");
log.fatal("this is shown too");debug, info, and success are written to stdout; warn, error, and fatal are written to stderr, matching standard Unix conventions.
Pass a plain object as the second argument to attach structured data. In pretty mode it's rendered as key=value pairs; nested objects and arrays are formatted safely (including circular references):
log.info("Server started", { port: 3000, environment: "production" });22:41:03 INFO Server started port=3000 environment=production
Pass a JavaScript Error (or subclass) as the second argument and the logger renders its message and stack trace underneath the log line:
try {
await db.connect();
} catch (error) {
log.error("Database connection failed", error);
}22:41:05 ERROR Database connection failed
Error: connect ECONNREFUSED 127.0.0.1:5432
at ...
Cause chains (new Error(msg, { cause })) are serialized recursively, and rendered under a "Caused by:" heading in pretty mode:
try {
await db.connect();
} catch (inner) {
throw new Error("Query failed", { cause: inner });
}Custom properties on an Error (err.code, err.statusCode, etc.) are preserved under error.extra in JSON mode and shown beneath the error details in pretty mode. Because these often come from HTTP client libraries and can carry credentials the developer never consciously logged, they're scrubbed against a built-in sensitive-name list by default, independent of your redact config — set redactErrorProps: false to disable this safety net.
Create a namespaced child logger that inherits its parent's configuration (including any persistent context from withContext):
const log = createLogger({ name: "API" });
const dbLog = log.child("Database");
dbLog.info("Connected");22:41:03 INFO [API:Database] Connected
Namespaces are joined with :, and child loggers can be nested as deeply as you like.
There are two ways to attach ambient fields to every log call: a persistent logger view (withContext) and ambient async context (runWithContext, built on AsyncLocalStorage — see AsyncLocalStorage).
const requestLog = log.withContext({ requestId: "req_123", userId: 42 });
requestLog.info("Fetching user");
requestLog.info("User found");INFO Fetching user requestId=req_123 userId=42
INFO User found requestId=req_123 userId=42
Precedence, lowest to highest specificity: ambient request context (runWithContext) < persistent context (withContext) < explicit call-site metadata. A field set in more than one place uses the most specific value:
log.withContext({ userId: 1 }).info("Test", { userId: 2 });
// => userId=2 — explicit metadata always winswithContext() composes with child() in either order, and both work identically in pretty and JSON mode.
const timer = log.time("Database query");
await database.query();
timer.end();INFO Database query completed duration=143ms
- Timing uses
process.hrtime.bigint()for high-resolution, accurate measurements, and works correctly acrossawaitand any async gap. timer.end(message, meta)lets you customize the completion message and attach extra fields.- Calling
.end()more than once is a safe no-op — only the first call is recorded. durationis rendered with anmssuffix in pretty mode; in JSON mode it stays a plain number ("duration": 143).- If you prefer a
console.time()-style pairing,log.time(label)/log.timeEnd(label, message?, meta?)work the same way, keyed by label.timeEnd()on a label that was never started is a silent no-op. - Timers work through child loggers and carry their name/context.
- Every timer removes its own bookkeeping entry as soon as it's ended (via either
.end()ortimeEnd()), so ending timers never accumulates memory — see the v1.5.1 entry in the CHANGELOG if you're upgrading from an earlier version. A timer that's started and genuinely never ended is kept around so a latertimeEnd(label)can still find it; this is bounded by how many distinct, unfinished labels your application creates, not by log volume.
const log = createLogger({
redact: ["password", "token", "authorization", "apiKey", "secret"],
});
log.info("User login", { username: "raj", password: "super-secret", token: "abc123" });INFO User login username=raj password=[REDACTED] token=[REDACTED]
Redaction rules come in two flavors:
- A bare key name (
"password") matches that key at any depth, recursively through objects and arrays. - A dotted path (
"user.password","request.headers.authorization") matches only that exact shape. A single*segment acts as a one-level wildcard:"*.password"matchespasswordone level under any key.
There is no deep/glob wildcard (**) — this is a deliberate simplicity trade-off; if you need more, redact at a higher level in your own data before logging.
const log = createLogger({
redact: {
paths: ["password", "token"],
replacement: "[HIDDEN]", // default: "[REDACTED]"
},
});redact: true turns on a built-in list of common sensitive names, covering at minimum: password, passwd, token, accessToken, refreshToken, authorization, cookie, secret, apiKey, clientSecret, privateKey (also exported as DEFAULT_REDACT_KEYS). It is deliberately conservative — it won't catch every possible field name, but it also won't redact something you actually wanted to see.
Redaction:
- Works recursively through plain objects and arrays, in both pretty and JSON mode.
- Never mutates your original object — a fresh copy is built internally.
- Treats
Errors,Dates,Maps, and other class instances as opaque leaves rather than reflecting into their internals — this avoids tripping hostile getters or breaking encapsulation. (Errors get their own redaction pass — see Errors.) - Also applies to persistent (
withContext) and ambient (runWithContext) context fields, not just explicit metadata. - Stops descending past 20 levels of nesting (objects and arrays combined), replacing anything deeper with
"[Redaction depth limit exceeded]"instead of continuing to recurse. This is well beyond any nesting depth a real application would deliberately construct, and exists purely as a safety net against pathological or maliciously crafted input.
For production log aggregation, switch to structured JSON output — one JSON object per line:
const log = createLogger({ format: "json" });
log.info("Request completed", { method: "GET", path: "/users", status: 200, duration: 42 });{
"timestamp": "2026-08-30T22:41:03.000Z",
"level": "info",
"pid": 48213,
"hostname": "web-1",
"message": "Request completed",
"method": "GET",
"path": "/users",
"status": 200,
"duration": 42
}pid (process.pid) and hostname (os.hostname()) are included on every JSON line, resolved once at process start — this is JSON-mode only and doesn't appear in pretty output.
Context and metadata fields are flattened onto the top-level object (metadata wins on key collisions with context, per the precedence rule above). If a field would collide with a reserved envelope key (timestamp, level, pid, hostname, message, name, error), it's automatically namespaced as meta_<key> so it can never silently overwrite a core field.
log.middleware() gives you automatic per-request logging with method/path/status/duration/request ID, with no framework dependency:
const log = createLogger({ name: "API" });
app.use(log.middleware());GET /users 200 42ms
POST /login 201 83ms
GET /reports 500 1842ms
Options:
log.middleware({
enabled: true, // set false to disable automatic logging (context propagation still runs)
level: undefined, // force every request log to a fixed level instead of status-based
statusLevel: (status) => …, // customize the status -> level mapping
ignore: ["/health"], // strings (prefix match), RegExp, or a (req) => boolean function
fields: (req, res) => ({}), // merge in extra fields
requestId: { header: "x-request-id", trustHeader: true },
});By default, status codes map to levels as 2xx/3xx → info, 4xx → warn, 5xx → error. Request bodies are never read or logged.
log.middleware() works with Express ((req, res, next)) and as a raw http.createServer request listener — next is only invoked if it was actually provided:
const server = http.createServer((req, res) => {
requestLogger(req, res, () => handleRequest(req, res));
});If you only want request-ID propagation without automatic request/response logging, use log.requestContext(options) — it accepts the same fields/requestId options.
log.middleware() and log.requestContext() are built on Node's built-in AsyncLocalStorage — no dependency required. You can use it directly for framework-independent context propagation:
const log = createLogger();
log.runWithContext({ requestId: "req_123" }, async () => {
await doSomething();
log.info("Inside request"); // automatically includes requestId
});The context survives await, promises, timers, nested callbacks, and database calls — anything within the same async execution. Nested runWithContext calls merge with the outer context, with the inner context winning on key collisions; the outer context is automatically restored once the nested call returns.
Request IDs are generated with node:crypto (req_<12 hex chars>). If an incoming request has a valid X-Request-ID header, it's reused by default (requestId: { trustHeader: true }, the default) — but only if it matches a strict allowlist pattern (/^[A-Za-z0-9_-]{1,128}$/). A malformed or suspicious header value is never trusted blindly; a fresh ID is generated instead. Set trustHeader: false to always generate a fresh ID, or header: "x-trace-id" to read a different header name.
log.middleware() is framework-agnostic and works directly as Express middleware:
const express = require("express");
const { createLogger } = require("@rajlabs/logger");
const app = express();
const log = createLogger({ name: "API" });
app.use(log.middleware());Express is never a runtime dependency of this package — log.middleware() only relies on the (req, res, next) shape that Express (and plain Node http) already provide.
Internally, every log call builds a structured entry and hands it to one or more transports:
Logger → Structured Entry → Redaction/Sampling → Transports (console / JSON / custom)
A transport is any object with a log(entry) method:
const entries = [];
const customTransport = {
log(entry) {
entries.push(entry);
},
};
const log = createLogger({ transports: [customTransport] });
log.info("User logged in", { userId: 42 });
// entries[0] is the structured entry:
// { timestamp, level: "info", message: "User logged in", name, context, metadata: { userId: 42 }, error, source }Transports can optionally implement flush()/close() for cleanup on shutdown — called via logger.flush() / logger.close().
The built-in console output is itself a transport, exported as consoleTransport(), so you can combine it with your own:
const { createLogger, consoleTransport } = require("@rajlabs/logger");
const log = createLogger({
transports: [
consoleTransport(), // keep the normal terminal output
{ log(entry) { sendSomewhere(entry); } },
],
});Supplying transports replaces the default console transport entirely — include consoleTransport() explicitly if you still want terminal output alongside your own. A transport that throws is caught and ignored (with a one-time warning to stderr) rather than crashing your app or blocking other transports.
const log = createLogger({
hooks: {
before(entry) {
// can mutate and return the entry, or return false to cancel logging
return entry;
},
after(entry) {
// called once the entry has been sent to every transport
},
},
});beforecan mutate the entry and must return it (or a replacement) to continue, or returnfalseto cancel the log entirely.afteris fire-and-forget — its return value is ignored.- A hook that throws is caught, a one-time warning is written to stderr, and logging proceeds as if the hook wasn't there — a broken hook never crashes your application.
const log = createLogger({
level: "trace",
levels: {
trace: 5,
debug: 10,
info: 20,
success: 25,
warn: 30,
error: 40,
fatal: 50,
},
});
log.trace("Detailed trace");Custom levels are additive on top of the built-ins by default — defining just { trace: 5 } keeps debug/info/etc. intact. Redefining a built-in name (e.g. debug) overrides it entirely. A level definition can be a plain number, or { value, label?, color? } for a custom display label/color.
Validated at createLogger() time: level names can't collide with reserved logger methods (child, time, once, etc.), and two levels can't share the same numeric value.
const log = createLogger({ sampling: { debug: 0.1 } });Sampling here is deterministic, not randomized: a rate of 0.1 emits exactly the 1st out of every 10 eligible messages, not "roughly" 1 in 10. This makes the volume predictable and the behavior testable, at the cost of not being a true random sample. If you need randomized sampling, implement that policy in a custom transport or upstream.
- Only levels explicitly listed in
samplingare affected; anything else (includingerror/fatalby default) is never sampled. - Sampling is checked immediately after the level filter, before any redaction, context merging, or formatting happens — sampled-out messages do essentially no work.
const log = createLogger({ source: true });
log.info("Server started");INFO Server started [src/server.js:42]
This is opt-in only — capturing and parsing a stack trace on every call is real overhead, so it's never part of the default fast path. It's available in both pretty mode ([file:line] tag) and JSON mode ("source" field).
The normal path is designed to stay fast, especially for disabled levels:
- A filtered-out level call (
log.debug(...)whenlevel: "info") does no formatting, no redaction, no cloning, and no stack inspection — just a single numeric comparison against the current level threshold. (Before v1.5.1, this path also re-validated the level name on every call; that check was redundant — the level name reaching it was always already known-valid — and has been removed. See the CHANGELOG.) - Sampling is checked right after the level filter, before any other work.
- Redaction is skipped entirely (no allocation, no cloning) when
redactisn't configured. - Source-location capture only happens when
source: trueis set. - Metadata/context objects are only copied when there's actually something to merge (context + metadata); a plain
log.info("msg", data)with no context passesdatastraight through by reference until formatting.
See bench/ for the benchmark suite and how to run it.
- The logger never sends logs over the network on its own — that only happens if you configure a transport that does.
- Logging never mutates caller-provided objects, whether or not redaction is configured.
- Pretty-mode output sanitizes control characters (newlines, carriage returns, tabs, ANSI/terminal escape sequences) out of the
message, metadata values, and error name/message — a value like"line one\nFAKE"renders as the visible textline one\nFAKE, not as a forged second log line. Normal printable Unicode is untouched. JSON mode was never affected by this, sinceJSON.stringifyalready escapes control characters. - Redaction never reflects into class instances,
Dates,Maps, etc. — only plain objects and arrays are walked, which avoids triggering hostile/unexpected getters. - A getter that throws degrades that single field to
"[unreadable]"instead of crashing the log call or losing the rest of the line. - Circular references are handled safely in both pretty (native
util.inspectbehavior) and JSON ("[Circular]") modes. - Redaction stops descending past 20 levels of nesting rather than recursing arbitrarily deep, so a pathological or maliciously deep object can't crash the process — see Redaction.
- Custom Error properties are scrubbed against a built-in sensitive-name list by default, independent of your
redactconfig (redactErrorProps: falseto disable). - Incoming
X-Request-IDheader values are validated against a strict allowlist pattern before being trusted; anything else is discarded in favor of a freshly generated ID. - Request bodies are never read or logged by
log.middleware().
levelandformat(plain strings) never throw on an invalid value — they fall back to the default, since these often come from environment variables that shouldn't be able to crash your app. This matches v1.0's original contract.- Structural configuration you write in code —
levels,redact,sampling,transports,hooks— throws synchronously atcreateLogger()time on a malformed shape. These are programming errors you want to catch immediately in development, not something that should silently misbehave in production.
Creates a new logger. See Configuration.
Logs at the given level. message can be any value (safely stringified). meta may be a plain object, an array, or an Error instance. Custom levels (see Custom levels) add their own methods the same way.
Returns a new logger that inherits the parent's configuration (including persistent context) and appends name to the namespace.
Returns a new logger view with fields merged into its persistent context. See Context.
Runs fn with fields merged into the ambient AsyncLocalStorage context for the duration of the call (including across await). See AsyncLocalStorage.
Request logging / context-only middleware. See Request logging.
Starts a timer; returns { end(message?, meta?) }. See Timers.
Ends a timer started with log.time(label), by label.
Logs message at warn level, but only the first time a given key is seen:
log.once("deprecated-api", "This API is deprecated");once() state is shared across a logger and all of its .child()/.withContext() descendants, but not across independent createLogger() calls. log.resetOnce(key?) clears one key, or all keys if called with no argument.
Calls flush()/close() on every transport that implements it.
The built-in console transport, exported for combining with custom transports. See Transports.
The default level table and its severity order (["debug", "info", "success", "warn", "error", "fatal"]).
The built-in list of sensitive key names used by redact: true.
The package version string.
See examples/ for complete, runnable examples:
basic.js— the five-minute quick startjson.js— JSON production outputchild-loggers.js— namespacingtimers.js— timing async operationscontext.js—withContextandrunWithContextredaction.js— key, path, and wildcard redactionapi-server.js— a realhttpserver with automatic request loggingcustom-transport.js— combining transports and hooks
There are no breaking changes. Every v1.0.0 API and behavior — createLogger(), debug/info/success/warn/error, level, timestamp, colors, format, name, child(), metadata handling, error handling, NO_COLOR/FORCE_COLOR, CJS/ESM — continues to work exactly as documented. fatal is a new, additive level; everything else described above is opt-in via new configuration options.
Requires Node.js >= 18.0.0. Ships as CommonJS with a thin ESM wrapper — no build step, no transpilation, no bundler required.