evlog vs pino, winston, consola
evlog is a fully-featured general-purpose logger first, with wide events as a native extension of the same API. This page compares it head-to-head with the three loggers TypeScript developers usually consider — pino, winston, and consola — so you know exactly what you gain, what stays the same, and what (if anything) is missing today.
TL;DR
- Pick evlog over pino if you want the same throughput class with structured errors, redaction, and wide events built in — and you don't want to assemble
pino+pino-pretty+pino-http+ custom transports yourself. - Pick evlog over winston in any new TypeScript project. winston is older, slower (see benchmarks), and ships none of the modern features (typed events, redaction, structured errors, AI SDK integration).
- Pick evlog over consola as soon as your code leaves a CLI. consola is great for terminal pretty-printing but doesn't ship a drain pipeline, sampling, or wide events.
- Stay on pino only if you're on an extremely hot path that emits hundreds of thousands of fire-and-forget log lines per second to
/dev/nulland you have a custom transport you don't want to port. evlog still wins the wide event lifecycle by ~8x, but pino can edge it on rawinfo('hello world')throughput.
Feature comparison
Three tables instead of one wall. The Winner column on the right tells you who wins each row at a glance; cells use semantic words ("Built-in", "Manual", "via X") instead of generic "Yes" so you can read the level of effort without reading the spec.
Core API
| Feature | evlog | pino | consola | winston | Winner |
|---|---|---|---|---|---|
| Standard levels | Yes | Yes | Yes | Yes | All |
| Custom levels | No | Yes | Yes | Yes | pino, consola, winston |
| Structured fields per call | Yes | Yes | Partial | Yes | evlog, pino, winston |
| Child loggers / persistent bindings | Yes | Yes | Yes | Yes | All |
| Pretty in dev / JSON in prod (auto) | Built-in | via pino-pretty | Built-in | Manual | evlog, consola |
| Browser-safe build | Yes | No | Yes | No | evlog, consola |
| Sub-operation logger (log.fork) | Yes | No | No | No | evlog |
| Source distinction (server / client) | Yes | No | No | No | evlog |
| Runtime level mutation | No | Yes | Yes | Yes | pino, consola, winston |
| Plugin / serializer system | No | Yes | No | Yes | pino, winston |
| Wide events (one per operation) | Yes | No | No | No | evlog |
| Structured errors (why / fix / link) | Yes | No | No | No | evlog |
Production features
| Feature | evlog | pino | consola | winston | Winner |
|---|---|---|---|---|---|
| Built-in PII redaction (auto in prod) | Built-in | Manual | No | No | evlog |
| Head + tail sampling | Built-in | Manual | No | No | evlog |
| Async I/O for shipping logs | via drains | Worker thread | No | Worker thread | pino, winston |
| Drain pipeline (batch / retry / fan-out) | Built-in | via transports | No | via transports | evlog |
| Multi-destination fan-out | Yes | Yes | No | Yes | evlog, pino, winston |
| Audit trail (tamper-evident chain) | Built-in | No | No | No | evlog |
| Built-in enrichers (UA / Geo / Trace / Size) | Built-in | No | No | No | evlog |
| Sensitive header filtering | Built-in | Manual | No | Manual | evlog |
| W3C trace context (traceparent) | Built-in | No | No | No | evlog |
| AI SDK integration (tokens / tools / streaming) | Built-in | No | No | No | evlog |
| Better Auth integration | Built-in | No | No | No | evlog |
| Self-hosted storage (NuxtHub adapter) | Built-in | No | No | No | evlog |
| Edge / Workers runtime | Built-in | Partial | No | No | evlog |
Footprint and ecosystem
| Feature | evlog | pino | consola | winston | Winner |
|---|---|---|---|---|---|
| Zero transitive dependencies | Yes | 1 dep | No | No | evlog |
| Bundle size (gzip) | ~5 kB | ~6 kB | ~12 kB | ~50 kB | evlog |
| Wide event lifecycle throughput | 1.7M ops/s | 209K ops/s | n/a | 115K ops/s | evlog |
| Framework auto-init (13+ integrations) | Yes | HTTP only | No | No | evlog |
| Client → server log transport | Yes | No | No | No | evlog |
| Vite plugin (auto-replace console.log) | Yes | No | No | No | evlog |
| Path filtering (include / exclude globs) | Built-in | Manual | No | Manual | evlog |
| AI agent skills (Cursor / Claude / ChatGPT) | Yes | No | No | No | evlog |
Counted up across the three tables (33 rows total): evlog wins 23 rows outright, ties on 6, and loses 4 — custom levels, runtime level mutation, plugin/serializer system, and async-I/O on a worker thread. All four losses are documented in Honest gaps below so you know what you're trading off.
See packages/evlog/bench/ for the open-source benchmarks behind the throughput numbers, and the Performance page for the full breakdown.
Honest gaps (today)
We'd rather you read this list than discover the limits the hard way. Each item is a potential future Linear ticket — none of them are currently blocking for the workloads we've shipped evlog on.
No persistent-bindings shorthand on log.*
pino has log.child({ component: 'auth' }) that returns a new logger inheriting both the parent's bindings and the child's. evlog's simple log.* API is global; to attach persistent context you create a wide-event logger:
import { createLogger } from 'evlog'
const log = createLogger({ component: 'auth' })
log.set({ userId: 42 })
log.emit()
Or, in framework integrations, the request middleware does it for you. This works but it's not the same ergonomic shape as pino.child. A log.child(bindings) shorthand is a likely next addition.
minLevel is set once at startup
You configure minLevel in initLogger({ minLevel: 'info' }) and that's it for the process lifetime. pino lets you mutate logger.level = 'debug' at runtime (handy for --verbose flags or hot-reload). The workaround today is to call initLogger again before locking — fine for CLIs that read flags before any logging, awkward for runtime toggles.
No custom levels
evlog ships debug / info / warn / error and that's it. pino, consola, and winston all let you define trace, notice, fatal, etc. We chose four levels on purpose (most teams never use more than four), but if your existing pipeline depends on fatal or trace you'll need to map them onto the closest evlog level.
No multi-stream / transport array on log.*
pino lets you pipe a single log to multiple destinations via pino.multistream. evlog does the same via the drain pipeline (one drain that fans out to N adapters, with batching and retry shared across all of them) — but the mental model is different. If you've structured your existing code around "stream A is debug to stdout, stream B is warn+ to a file, stream C is error to Sentry", you'll rebuild that as drain-level routing instead.
No formatter / serializer plugin system
pino has serializers for converting common types (errors, requests, responses) into JSON. evlog handles the common cases via the redaction layer and the built-in error serialization (createError + parseError); for anything custom (e.g. masking a particular field or transforming a payload), you write it inside a custom drain or before calling log.set.
Migrating from
Pick the tab that matches your current logger. Each tab shows the before code in that library's own API. Underneath the tabs is the single after snippet — the same evlog code regardless of where you came from.
import pino from 'pino'
const log = pino({ name: 'checkout' })
const child = log.child({ flow: 'checkout' })
child.info({ event: 'checkout_started' })
try {
const cart = await getCart(userId)
child.info({ cart: { items: cart.items.length, total: cart.total } }, 'cart loaded')
const charge = await stripe.charge(cart.total)
child.info({ stripe: { chargeId: charge.id } }, 'charge ok')
if (!charge.success) {
throw new Error(`Payment failed: ${charge.decline_reason}`)
}
} catch (err) {
child.error({ err }, 'checkout failed')
throw err
}
import { createLogger as createWinston, format, transports } from 'winston'
const log = createWinston({
defaultMeta: { service: 'checkout' },
format: format.combine(format.timestamp(), format.json()),
transports: [new transports.Console()],
})
log.info({ event: 'checkout_started', flow: 'checkout' })
try {
const cart = await getCart(userId)
log.info({ flow: 'checkout', cart: { items: cart.items.length, total: cart.total } })
const charge = await stripe.charge(cart.total)
log.info({ flow: 'checkout', stripe: { chargeId: charge.id } })
if (!charge.success) {
throw new Error(`Payment failed: ${charge.decline_reason}`)
}
} catch (err) {
log.error({ flow: 'checkout', err })
throw err
}
import { consola } from 'consola'
const log = consola.withTag('checkout')
log.info('Starting checkout flow')
try {
const cart = await getCart(userId)
log.info('cart loaded', { items: cart.items.length, total: cart.total })
const charge = await stripe.charge(cart.total)
log.info('charge ok', { chargeId: charge.id })
if (!charge.success) {
throw new Error(`Payment failed: ${charge.decline_reason}`)
}
} catch (err) {
log.error('checkout failed', err)
throw err
}
console.log('[checkout] Starting checkout flow')
try {
const cart = await getCart(userId)
console.log('[checkout] cart loaded', { items: cart.items.length, total: cart.total })
const charge = await stripe.charge(cart.total)
console.log('[checkout] charge ok', { chargeId: charge.id })
if (!charge.success) {
throw new Error(`Payment failed: ${charge.decline_reason}`)
}
} catch (err) {
console.error('[checkout] failed', err)
throw err
}
All four become this — same code regardless of the source library:
import { initLogger, createLogger, createError } from 'evlog'
initLogger({ env: { service: 'checkout' } })
const log = createLogger({ flow: 'checkout' })
try {
const cart = await getCart(userId)
log.set({ cart: { items: cart.items.length, total: cart.total } })
const charge = await stripe.charge(cart.total)
log.set({ stripe: { chargeId: charge.id } })
if (!charge.success) {
throw createError({
message: 'Payment failed',
status: 402,
why: charge.decline_reason,
fix: 'Try a different payment method',
})
}
} catch (err) {
log.error(err as Error)
throw err
} finally {
log.emit()
}
Three things changed in every migration:
- N log lines → 1 wide event. The 3-4 calls per request become
log.setaccumulations and onelog.emitat the end. Your dashboard gets one queryable row instead of stitching by request id. - Errors carry
whyandfix. ThrowingcreateErrorinstead ofnew Errormeans your client (and on-call) get actionable context, not just a stack. - Setup is one line. No formatter wiring, no transport assembly, no
pino-prettypeer dep.initLoggeronce at boot and you're done.
Reverse direction: when not to pick evlog
Be honest with yourself. Don't switch if:
- You ship a library that's already part of the pino ecosystem (
pino-http,pino-pretty,pino-multi-streamplugins) and would lose tooling. - You have a custom pino transport (e.g. a worker-thread Datadog forwarder you wrote in 2021) you don't want to re-implement as an evlog drain. Most of the built-in adapters cover the common destinations, but custom protocols mean a port.
- You log only inside CLIs and use consola purely for the pretty terminal output. evlog's pretty output is good but not consola-grade for spinners, prompts, and box renders. Use both: evlog for events that go to a drain, consola for prompts / TUIs.
Next Steps
- Simple Logging — the
log.*API, migration tabs, and patterns - Wide Events — what unlocks when you accumulate context per operation
- Performance Benchmarks — the methodology behind the numbers above
- Standalone TypeScript — scripts, workers, and libraries without a web framework
Agent Skills
AI-assisted code review and evlog adoption using Agent Skills. Let AI review your logging patterns and guide migration to wide events.
Overview
evlog gives you three ways to log. Simple one-liners, wide events that accumulate context, and auto-managed request logging. Choose the right one for your use case.