Log Levels and Transports

Levels, Redaction and Transports

Pino 18,227 's six levels are numbers: trace 10, debug 20, info 30, warn 40, error 50, fatal 60. A logger drops anything below its own level, and the check is a numeric comparison made before the arguments are serialized, so a log.debug({ big: object }) nobody will print costs one integer compare. Take the level from the environment and you can raise it on a misbehaving pod without a rebuild.

The second concern is what you must never write down. Passwords, tokens, card numbers and authorization headers reach logs through an object that quietly grew a field. redact takes JSONPath-like paths, wildcards included, and censors those values as the line is built.

logger.mjs — level from the environment, secrets removedJavaScript
import pino from 'pino';
const log = pino({
  base: null,
  level: process.env.LOG_LEVEL ?? 'info',
  redact: { paths: ['user.password', 'card', 'req.headers.*'], censor: '[redacted]' },
});
log.info({ user: { id: 'u-77', password: 'hunter2' } }, 'signup');
log.warn({ card: '4111111111111111', total: 78.5 }, 'retry');
Output
{"level":30,"time":1789645248117,"user":{"id":"u-77","password":"[redacted]"},"msg":"signup"}
{"level":40,"time":1789645248119,"card":"[redacted]","total":78.5,"msg":"retry"}

The key err is special: pino serializes an Error there to {type, message, stack} and follows cause, appending the inner trace after a caused by: line and folding the inner message into the outer one, so new Error('checkout failed', { cause: new Error('socket hang up') }) logs "message":"checkout failed: socket hang up". The Errors and Causes chain arrives intact.

Writing anywhere but stdout goes through a transport, which runs on a worker thread connected by a SharedArrayBuffer-backed stream, so formatting and disk I/O never occupy the event loop:

Two destinations, one worker threadJavaScript
const transport = pino.transport({
  targets: [
    { target: 'pino-pretty', level: 'info', options: { colorize: false } },
    { target: 'pino/file', level: 'warn',
      options: { destination: './app.log', mkdir: true } },
  ],
});
const log = pino({ base: null }, transport);

Each target filters by its own level: the console shows info and up in pretty form while app.log receives only the JSON for warn and above. pino/file is built in; pino-roll 4.0.0 adds rotation by size or time, pino-loki 3.0.0 ships to Grafana Loki 2,264 . In a container, prefer none of them: write JSON to stdout.