Pino logging in Node.js: fields, errors, transports, and redaction
Configure Pino JSON logs, child loggers, error serialization, redaction, worker transports, and crash diagnostics, then verify collection in Guance.
Pino writes structured JSON logs from Node.js applications. Its child loggers, error serialization, redaction, and transport API let an application keep a consistent event schema while choosing how to deliver those events. Fastify uses Pino when logging is enabled.
This guide builds that configuration step by step, then checks the boundary between application output and collection in Guance. Pino is designed to reduce logging overhead, but the result still depends on payload size, serializers, destination speed, and shutdown behavior. Measure those costs with your own workload.
Start with JSON output
Install Pino in a Node.js project, save this example as app.mjs, and run node app.mjs:
npm install pino
import pino from 'pino';
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
});
logger.info('Service started');
logger.error('Database connection failed');
Each event is one JSON line. A representative record is:
{"level":30,"time":1756100000000,"pid":5422,"hostname":"prod-01","msg":"Service started"}
The timestamp is Unix time in milliseconds. Standard levels are trace=10, debug=20, info=30, warn=40, error=50, and fatal=60. The default threshold is info. These numeric levels are usable production data; a collector can map them to its own severity field.
Define the timestamp and shared fields
If your collection schema uses ISO timestamps and a readable level label, replace the logger configuration with:
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
timestamp: pino.stdTimeFunctions.isoTime,
formatters: {
level: (label, number) => ({ level: number, level_name: label.toUpperCase() }),
bindings: (bindings) => ({
pid: bindings.pid,
host: bindings.hostname,
node_version: process.version,
}),
},
});
This retains numeric level and adds level_name. That distinction matters with multiple transport targets: Pino uses the numeric field for routing. Replacing it with "INFO" can break that routing. For a single destination that explicitly expects text, level: (label) => ({ level: label.toUpperCase() }) is an alternative formatter; verify every consumer before changing the schema. See the Pino API and transport guidance.
Choose one timestamp representation and tell the parser whether it is a string or an epoch value. Renaming hostname to host also changes the collection contract; do not assume the platform will infer every mapping.
Attach request context with a child logger
Bind repeated fields once:
const reqLogger = logger.child({
request_id: 'req-abc-123',
service: 'order',
});
reqLogger.info({ order_id: 'ORD-34567' }, 'Order created');
Pass reqLogger into the work associated with that request, or use your framework's request logger. A child logger does not automatically discover the active request. Avoid using the same mutable request fields across concurrent requests.
A business request identifier and a distributed trace identifier serve different purposes. Preserve request_id; add the actual active trace_id separately when tracing is configured.
Keep the Error object
Pass an error under err so its type, message, and stack are serialized:
const err = new Error('Payment provider unavailable');
logger.error({ err, order_id: 'ORD-34567' }, 'Payment processing failed');
Pino already configures pino.stdSerializers.err for err by default. Explicitly setting serializers: { err: pino.stdSerializers.err } can document that choice, but it is not required to activate the default behavior. If you change the error key or replace serializers, check the resulting JSON. Passing only err.message discards the stack and other error information.
Inspect error text as well as structured fields for secrets. Redacting password will not remove a password embedded inside an exception message.
Record a crash without suppressing Node's exit behavior
An uncaught exception leaves the application in an uncertain state. Record it and let a supervisor restart the process; do not treat a logging handler as permission to resume serving requests.
For a small synchronous crash record, add this near application startup:
const crashLogger = pino(
{ level: 'fatal' },
pino.destination({ dest: 2, sync: true }),
);
process.on('uncaughtExceptionMonitor', (err, origin) => {
crashLogger.fatal({ err, origin }, 'Uncaught failure; process will terminate');
});
Run with an explicit unhandled-rejection policy:
node --unhandled-rejections=strict app.mjs
Under that policy, an unhandled Promise rejection becomes an uncaught exception. uncaughtExceptionMonitor observes the failure without replacing Node's default crash behavior. Other application-installed exception handlers can change that behavior, so audit the complete process configuration. See Node's exception events.
This synchronous stderr path avoids depending on a worker transport during the crash handler. It still cannot promise a record after a forced kill, machine failure, or destination failure. Test both thrown exceptions and rejected Promises in a disposable process, along with your container or service restart policy.
Choose stdout or a file transport
Stdout is a useful default in containers when the runtime and collector own retention and delivery. For a file destination, a Pino v7+ transport can move destination work into a worker thread:
import pino from 'pino';
const transport = pino.transport({
target: 'pino/file',
options: { destination: './logs/app.log', mkdir: true },
});
const logger = pino({ level: 'info' }, transport);
logger.info('File logging started');
Save this alternative example in its own .mjs file. mkdir: true permits creation of a missing destination directory; permissions still need to be correct. Serialization and preparing each event still cost time in the application thread. A worker does not provide unlimited buffering or eliminate backpressure.
Transports start asynchronously. An immediate process.exit() can interrupt startup or pending output; follow the transport's lifecycle and test shutdown instead of assuming the last log was delivered. Normal process exit, application shutdown, transport failure, and abrupt termination are separate cases.
The built-in pino/file target does not manage rotation. Coordinate external rotation with file reopening, or use a transport that implements the required rotation policy. Renaming a file alone can leave a process writing to the old inode, and copytruncate has a loss window. Verify the collector follows the files you actually produce.
For local development, install pino-pretty and run node app.mjs | npx pino-pretty. Keep machine-readable JSON on the production collection path.
Redact structured secrets
Use an allowlist when choosing fields, then add redaction as a second control:
const logger = pino({
redact: {
paths: ['password', 'profile.phone', 'req.headers.authorization'],
},
});
logger.info({ username: 'john', password: 'secret' }, 'Sign-in attempted');
The matching password value becomes [Redacted]. Add remove: true inside the redact object to omit matched properties instead. Paths are case-sensitive and must match the emitted object structure; nested arrays and different header names require their own rules. Never let untrusted input define redaction paths. Consult Pino's redaction reference.
Test final output containing representative sensitive values. A field rule does not sanitize a free-text message or every custom serializer.
Verify collection in Guance
Configure the actual file or container output using the DataKit log collector. Set a stable source and service, parse the selected timestamp format, map numeric level or your chosen label to status, and preserve request fields.
Send one information event, one error with a stack, and several events with a known request_id. Check their timestamps, severity, and request grouping before configuring a log detection rule. For application performance monitoring (APM) correlation, follow the log-to-trace association requirements and verify the actual trace identifier against a stored trace.
Also test a collector interruption and a file rollover if applicable. Successful application output and successful backend ingestion are separate observations.
Common questions
Should every application convert numeric levels to text?
No. Keep numeric levels when your transport or collector expects them. A separate label is convenient for people, while a verified mapping supplies the backend's severity field.
Can Pino send directly to a log platform?
Yes, through an appropriate transport. A worker transport can keep network handling off the main thread, but it still needs bounded buffering, retries, failure reporting, and shutdown behavior. A local output plus collector makes those delivery concerns easier to operate independently.
Does Pino rotate files automatically?
The built-in file transport does not. Choose and test an external reopening strategy or a rotation-capable destination. Collector compatibility depends on that actual strategy.
When should I choose Pino over Winston?
Choose based on the API, schema, destinations, and integration you need. Compare both with identical fields, payload sizes, destinations, and load, including slow-output and shutdown cases. A library benchmark alone does not measure the production delivery path.
Related reading
Contact us
Join the community
to join the community
Try Guance
Start online and pay only for what you use.
Get startedChoose a Guance plan