Guides
Guide

Pino Logger: JSON Logging, Redaction, and Transports

By Sazabi Team

Last verified on Nov 8, 2026.

Pino is a Node.js logger that writes one JSON object per line and does as little work as possible on the main thread. You call logger.info({ order_id: 4812 }, 'order placed') and get a JSON line with the level, a timestamp, and your fields. This guide sets up production output, redaction, child loggers, transports, request IDs through pino-http, and trace IDs.

Install and Configure

npm install pino

A production configuration with readable level labels, ISO timestamps, a service name, and redaction:

const pino = require('pino')

const logger = pino({
  level: process.env.LOG_LEVEL || 'info',
  base: { service: 'checkout' },
  timestamp: pino.stdTimeFunctions.isoTime,
  formatters: {
    level(label) {
      return { level: label }
    },
  },
  redact: {
    paths: ['req.headers.authorization', 'password', 'user.email'],
    censor: '[Redacted]',
  },
})

logger.info({ order_id: 4812, user: { id: 42, email: 'a@b.co' } }, 'order placed')
{
  "level": "info",
  "time": "2026-11-08T06:52:00.839Z",
  "service": "checkout",
  "order_id": 4812,
  "user": { "id": 42, "email": "[Redacted]" },
  "msg": "order placed"
}

Three defaults matter. Pino writes the level as a number (30 for info) unless formatters.level changes its shape, as above. Setting base replaces the default pid and hostname fields, so add them back if you want them. The message key is msg.

Levels

The level option accepts fatal, error, warn, info, debug, trace, or silent, and defaults to info. The Pino docs note that raising the level reduces load, since debug and trace output is rarely needed in production. Read the level from an environment variable so you can raise verbosity for one deploy. See log levels for how to choose between them.

The fatal method always flushes the destination synchronously, since fatal messages are meant for just before the process exits.

Child Loggers for Context

logger.child(bindings) returns a logger that adds the bindings to every line and shares the parent's output stream. It inherits the parent's level at creation, and you can change the child's level independently.

const child = logger.child({ request_id: 'req-8f3a' })
child.warn({ password: 'hunter2' }, 'retrying payment')
{
  "level": "warn",
  "time": "2026-11-08T06:52:00.841Z",
  "service": "checkout",
  "request_id": "req-8f3a",
  "password": "[Redacted]",
  "msg": "retrying payment"
}

One warning from the Pino docs: do not pass externally supplied objects straight into child(). Binding keys become top-level fields on every line, so a user-controlled key can collide with level, time, or msg. Nest untrusted data under a key you control, as in logger.child({ untrusted: obj }).

Pass errors as { err } and Pino serializes type, message, and stack into a nested object.

Redaction

redact takes an array of paths or an object with paths, censor, and remove. Paths support dot notation, [*] wildcards for arrays, and brackets for hyphenated keys:

redact: ['req.headers.authorization', 'stuff.thats[*].secret', 'path["with-hyphen"]']

The default censor is [Redacted]. Set remove: true to drop the key entirely. Redaction works on paths you name, so a secret under a key you did not list still gets through. Combine it with a rule that nobody logs whole request or user objects, and see secrets in logs for what to look for after the fact.

Transports and Pretty Printing

A transport is a module that receives log lines, and since Pino v7 it runs in a worker thread so formatting and shipping stay off your request path. For local work, use pino-pretty and keep production on plain JSON:

const logger = pino({
  transport: process.env.NODE_ENV === 'production' ? undefined : { target: 'pino-pretty' },
})

To split output by level, use targets:

const transport = pino.transport({
  targets: [
    { target: 'pino/file', options: { destination: 1 }, level: 'info' },
    { target: 'pino/file', options: { destination: '/var/log/app/errors.log' }, level: 'error' },
  ],
})
const logger = pino({ level: 'debug' }, transport)

Two gotchas from the transport docs, both reproduced in testing. With targets, a target with no level defaults to info, so a debug logger level alone does not get debug lines to a target. And when you use multiple targets, the record must keep a numeric level, so formatters.level cannot swap in labels. Transports also boot asynchronously, so calling process.exit() right after startup can lose logs.

If you configure pino.destination({ sync: false }) for lower overhead, the docs warn that the most recently buffered lines can be lost in a system failure, and that on AWS Lambda you should keep sync: true.

Request IDs With pino-http

For Express and similar frameworks, pino-http logs each request and attaches a child logger at req.log:

npm install pino-http
const express = require('express')
const pinoHttp = require('pino-http')
const { randomUUID } = require('node:crypto')

const app = express()
app.use(
  pinoHttp({
    logger,
    genReqId: (req, res) => {
      const id = req.headers['x-request-id'] || randomUUID()
      res.setHeader('X-Request-ID', id)
      return id
    },
    quietReqLogger: true,
  })
)

app.get('/order', (req, res) => {
  req.log.info({ order_id: 4812 }, 'order placed')
  res.send('ok')
})
{
  "level": "info",
  "time": 1794120744581,
  "service": "checkout",
  "reqId": "req-8f3a",
  "order_id": 4812,
  "msg": "order placed"
}

Without quietReqLogger, every line from req.log repeats the full req object, headers included. That is noisy and a leak risk, so keep it on and let the automatic "request completed" line carry the details. Add autoLogging.ignore for health checks, and watch the 4xx lines with client errors. The value is your correlation ID. For the broader Express and morgan story, see the Node.js logging guide.

Add Trace and Span IDs

The simplest route is @opentelemetry/instrumentation-pino. Registered with your OpenTelemetry SDK, it adds trace_id, span_id, and trace_flags to any line logged inside a span, and its README notes it can also send Pino records to the OpenTelemetry Logs SDK. Setup is in the OpenTelemetry Node.js guide.

Without the instrumentation, use Pino's mixin, which runs on every log call:

const { trace, context } = require('@opentelemetry/api')

const logger = pino({
  mixin() {
    const span = trace.getSpan(context.active())
    if (!span) return {}
    const { traceId, spanId } = span.spanContext()
    return { trace_id: traceId, span_id: spanId }
  },
})

Inside a span the line gains "trace_id":"7fa8e97cddc892bb535a39e2b7b90672","span_id":"3e194307ffbf44a1"; outside one it gains nothing. Either way, you can follow a trace ID from a slow request to every line it logged.

Common Mistakes

  • Shipping pino-pretty to production. It is a development formatter, and the docs note logger.flush() does not flush its output.
  • Forgetting msg is the message key. Backends that look for message need messageKey: 'message'.
  • Logging a string with data in it. Put values in the merging object and keep the message constant.
  • Passing req or user wholesale. Redaction needs explicit paths. Log IDs.
  • Calling process.exit() in an error handler. Buffered or transport-bound lines may not be written. Let fatal flush, then exit.

Production Tips

  • Log to stdout in production and let the platform or a collector forward it. See the JSON logging guide and logging best practices.
  • Create one root logger per process and derive everything else with child().
  • Set LOG_LEVEL through the environment, not in code.

Sending Pino Logs to Sazabi

Sazabi, an AI-native observability and security platform, accepts logs and traces over OTLP/HTTP. Its OpenTelemetry source docs say to add an explicit log record processor with an OTLP log exporter to the SDK if traces arrive without logs, as the OpenTelemetry Node.js guide shows. With PinoInstrumentation registered, Pino records flow to that processor. Pino's own transport docs also list pino-opentelemetry-transport, which forwards logs to an OpenTelemetry collector. See the OpenTelemetry integration and its collector page.

Frequently Asked Questions

Why does Pino log the level as a number?

Pino writes numeric levels by default, for example 30 for info. Use the formatters.level option to log the label instead, or map numbers to names in your log tool.

How do I redact sensitive fields in Pino?

Pass a redact option with paths such as req.headers.authorization or user.email. Pino replaces the values with [Redacted], or removes the keys if you set remove to true.

Should I use pino-pretty in production?

No. pino-pretty is for readable local output. In production, write plain JSON to stdout and let your platform or collector handle it.

How do I add a request ID to every Pino log line?

Use pino-http with a genReqId function, then log through req.log. Each line includes the request ID, and quietReqLogger keeps req.log lines short.

Sources

  1. Default level is info, level names, formatters.level, mixin, redact option, child loggers inherit the output stream and level, error serialization under err, and fatal always sync flushes (accessed Nov 8, 2026)
  2. redact paths, wildcards, bracket notation for hyphenated keys, censor, and remove (accessed Nov 8, 2026)
  3. Transports run in a worker thread, targets with per-target levels that default to info, process.exit before startup loses logs, and pino-pretty and pino-opentelemetry-transport (accessed Nov 8, 2026)
  4. Child loggers add bindings to every line, and untrusted objects should not be passed directly as bindings (accessed Nov 8, 2026)
  5. Asynchronous destinations buffer writes for lower overhead, and the most recent buffered messages can be lost in a system failure (accessed Nov 8, 2026)
  6. genReqId, quietReqLogger, autoLogging, and req.log (accessed Nov 8, 2026)
  7. PinoInstrumentation adds trace_id, span_id, and trace_flags to log records inside a span, and can send logs to the OpenTelemetry Logs SDK (accessed Nov 8, 2026)