evlog vs Pino: request context and migration
Your Pino setup already ships JSON logs. The next decision is how your application assembles request context. evlog provides an accumulator and a lifecycle for that context. Pino provides JSON logging, bindings, serializers, and transports that you can keep if they already fit your application.
Compare the work you maintain
| Concern | evlog | Pino |
|---|---|---|
| Request context | createLogger() and typed set() calls accumulate fields until emit() | child() attaches bindings to records. The application can accumulate an object and log it once. |
| Adding bindings | set() merges fields into the pending event | setBindings() adds bindings after creation. It does not overwrite existing keys and can introduce duplicate keys. |
| Error fields | createError() carries why, fix, and link | Error serializers, including pino.stdSerializers.err, shape error records. |
| Extensibility | Plugins and enrichers hook into the event lifecycle | Per-key serializers, formatters, and transports |
| Levels | Four severities, global minLevel, sampling for wide events | Built-in and custom levels, mutable logger level |
| Delivery | Drain adapters and configurable batching/retry | Destinations and transports, including worker-thread transports |
Pino documents these contracts in its API reference. A worker transport moves transport processing and delivery to a worker. The logger still performs the initial JSON serialization on the calling thread. See Pino transports.
Compare one completed operation
Both libraries can emit one structured record for an operation. Install pino or evlog and run the corresponding TypeScript file with Node.js 24. The decline argument exercises the failure path. These examples simulate the same checkout decision without calling a payment provider.
With Pino, the application owns the accumulated context and emits it in finally:
import pino from 'pino'
const logger = pino()
const context = { orderId: 'order-123', amount: 2999, outcome: 'pending' }
let failure: Error | undefined
try {
if (process.argv.includes('decline')) throw new Error('Payment declined')
context.outcome = 'paid'
} catch (error) {
failure = error instanceof Error ? error : new Error(String(error))
context.outcome = 'declined'
process.exitCode = 1
} finally {
if (failure) logger.error({ ...context, err: failure }, 'checkout completed')
else logger.info(context, 'checkout completed')
}
With evlog, the logger owns the accumulator and selects error severity after error():
import { createLogger, initLogger } from 'evlog'
initLogger({ env: { service: 'checkout' }, pretty: false })
const log = createLogger({ orderId: 'order-123', amount: 2999, outcome: 'pending' })
try {
if (process.argv.includes('decline')) throw new Error('Payment declined')
log.set({ outcome: 'paid' })
} catch (error) {
log.error(error instanceof Error ? error : new Error(String(error)))
log.set({ outcome: 'declined' })
process.exitCode = 1
} finally {
log.emit()
}
Each version emits one record on either path. Their envelopes differ: Pino uses a numeric level and its error serializer, while evlog adds its event metadata and duration. evlog's wide-event model becomes useful when several layers contribute fields and framework integrations own the request lifecycle. Pino's pino-http also provides automatic HTTP request logging.
Migrate one handler before changing the whole application
Inventory the fields your dashboards query, custom levels, serializers, and transport configuration. Map them explicitly before switching output. Pino serializers do not transfer unchanged into evlog plugins, and a custom transport may need a new drain.
Keep the existing Pino path for unrelated handlers while you validate one evlog integration. Avoid emitting duplicate records for the same request through both paths unless you deliberately account for that in queries and ingestion costs. Test successful requests, thrown errors, shutdown, and redaction using the same assertions about required fields.
Read benchmark conditions before choosing on speed
The repository's historical comparison uses silent evlog event construction, Pino JSON writes to /dev/null, and different output counts in the request-lifecycle scenario. It does not establish an equivalent-output throughput ranking. The Performance reference records the workloads and how to measure your own configuration.
Keep Pino when its serializers, ecosystem integrations, or worker transports already solve your problem. Choose evlog when its context accumulator, structured errors, and framework lifecycle remove application code you would otherwise maintain. For other choices, use evlog vs other loggers.