The Node.js Logging Model
A log is a structured, timestamped record of a single fact your process observed - a request completed, a payment failed, a background job started.
Search across all documentation pages
A log is a structured, timestamped record of a single fact your process observed - a request completed, a payment failed, a background job started.
It sounds like console.log with extra steps, but the difference matters: a log line written for a person watching a terminal and a log line written for a machine to index, filter, and alert on are solving different problems, even though they look similar on screen.
This page is the mental model behind the rest of the logging section: Logging Basics shows working code for structured logging with Pino, and the pages on correlation IDs, redaction, and AsyncLocalStorage go deep on specific mechanics.
Here, the goal is understanding what a log actually is, what pipeline it travels through, and why that pipeline shapes almost every logging decision you'll make.
Before structured logging, most Node code just wrote text to stdout: console.log("user logged in: " + userId).
That's fine for a human staring at a terminal during local development, but it breaks down the moment more than one person, more than one service, or more than a few requests per minute are involved - a human can't grep meaning out of thousands of free-form sentences fast enough to matter during an incident.
Structured logging solves this by treating each log line as data first, message second: a JSON object with named fields (userId, action, statusCode) and a short human-readable message attached, rather than a sentence with values baked in.
A useful analogy is a ship's log versus a diary.
A ship's log records discrete, structured facts in a fixed format - time, position, event - specifically so a different reader, days or years later, can reconstruct exactly what happened and when, without needing the original writer's context.
A diary is prose, written for the writer's own recall; it's expressive, but nobody can write a query against it.
Production logging aims for the ship's log, not the diary - even when it's still readable at a glance.
Log level is the other foundational idea: fatal, error, warn, info, debug (in Pino's convention) aren't a mood scale for how upset the developer was when they wrote the line - each level is a promise about what an operator should do when they see it. error means something needs attention; warn means something degraded but recovered; info means normal, expected activity worth recording; debug means detail useful in development or targeted troubleshooting, not routine production noise.
Every log line, from the moment your code calls logger.info(...) to the moment someone searches for it in a dashboard, travels a pipeline:
call site -> level filter -> serialize -> transport -> aggregator -> index -> query
(logger.info) (below (object to (stdout, (Datadog, (searchable (dashboards,
threshold? JSON string) file, socket) Loki, CW) storage) alerts)
drop early)
The level filter runs first and matters more than it looks: a well-built logger checks the configured level before doing the expensive work of serializing the log object, so a debug call in a production process running at info costs almost nothing - the object is never turned into a string, never written anywhere.
This is why blanket "just log everything" advice is naive; it's not really "everything" that's expensive, it's serializing and transporting everything that's expensive, and the filter step exists precisely to avoid paying that cost for lines nobody will read.
Transport is where logging intersects with the event loop directly: writing to stdout synchronously is cheap for small volumes but can become a source of backpressure under heavy load, since a slow consumer (a piped process, a full disk buffer) can make writes block.
Most production setups keep the application's write path simple - write structured JSON to stdout - and let an external agent (a sidecar, a log shipper) handle the slower work of transport and aggregation, so the application process itself never blocks on network I/O just to emit a log line.
// why serialization order matters: the filter runs before the (expensive) work
function log(level: "info" | "debug", threshold: "info" | "debug", fields: object): void {
const levels = { debug: 0, info: 1 };
if (levels[level] < levels[threshold]) return; // cheap check, no serialization yet
process.stdout.write(JSON.stringify({ level, ...fields }) + "\n");
}Correlation is the mechanism that ties one request's scattered log lines back together: a requestId (or trace_id when correlated with tracing) attached once, early in the request lifecycle, and carried through every subsequent log line for that request - either by passing a child logger down the call stack or by reading it from AsyncLocalStorage at each call site.
Without correlation, a busy service's logs are an interleaved stream of unrelated requests, and reconstructing "everything that happened for this one failing request" becomes a manual, error-prone search.
Cardinality is usually the real cost driver in logging at scale, more than raw line count.
A field like statusCode has a handful of possible values and compresses beautifully in most log indexes; a field like userId or a raw error message with an embedded ID has effectively unbounded distinct values, and indexing on high-cardinality fields is what actually inflates storage and query cost in most log aggregation platforms' pricing models.
This is why teams often reserve fields like userId for values you'll search on selectively, rather than indexing every possible field by default.
Sampling logs - deliberately dropping some fraction of routine, high-volume events (like successful health-check pings) while keeping all errors - is the usual answer once "log everything" becomes financially or operationally unsustainable; it trades a small amount of completeness on the routine path for a large reduction in noise and cost, while intentionally never sampling the events that actually matter for debugging.
Logging also sits in tension with security and compliance: the same structured fields that make logs useful for debugging (email addresses, IPs, request bodies) are frequently regulated data under GDPR, PCI-DSS, or similar frameworks, which is why redaction at serialization time - not "remember not to log passwords" as a developer habit - is the reliable control. PII Redaction & Compliance covers this in depth.
The industry direction is convergence, not divergence: OpenTelemetry's Logs signal is standardizing structured logs alongside traces and metrics under one correlation model, so a log line, a span, and a metric data point about the same request increasingly share the same trace_id and semantic field names rather than living in three unrelated tools.
| Approach | Strength | Weakness | Best Fit |
|---|---|---|---|
| Unstructured text logs | Zero setup; human-readable in a raw terminal | Unqueryable at scale; no reliable field extraction; encourages inconsistent messages | Local development only |
| Structured JSON logs (Pino) | Machine-parseable; fast to filter/alert on; low per-call overhead | Requires discipline on field naming across services; less pleasant to eyeball raw | Production services, default choice |
| OpenTelemetry Logs signal | Native correlation with traces/metrics via shared trace_id; vendor-neutral pipeline | Newer, less universally supported by every backend; adds SDK surface | Services already instrumented with OTel tracing |
console.log is fine as long as it works." It bypasses level filtering, structured serialization, and redaction entirely, and it's usually the first thing that breaks a JSON-only log pipeline downstream.error for routine, self-healing events (like a single retry) trains operators to ignore real errors.A structured log is a data object (typically serialized to JSON) with named fields plus a short message, rather than a single free-form string with values interpolated into it. The distinction matters because machines can filter, aggregate, and alert on named fields; they can't reliably parse arbitrary sentences.
Because the level filter runs first, before any serialization or I/O happens - a debug call in a service running at info level costs almost nothing, since the object never gets turned into a string or written anywhere. Understanding this order explains why liberal use of debug calls is cheap in production as long as the level is set correctly.
Log level is a technical signal about operator response (investigate now, watch a trend, informational), while business impact is a separate axis - a warn-level retry might have zero business impact if it succeeds, while an info-level "refund processed" line might be very important to a specific team even though nothing is broken.
A unique identifier (a requestId, or a trace_id shared with tracing) is generated or received once, early in the request's lifecycle, and then attached to every subsequent log line for that request - either via a bound "child" logger passed down the call stack, or read from context storage (AsyncLocalStorage) at each call site.
Most log aggregation platforms index fields to make them searchable, and indexing cost scales with how many distinct values a field has, not just how many lines exist. A field like statusCode (a handful of values) indexes cheaply; a field with effectively unlimited unique values (like a raw error message with an embedded id) can be far more expensive to index than raw line count suggests.
When the routine, high-volume path (successful health checks, repetitive polling) contributes cost and noise without adding debugging value - sample those, while never sampling the events that actually indicate a problem (errors, security events, business-critical transactions).
No - it skips level filtering, structured field serialization, and any redaction rules entirely, and it writes synchronously in a way that doesn't compose with structured log pipelines downstream. A real logger is a small system with configurable behavior; console.log is a single fixed behavior.
They answer different questions: a trace shows timing and causal structure across services for one request, while a log carries specific, often arbitrary detail (an exact error message, a payload field) that a trace's structured spans typically don't capture. Correlating them via a shared id lets you jump from "this request was slow" (trace) to "here's exactly why" (log) in one step.
Logs are frequently retained longer, replicated to more systems (aggregators, backups, third-party SaaS), and accessed by more people than the primary database - meaning PII in logs can violate GDPR, PCI-DSS, or internal data-handling policies even if the primary data store is fully compliant. Redaction at serialization time is the reliable fix, not developer discipline alone.
Not in practice - tools like pino-pretty reformat structured JSON into a readable, colorized line for local development, while production still receives and stores the raw structured object. You get both: machine-queryable storage and human-readable local output, from the same underlying data.
Pino remains the emission library; OpenTelemetry's Logs signal is a standardization effort for how structured logs are represented and correlated with traces and metrics, using shared identifiers like trace_id. The two aren't competitors - many setups use Pino to emit and an OTel-aware pipeline to correlate and export.
Treating it as free - logging everything at info with no thought to level, cardinality, or redaction - and only discovering the cost (financial, in noise, or in compliance risk) once volume grows past what a single developer glancing at output can absorb.
Stack versions: This page is conceptual and not tied to a specific stack version, though illustrative snippets assume Node.js 24 LTS and TypeScript 5.6+.
Reviewed by Chris St. John·Last updated Jul 19, 2026