At 02:14, an HTTP 500 appears in a live stream during a deployment. The message says “request failed,” but it doesn't identify the request, release, route, or downstream operation. A Pino logger setup changes that investigation from text matching into field filtering, because each event can carry a stable service name, severity, timestamp, request identifier, and carefully selected context.
Pino is most useful when treated as a production log shape layer. The application emits newline-delimited JSON, while infrastructure or an explicit receiver handles collection, routing, search, and retention. The rollout succeeds when the schema stays predictable, sensitive fields are removed before emission, and transport choices match the receiver protocol instead of relying on guessed endpoints.
Table of Contents
- What Pino Is and Why Production Teams Pick It
- Basic Setup, Levels, and Child Loggers in Node.js
- Pino Performance Characteristics and the Transport Caveat
- Structured Fields, Redaction, and Correlation IDs
- Routing Pino Logs to Fluxtail With Transports and Receivers
- Troubleshooting Common Pino Pitfalls
- A Production-Ready Pino Checklist and Next Step
What Pino Is and Why Production Teams Pick It
Pino is a Node.js logging project established on GitHub in 2016. Its current repository metadata shows ongoing maintenance, including recent release and commit activity. That matters for services expected to run through Node.js upgrades, framework changes, container deployments, and evolving observability pipelines.
Pino has grown from a small utility into a practical production logger for APIs, workers, and command-line processes. Its useful boundary is clear: the application defines the event shape, while collection, routing, search, and retention belong to infrastructure or an explicit receiver.
JSON belongs on the production path
Pino writes newline-delimited JSON to stdout by default. Each line is a complete record, allowing a runtime, collector, command-line filter, or centralized log platform to parse fields without interpreting fragile human-formatted text.
A basic event looks like this:
{"level":30,"time":1717000000000,"pid":1,"hostname":"api-pod","userId":42,"msg":"login attempt"}
The numeric level is intentional. Pino assigns trace 10, debug 20, info 30, warn 40, error 50, and fatal 60. Its configured level acts as a minimum threshold, so equal and higher severities are emitted while lower ones are skipped. The Pino level documentation defines this hierarchy and threshold behavior.
That predictable shape is what makes centralized ingestion reliable. A receiver can bind to the expected protocol and parse the record directly, rather than guessing whether a line is plain text, JSON, or a framework-specific format.
Keep formatting separate from ingestion
Raw JSON is the right production default because downstream systems can query fields independently. Human-readable formatting belongs at the developer terminal, where a pretty printer can transform stdout without changing the application's event schema.
Pino also supports child loggers, serializers, redaction, level routing, and transports. These controls establish consistent records before they leave the process, which gives incident responders usable fields instead of attractive but inconsistent messages.
Practical rule: Keep application output machine-readable, then make the viewing layer human-readable.
Basic Setup, Levels, and Child Loggers in Node.js
Start with the smallest useful installation:
npm i pino
const pino = require('pino');
const logger = pino({
level: process.env.LOG_LEVEL || 'info'
});
logger.info({ userId: 42 }, 'login attempt');
The resulting line follows Pino's structured format:
{"level":30,"time":1717000000000,"pid":1,"hostname":"api-pod","userId":42,"msg":"login attempt"}
Treat this output as the application's event contract. The timestamp and process metadata travel with the record, while userId remains a field that a centralized log platform can index and query. Align the configured threshold with your operational log severity levels, so alerts and dashboards interpret events consistently.
Use levels as an emission boundary
Pino's standard levels are:
| Level | Value | Typical use |
|---|---|---|
trace |
10 | Very detailed diagnostic context |
debug |
20 | Development and targeted troubleshooting |
info |
30 | Normal service milestones |
warn |
40 | Degraded behavior that needs attention |
error |
50 | Failed operations |
fatal |
60 | Process-threatening failures |
A production service should usually start at info, with the threshold changed deliberately through configuration. The setting is an emission boundary: records below it are skipped, while records at or above it are written. Keep noisy messages out of tight loops anyway. A disabled level reduces output, but it does not repair poor event design.
Create a child logger at the request boundary
Attach stable request metadata once:
const requestLogger = logger.child({
requestId: 'req-7f2',
route: '/orders'
});
requestLogger.info('request started');
requestLogger.info({ orderId: 'ord-18' }, 'order loaded');
Both records inherit requestId and route. A child logger uses the parent output stream and level when created, and its level can later be changed independently. The Pino child logger API documents this behavior.
For HTTP objects, use targeted serializers instead of passing complete request and response objects at every call site:
const logger = pino({
level: 'info',
serializers: {
req: pino.stdSerializers.req,
res: pino.stdSerializers.res
}
});
This keeps calls short and makes the emitted shape explicit for protocol-bound receivers. Keep application output machine-readable, then let the viewing layer format it for developers.
Pino Performance Characteristics and the Transport Caveat
Pino's performance reputation comes from benchmark results, but those results describe logger overhead, not delivery from an application to an operator's search screen. In a basic test, Pino averaged 114.801 ms, compared with 377.434 ms for Bunyan and 270.249 ms for Winston. The low-memory PinoMinLength variant averaged 70.968 ms in the same suite. See the Pino HTTP benchmark reference.
The deeper object test is a useful check against broad conclusions. Pino averaged 2.256 ms, while BunyanDeepObj averaged 1.839 ms and WinstonDeepObj 5.604 ms. Pino performs well for common logging patterns, though individual micro-tests can produce different rankings. The Pino performance documentation provides the relevant benchmark context.
| Scenario | Approx. lines/sec | Notes |
|---|---|---|
| Basic Pino benchmark | Not stated by the verified benchmark table | Average runtime was 114.801 ms |
| Basic PinoMinLength variant | Not stated by the verified benchmark table | Average runtime was 70.968 ms |
| Production transport path | Not stated | Limited by serialization, worker routing, network, receiver, and downstream processing |
The architecture caveat is straightforward. JSON encoding to stdout covers one stage. A transport, collector, network receiver, or processing pipeline adds serialization work, queues, network behavior, acceptance rules, and backpressure.
Use stdout benchmarks to compare logger overhead. Measure the complete route before setting capacity expectations. Include serialization choices, worker behavior, batching, network failures, receiver acceptance, and search visibility in that test. An explicit receiver contract also matters, because a fast logger cannot compensate for a destination that rejects, delays, or reshapes records.
Structured Fields, Redaction, and Correlation IDs
A log schema should be treated as an interface. Fixed fields such as service, env, version, level, time, and msg give operators stable filters, while request-scoped fields explain what a particular operation was doing.
The following CommonJS example combines base metadata, path-based redaction, and a request child logger:
const http = require('node:http');
const { randomUUID } = require('node:crypto');
const pino = require('pino');
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
base: {
service: 'orders-api',
env: process.env.NODE_ENV || 'development',
version: process.env.APP_VERSION || 'unknown'
},
redact: {
paths: [
'req.headers.authorization',
'req.headers.cookie',
'*.password',
'*.token'
],
censor: '[REDACTED]'
}
});
const server = http.createServer((req, res) => {
const reqId = req.headers['x-request-id'] || randomUUID();
const requestLogger = logger.child({ reqId });
requestLogger.info({
req: {
method: req.method,
url: req.url,
headers: {
authorization: 'Bearer secret',
cookie: 'session=private'
}
},
account: {
password: 'hidden',
token: 'private-token'
}
}, 'request received');
res.end('ok');
});
server.listen(3000);
Pino's redact option accepts paths to sensitive fields and applies the rule at logger configuration level. (Pino redaction API)
A representative output line is:
{"level":30,"time":1717000000000,"pid":1,"hostname":"api-pod","service":"orders-api","env":"production","version":"2026.05.1","reqId":"req-7f2","req":{"method":"GET","url":"/orders","headers":{"authorization":"[REDACTED]","cookie":"[REDACTED]"}},"account":{"password":"[REDACTED]","token":"[REDACTED]"},"msg":"request received"}
| Field | Before | After |
|---|---|---|
req.headers.authorization |
Bearer secret | [REDACTED] |
req.headers.cookie |
session=private | [REDACTED] |
account.password |
hidden | [REDACTED] |
account.token |
private-token | [REDACTED] |
reqId |
Not present before request scope | req-7f2 |
A staging sanity check should verify that the line parses as one JSON object, reqId is a string, time is numeric when the default timestamp is retained, and no plaintext secret appears. Targeted serializers still matter because redaction doesn't make an oversized request object readable or safe by itself.
Routing Pino Logs to Fluxtail With Transports and Receivers
The safest integration method starts with the receiver contract. Pino emits JSON, but the destination determines the transport target, address, port, authentication method, and retry behavior. Fluxtail's supported path for shared HTTP JSON and OTLP receivers uses TLS on port 443 with receiver-bound Bearer credentials. Dedicated protocol receivers are separate destinations and shouldn't be treated as interchangeable HTTP endpoints.
For HTTP JSON, keep the credential outside source control:
const pino = require('pino');
const logger = pino({
transport: {
targets: [
{
target: './pino-http-json-target.js',
level: 'info',
options: {
endpoint: process.env.FLUXTAIL_HTTP_JSON_ENDPOINT,
token: process.env.FLUXTAIL_TOKEN
}
}
]
}
});
The target module must follow the current Fluxtail receiver documentation for its request format and endpoint. The token belongs in FLUXTAIL_TOKEN, never inline in JavaScript, a container image, or a committed configuration file. Teams can use the Fluxtail data ingestion example to validate the documented receiver setup before wiring it into a service.
Match transport protocol to receiver
| Pino transport target | Fluxtail receiver | Port | Credential |
|---|---|---|---|
| HTTP JSON target | Shared HTTP JSON over TLS | 443 | Receiver-bound Bearer token |
| OTLP HTTP target | Shared OTLP over TLS | 443 | Receiver-bound Bearer token |
| Syslog target | Dedicated Syslog, RFC5424 over TCP | 6514 | Receiver-specific configuration |
| GELF target | Dedicated GELF over UDP | 12201 | Receiver-specific configuration |
| Fluent Forward target | Dedicated Fluent Forward over TCP | 24224 | shared_key |
| Beats target | Dedicated Beats over TCP | 5044 | Receiver-specific configuration |
OTLP should be selected when the service already has an OpenTelemetry logging pipeline and the receiver configuration supports the emitted record shape. It isn't a reason to force application logs into a trace exporter without confirming the supported signal and payload contract.
Pino transports commonly run work outside the main application path, but network delivery still introduces buffering, backpressure, failures, and operational visibility requirements. A rollout should confirm delivery in the receiver's recent-events view, inspect rejected records, and test shutdown behavior. For containerized services, stdout collection remains a valid alternative when infrastructure already owns forwarding.
Troubleshooting Common Pino Pitfalls
Pino's efficiency doesn't protect a service from poor object selection. Passing an entire request or response object can expose circular references, oversized headers, functions, or sensitive values, and a transport may fail while trying to serialize it. A targeted serializer keeps the event useful:
const logger = pino({
serializers: {
req(req) {
return {
method: req.method,
url: req.url,
status: req.statusCode,
responseTime: req.responseTime,
remoteAddress: req.socket && req.socket.remoteAddress
};
}
}
});
High-frequency logging creates a different failure mode. Even an efficient logger can generate storage, CPU, and network pressure when info messages appear repeatedly on a busy path. The fix is to remove low-value messages from loops, use levels intentionally, and route only the severity needed by each destination.
A third issue appears when teams customize level output while using multiple transport targets. Pino transport documentation and ecosystem discussions identify a constraint around custom level formatters with transport.targets, so changing the level shape can conflict with destination expectations. Preserve the numeric level for routing and add a separate label only when the destination contract permits it.
const logger = pino({
formatters: {
level(label, number) {
return {
level: number,
levelLabel: label
};
}
}
});
Operational rule: A readable level label is useful, but it must not replace the field that the transport uses for severity routing.

A short triage sequence isolates most rollout failures:
- Local output: Pipe Pino to stdout and inspect the raw NDJSON before involving a remote receiver.
- Network path: Capture traffic on the documented receiver port only after confirming the protocol and TLS expectations.
- Delivery state: Compare the sender's errors and backpressure signals with receiver-side recent events and ingestion statistics.
- Schema validity: Parse one emitted line and check that required fields remain at the top level.
A Production-Ready Pino Checklist and Next Step
A deployment gate should test the log contract, not just whether a process starts. The following checks are small enough for staging and meaningful enough to prevent an incident investigation from beginning with schema repair.
- Schema: Require stable names for
service,env,version,level,time,msg,traceId, andspanIdwhere those context fields exist. - Levels: Keep the standard numeric hierarchy and enforce level usage through code review. Runtime strings shouldn't become a substitute for a severity policy.
- Correlation: Create a child logger at the request or job boundary, then use that scoped logger throughout the operation.
- Serialization: Select fields from request, response, and error objects instead of dumping whole runtime objects.
- Redaction: Cover authorization headers, cookies, passwords, and tokens with explicit path rules before staging emits real traffic.
- Development readability: Pipe stdout through
pino-prettylocally, but preserve raw JSON in production. - Receiver contract: Send HTTP JSON or OTLP through the shared TLS 443 receiver with its receiver-bound Bearer credential. Use dedicated Syslog, GELF, Fluent Forward, or Beats receivers only for their matching protocols.
- Verification: Confirm that structured fields parse correctly and appear in the Fluxtail stream explorer before cutover.
The log management best practices checklist should support the deployment gate, not replace it. A passing test means an operator can filter by service and request context, identify severity, and inspect the same structured record after it reaches centralized storage.

Fluxtail provides centralized ingestion over documented HTTP, Syslog, OTLP, GELF, and collector-oriented receivers, with named streams, live tail, alerts, built-in AI chat, and hosted MCP access for compatible clients. Public access and pricing are available by request and confirmed before setup.
Set up one Pino stream, validate its JSON and redaction rules in staging, then visit Fluxtail to connect the documented receiver and turn incident-time terminal output into searchable, filterable events. Use the first service as the schema reference before expanding the same contract to workers and other Node.js services.