01 · Definition
+Pino, the JSON logger for Node.js
+ +Pino is a logger for Node.js that writes each log entry as one line of JSON. It is built to add as little work as possible to the code that serves your requests.
+
+ JSON lines suit services that run in containers or on platforms that collect stdout: whatever reads the output can filter on fields instead of matching text. Pino is MIT-licensed, Fastify uses it as its built-in logger, and pino-http adds request logging to Express and plain http servers.
+
+ Every call takes the same path. On the thread that runs your code, Pino drops calls below the configured level and turns the rest into a JSON line. Where the line goes next depends on your setup: +
+ +A destination and a transport are alternatives. The OpenTelemetry path is added on top of either one, so the line still reaches stdout or your transport.
+02 · Setup
+Install Pino and write the first logs
+ +The first log line
+Pino is one package and needs no configuration to start:
+Run{t.run}
+ Output
+ Each method takes an optional object first and the message second. The object's keys become top-level fields of the line, which is what lets you filter on port later. Arguments after the message fill %s, %d and %o placeholders, and an Error passed first is written under err with its type, message and stack.
+
+ Put data in the object, not in the message. logger.info({ orderId }, "order created") gives you an orderId field to search; a template string gives you text to parse back.
+
Pino log levels
+
+ Pino writes the level as a number. A call below the logger's level returns before any serialization, so a disabled debug call costs almost nothing.
+
| Level | Value | Use it for |
|---|---|---|
| {l.name} | +{l.value} | +{l.use} | +
+ The default is info, and silent turns logging off, for example in tests. Read the level from an environment variable so one deploy can run at debug without a code change:
+
+
+ A disabled call still evaluates its arguments, so wrap expensive ones in isLevelEnabled(). The customLevels option adds levels with your own names and numbers.
+
Child loggers
+
+ logger.child(bindings) returns a logger that adds the same fields to every line it writes. Pino serializes the bindings once, when the child is created, so a child per request is cheap.
+
+ Output
+ The usual pattern is one child per request or job, carrying a request ID or tenant, passed to the code that handles it. The second argument to child() overrides level, redact, serializers or msgPrefix for that child only.
+
03 · Production
+Configure Pino for production
+ +Redact secrets and trim objects
+
+ redact replaces values at the paths you list before the line is written. Paths use dot and bracket notation, [*] matches every array element, and keys with hyphens need brackets. A serializer is a function keyed by a top-level field name: when a line has that field, Pino writes the function's return value instead of the object.
+
+ Output
+ The default censor is "[Redacted]"; remove: true drops the key instead. Redaction only covers the paths you list, so a token under a new key name goes out in clear text. Logging whole request bodies is the larger risk, and a serializer that picks a few fields avoids it. pino.stdSerializers provides err, req and res; err is on by default.
+
pino-pretty in development only
+
+ pino-pretty turns JSON lines into colored, readable ones. Install it as a dev dependency and load it as a transport only when NODE_ENV is development, so a production install without dev dependencies never asks for it:
+
+ Output
Or leave the code alone and pipe the output through the CLI, which takes the same options as flags:
++ The pino-pretty README advises against running it in production: keep production output as JSON so the tool that collects it can read the fields. +
+ +Transports run in a worker thread
+
+ A transport is a module that receives Pino's lines and writes them somewhere. pino.transport() starts it in a worker thread, so formatting and network I/O don't compete with request handling. With targets, one logger feeds several transports, each with its own minimum level:
+
+ -
+
targetis a package name or an absolute path to your own module.pino/fileships with Pino.
+ - A target without a
levelgetsinfo. The logger's level applies first, so set it to the lowest level any target needs:debugin the example.
+ optionsis copied to the worker, so it cannot contain functions.
+ - Pino flushes the worker on a normal exit. The Pino docs treat an
'error'event from a transport as fatal.
+
Sync, async and buffered writes
+
+ Without a transport, Pino writes on the main thread through a destination built on sonic-boom. pino.destination() changes where it writes and when:
+
+ | Setting | Runs on | Behaviour | Use it when |
|---|---|---|---|
{w.setting.includes(":") || w.setting.includes("(") ? {w.setting} : w.setting} |
+ {w.runs.name} | +{w.behaviour} | +{w.when} | +
+ Pino's docs note that buffered logging in Lambda tends to delay or lose lines, because the runtime freezes the process before the buffer is written. +
+04 · HTTP
+Log HTTP requests with pino-http or Fastify
+
+ A request log needs one line per request with its status and duration, plus a per-request child logger so every line a handler writes carries the same request ID. Express gets both from pino-http; Fastify has them built in.
+
+
+
+ Both servers write the handler's line with the request ID and the orderId field. The next section adds the trace ID to that same line.
+
05 · Correlation
+Trace IDs and Pino logs in Maple
+
+ A line that says payment provider unreachable is more useful when you can open the request it came from. The OpenTelemetry pino instrumentation does two things, with no change to your logging calls:
+
-
+
- Lines written inside an active span get trace_id, span_id and trace_flags fields. +
- Pino's output is split in two: one copy goes to your destination or transport as before, the other to the OpenTelemetry log SDK, which batches the records and exports them over OTLP. This needs Pino 7 or later. +
Start the OpenTelemetry SDK
+
+ The instrumentation is part of @opentelemetry/auto-instrumentations-node, so the standard Node.js SDK setup turns it on. Install the SDK packages:
+
+ Create tracing.mjs next to server.mjs. It starts the SDK before your code loads Pino, which is what lets the instrumentation patch it. MAPLE_KEY is an ingest key from Maple, covered in the Maple steps below.
+
+ Check the BatchLogRecordProcessor signature
+
+ Current @opentelemetry/sdk-logs releases take an options object, new BatchLogRecordProcessor({ exporter }). Older releases took the exporter as the first argument. Passing an exporter to a release that expects the object raises no error, and no log record leaves the process, so check the installed .d.ts if logs never arrive.
+
+ An ingest key can only write telemetry to your organization; it cannot read data. To keep it out of source anyway, the SDK can take its whole configuration from the standard OTEL_* variables, as in the Node.js guide's environment variables section.
+
The same line, with a trace ID
+Load tracing.mjs ahead of either server from the HTTP section:
+ Output
+ Stdout now carries the trace context. The exported copy uses Pino's level name as the severity text, with the matching OpenTelemetry severity number, and your fields, such as orderId and reqId, become log attributes. It leaves out pid, hostname and the trace fields, which the resource and the record's trace context already carry. Lines written outside a span, such as the startup listening line, are exported without a trace ID.
+
+ The export receives every line the logger writes, whatever the per-target levels of a pino.transport(). To keep the trace IDs and ship logs another way, turn log sending off in tracing.mjs:
+
+
+ Find the logs in Maple
+-
+
-
+ 01
+ +
Create an ingest key
Open Settings → Ingestion and copy the private key (
maple_sk_...). Use it asMAPLE_KEYintracing.mjs, and switchMAPLE_ENDPOINTto the EU host if your organization is in the EU region.
+ -
+ 02
+ +
Filter the Logs page
Start the server with the SDK and send it a few requests. In Logs, filter Service to
+orders-api. The Severity filter lists Pino's level names. Paste a trace ID into the search box to see every line from one request.
+ -
+ 03
+ +
Open a line
Click a
+loading orderrow, then Open detail. Attributes showsorderIdandreqId, and the meta strip shows thetrace:link and span ID.
+ -
+ 04
+ +
Move to the trace
Click the
+trace:link to open the request's spans at the log's timestamp. From a trace, View Logs goes the other way.
+
Set it up in Maple
+Send your Node.js telemetry
+The SDK setup, environment variables and troubleshooting are in the docs.
+ +06 · Comparison
+Pino vs Winston
++ Winston is the other widely used Node.js logger. Both are MIT-licensed and both work with OpenTelemetry. They differ mostly in where formatting and shipping happen. +
+| Pino | Winston | |
|---|---|---|
| {row} |
+ Choose Pino when logs are JSON headed for a collector and per-call cost matters; Pino publishes its own benchmarks against other loggers. Choose Winston when the process itself must write several formats to several destinations, or an existing codebase depends on its format pipeline. Moving between them is mostly mechanical: both take a metadata object and a message, and Pino puts the object first. +
+Sources and further reading
+ +