Pino is a structured JSON logger for Node.js. A useful Pino logger configuration does four things before the first event reaches stdout: rejects an invalid log level, emits an ISO timestamp, gives the message and severity stable field names, and removes known secrets. This guide builds that configuration with Pino 10.3.1, verifies its output, and then collects the JSON from Docker without adding an application-side network transport.
Install and create one explicit Pino logger
Pin the dependency so an install produces the version this example was tested against:
npm install --save-exact [email protected]
npm pkg set type=module
Keep logger construction in one module. Other modules should import this instance or create children from it instead of calling pino() repeatedly.
// logger.js
import { hostname } from 'node:os'
import pino from 'pino'
const allowedLevels = new Set([
'trace',
'debug',
'info',
'warn',
'error',
'fatal'
])
export function readLogLevel(value = 'info') {
const level = value.trim().toLowerCase()
if (!allowedLevels.has(level)) {
throw new Error(`Invalid LOG_LEVEL: ${value}`)
}
return level
}
export function createLogger(destination) {
const options = {
level: readLogLevel(process.env.LOG_LEVEL),
messageKey: 'message',
timestamp: pino.stdTimeFunctions.isoTime,
base: {
pid: process.pid,
hostname: hostname(),
component: 'checkout-api',
environment: process.env.NODE_ENV ?? 'development'
},
formatters: {
level(label, number) {
return { level: number, severity: label.toUpperCase() }
}
},
serializers: {
err: pino.stdSerializers.err
},
redact: {
paths: [
'request.headers.authorization',
'request.headers.cookie',
'customer.password',
'items[*].token'
],
censor: '[REDACTED]'
}
}
return destination ? pino(options, destination) : pino(options)
}
export const logger = createLogger()
Pino's default threshold is info. That means info, warn, error, and fatal calls are emitted, while trace and debug calls are skipped. The explicit validator keeps a typo such as LOG_LEVEL=verbose from silently changing startup behavior. Change the allowlist only when the application intentionally defines custom levels.
pino.stdTimeFunctions.isoTime writes UTC ISO 8601 text under Pino's time key. messageKey changes the default msg key to message. The level formatter retains Pino's numeric level for its own threshold and routing semantics while adding the string severity that Fluxtail expects. Those names make the later collector boundary small and visible.
Log fields first and a message second
Pass structured data as the first argument and a stable message as the second:
import { logger } from './logger.js'
const requestLog = logger.child({
request_id: 'req_7f2b',
route: '/checkout'
})
requestLog.info(
{
order_id: 'ord_1842',
amount_minor: 2599,
currency: 'CAD'
},
'checkout authorized'
)
A representative line is:
{"level":30,"severity":"INFO","time":"2026-09-15T14:22:31.418Z","pid":7312,"hostname":"api-1","component":"checkout-api","environment":"production","request_id":"req_7f2b","route":"/checkout","order_id":"ord_1842","amount_minor":2599,"currency":"CAD","message":"checkout authorized"}
The timestamp, process ID, and host vary at runtime. The important contract is the field type and meaning: level remains numeric, severity remains text, amount_minor remains a number, request_id remains a string, and message remains short enough to search.
Use a child logger for metadata shared by a request, job, tenant-safe execution context, or subsystem. Do not attach an order ID to a long-lived service child if that child will process many orders. Child bindings persist on every event emitted by that child.
Reserve level, severity, time, message, pid, and hostname for the logger. Arbitrary request data can otherwise collide with Pino's top-level keys and produce ambiguous JSON. Put untrusted values under an application-owned object, select only required properties, and redact them before logging.
Selection is also the reliable way to bound noisy runtime objects. For example, build a small request value from the method, route template, and a truncated user-agent string instead of passing the complete framework request. A redaction rule removes configured secrets; it does not impose a general event-size limit.
Serialize errors instead of losing the stack
Log an Error under the configured err key:
try {
await chargeOrder('ord_1842')
} catch (err) {
logger.error(
{
err,
order_id: 'ord_1842',
operation: 'charge_order'
},
'payment provider request failed'
)
}
The standard error serializer retains useful properties such as the error type, message, and stack. Passing only err.message discards the stack and makes separate failures harder to distinguish. Avoid serializing complete HTTP request, response, SDK, or database objects; select the few fields needed to diagnose the operation.
The message should describe the failed operation, not repeat a variable exception string. Search payment provider request failed, then inspect err.type, err.message, and order_id on the matching event. When the call uses a request-scoped child, its request_id is included as well.
Redact known paths before stdout
Pino performs configured redaction before writing the JSON line. Its path syntax supports JavaScript-style dot and bracket notation plus * wildcards. It is case-sensitive, and a wildcard covers only the position where it appears; it is not a recursive secret detector.
For example, items[*].token covers each direct item token. It does not cover items[*].credentials.token, accessToken, differently cased keys, or secrets embedded inside a message string. Update the path list when the application's schema changes, and test it with representative nested objects. Never build redaction paths from user input.
Redaction is a safety layer, not permission to log entire request bodies. Prefer an allowlist of useful fields, keep credentials out of messages, and avoid logging payment data, session cookies, authorization headers, or personal data that is not required for operations.
Test the JSON contract
The following test sends records to an in-memory writable stream. It checks level filtering, ISO time, typed fields, child bindings, error serialization, and nested redaction without matching a complete runtime-dependent log line.
// logger.test.js
import assert from 'node:assert/strict'
import { Writable } from 'node:stream'
import { createLogger, readLogLevel } from './logger.js'
const records = []
const destination = new Writable({
write(chunk, _encoding, callback) {
for (const line of chunk.toString().trim().split('\n')) {
if (line) records.push(JSON.parse(line))
}
callback()
}
})
const logger = createLogger(destination)
const requestLog = logger.child({ request_id: 'req_test_1' })
requestLog.debug({ ignored: true }, 'debug detail')
requestLog.info(
{
order_id: 'ord_test_1',
amount_minor: 2599,
request: { headers: { authorization: 'Bearer secret' } },
items: [{ sku: 'sku_1', token: 'private' }]
},
'checkout authorized'
)
logger.error(
{ err: new TypeError('upstream timeout') },
'payment provider request failed'
)
assert.equal(records.length, 2)
assert.equal(records[0].level, 30)
assert.equal(records[0].severity, 'INFO')
assert.equal(records[0].message, 'checkout authorized')
assert.equal(records[0].request_id, 'req_test_1')
assert.equal(records[0].amount_minor, 2599)
assert.match(records[0].time, /^\d{4}-\d{2}-\d{2}T.*Z$/)
assert.equal(records[0].request.headers.authorization, '[REDACTED]')
assert.equal(records[0].items[0].token, '[REDACTED]')
assert.equal(records[1].err.type, 'TypeError')
assert.match(records[1].err.stack, /upstream timeout/)
assert.throws(() => readLogLevel('verbose'), /Invalid LOG_LEVEL/)
assert.throws(() => readLogLevel(''), /Invalid LOG_LEVEL/)
console.log('Pino JSON contract verified')
Run it as a plain script:
node logger.test.js
The command prints Pino JSON contract verified when every assertion passes.
Keep stdout as the container boundary
Pino writes newline-delimited JSON to stdout by default. In a container, that is usually the simplest boundary: the application has no receiver token, no retry loop, and no network transport to maintain. The runtime writes container logs, and a collector tails those files independently.
Avoid piping production output through a pretty printer. Pretty output is useful in a local terminal, but it turns typed JSON fields into display text before the collector can parse them.
Pino destinations and worker transports have different buffering behavior. An asynchronous pino.destination({ sync: false }) can buffer recent lines; logger.flush(callback) drains that local destination buffer, but it does not prove remote delivery. A custom transport must also implement backpressure and close cleanly. Do not call process.exit() immediately after logging because Node can terminate before pending stdout writes complete. Stop accepting new work, finish in-flight work, emit the final record, set process.exitCode, and let normal completion drain the process where possible.
Collect Pino Docker logs with Fluent Bit
Fluxtail's Docker with Fluent Bit guide uses the shipped collector to tail Docker JSON files, parse JSON stored in Docker's log field, buffer retries on the filesystem, and send HTTPS JSON with the exact receiver URL and receiver-bound Bearer token. The application continues to write only to stdout.
For the logger above, the boundary is explicit:
| Pino output | Collector handling | Fluxtail field |
|---|---|---|
message |
Preserved after JSON parsing | message |
severity |
Preserved after JSON parsing | severity |
Numeric level |
Retained as additional Pino context | Not used as the Fluxtail severity field |
ISO time |
Parsed as the Fluent Bit event time; the shipped Lua filter emits it as ISO text | timestamp |
| Docker container and host data | Added by the shipped collector | service_name, host, and labels |
The shipped setup assigns service_name from the Docker container name and creates collector labels. Keep application-specific values such as request_id, route, and order_id as additional structured fields; do not claim they become first-class filters unless the receiver and read API document that mapping.
The relevant parser stage is already in the public collector configuration:
[FILTER]
Name parser
Match docker.*
Key_Name log
Parser json
Reserve_Data On
Preserve_Key Off
Do not add a guessed Pino HTTP transport. Use the complete documented configuration because its TLS verification, authorization header, exact receiver path, filesystem storage, retry policy, container exclusions, and metadata filters work together.
Verify the event in Fluxtail
After Fluent Bit starts, emit a unique marker from the application:
logger.info(
{ verification_id: 'pino_setup_20260915_01' },
'pino collector verification'
)
Open the receiver's stream in Live Tail and search for pino collector verification. Inspect the row and confirm INFO, the container-derived service name, host, timestamp, and collector labels. Then use Fluxtail's compact search and filters for service, severity, host, message, labels, time, stream, and Kubernetes metadata as documented in Search and Filters.
If the marker is missing, check the application container logs first, then the Fluent Bit logs. Confirm the Docker log mount, exact receiver path, matching receiver token, HTTP status, and selected stream before changing the logger.
Once the fields are visible, Fluxtail's hosted MCP can let an authorized agent search the same account-scoped logs during an investigation. That keeps agent-driven investigation close to the same simple Live Tail and filtering workflow used by a person. Keep the original events as the evidence and verify any AI-generated explanation against their timestamps and fields.
For another Node.js library with a different transport and formatting model, compare this focused setup with the Winston logger guide.
Create a self-service account at Fluxtail, configure an HTTP JSON receiver, and use the Docker collector guide to verify one Pino event end to end.