Structured Logging in KrakenJS with Pino: Why JSON Beats console.log in Production
Ad-hoc console.log output can't be correlated or queried in production. Here's how to wire Pino's structured JSON logging into a kraken-style middleware stack, with request IDs, redaction, and honest trade-offs.
29 Jan 2026, 17:09 UTC

The first time a Node service misbehaves under load, the debugging session usually starts the same way: someone greps a log file full of free-form strings like user logged in!! and err happened, and realizes none of it can be filtered, aggregated, or correlated. The useful takeaway here is simple: swapping ad-hoc console.log calls for a structured logger like Pino gives you machine-readable JSON logs with request IDs and timestamps for very little code, and the performance overhead is small enough that you can leave it on in production.
This post walks through wiring Pino into a KrakenJS-style middleware stack, shows a concrete configuration, and covers the honest trade-offs. One caveat up front: KrakenJS has historically been built on Express, while some community setups pair the kraken config/middleware conventions with Koa. The middleware pattern below works in either; verify which HTTP framework your project pins before copying imports.
The problem with unstructured logs
Plain-text logs fail in three specific ways once you have more than one server:
- No correlation. When 500 requests interleave, you cannot tell which log lines belong to which request without an ID on every line.
- No querying. Log aggregators (Elasticsearch, Loki, Datadog) can index JSON fields. They cannot reliably parse
user 42 took 81mswritten six different ways by six different developers. - Surprising cost.
console.logwrites synchronously to stdout in many Node versions and stringifies objects expensively. Under load it becomes a measurable CPU and event-loop cost.
Why Pino fits this stack
Pino is a JSON logger designed around low overhead: it avoids expensive object serialization on the hot path and writes newline-delimited JSON, one object per line. Each line carries a numeric level, a time field, your message, and whatever fields you attach. Because the output is a stream, you can pipe it straight to a file, to systemd/journald, or to a shipper like Fluent Bit without changing application code.
The middleware pattern that pairs well with kraken-style configuration is: create one root logger at startup, then per request create a child logger that automatically includes a request ID on every line. Child loggers in Pino are cheap and are the idiomatic way to add context.
Worked example: request-scoped logging middleware
Run these commands in your project directory with a Node version your deployment targets (the example assumes Node 18+ and Pino 8/9-era APIs; check npm view pino version for current releases):
npm install pino pino-httpThen wire it up. For a Koa-flavored stack the middleware looks like this:
// logger.js
const pino = require('pino');
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
// Redact secrets before they ever hit the log stream
redact: ['req.headers.authorization', 'req.headers.cookie']
});
module.exports = logger;// middleware/requestLogger.js
const { randomUUID } = require('crypto');
const logger = require('../logger');
module.exports = function requestLogger() {
return async (ctx, next) => {
const requestId = ctx.get('x-request-id') || randomUUID();
ctx.state.log = logger.child({ requestId, method: ctx.method, path: ctx.path });
const start = process.hrtime.bigint();
try {
await next();
} finally {
const ms = Number(process.hrtime.bigint() - start) / 1e6;
ctx.state.log.info({ status: ctx.status, responseTimeMs: ms.toFixed(1) }, 'request complete');
}
};
};Register it early in your middleware chain so every downstream handler can log via ctx.state.log.info({ userId }, 'order created') and automatically inherit the request ID. In an Express-based kraken app the same idea applies with (req, res, next) and res.on('finish'); the pino-http package does this for you out of the box.
Expected output is one JSON object per line, roughly:
{"level":30,"time":1760289000000,"requestId":"9f1c...","method":"GET","path":"/orders","status":200,"responseTimeMs":"12.4","msg":"request complete"}How to verify: start the app, hit an endpoint with curl, and pipe stdout through jq (node server.js | jq .). Confirm every line parses as JSON and that requestId is present and identical across lines from the same request. Send two concurrent requests and confirm their IDs differ. If jq errors, something is writing non-JSON to stdout — usually a stray console.log or a dependency logging directly.
Trade-offs worth knowing before you commit
JSON is not pleasant to read raw. During local development, pipe through pino-pretty (node server.js | npx pino-pretty) rather than enabling pretty-printing in-process — the pretty printer is slow and should never run in production.
Log volume is a configuration problem, not a library problem. A default level of debug on a busy service will drown your storage budget. Keep production at info, and use per-module child loggers with their own levels if you need temporary verbosity.
Performance claims need your own benchmark. Pino is widely benchmarked as faster than console.log and most alternatives, but any specific percentage depends on payload size and transport. Run autocannon against a test endpoint with both logging approaches and compare CPU with your own workload before quoting numbers to your team.
Actionable closing
Pick one service this week: add Pino, add the request-ID middleware, redact authorization headers, and pipe output through jq to confirm the structure. That single change — a request ID on every line — is the difference between reading logs and actually searching them.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.