Getting Started

evlog vs pino, winston, consola

Side-by-side comparison of evlog with pino, winston, and consola. Feature parity matrix, honest gaps, and migration snippets so you can switch with no surprises.

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/null and you have a custom transport you don't want to port. evlog still wins the wide event lifecycle by 7.7x, but pino can edge it on raw info('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.

Hover (or tap on mobile) the info icon next to any feature name for a one-line explanation of what the row actually compares.

Core API

FeatureevlogpinoconsolawinstonWinner
Standard levelsYesYesYesYesAll
Custom levelsNoYesYesYespino, consola, winston
Structured fields per callYesYesPartialYesevlog, pino, winston
Child loggers / persistent bindingsYesYesYesYesAll
Pretty in dev / JSON in prod (auto)Built-invia pino-prettyBuilt-inManualevlog, consola
Browser-safe buildYesNoYesNoevlog, consola
Sub-operation logger (log.fork)YesNoNoNoevlog
Source distinction (server / client)YesNoNoNoevlog
Runtime level mutationNoYesYesYespino, consola, winston
Plugin / serializer systemNoYesNoYespino, winston
Wide events (one per operation)YesNoNoNoevlog
Structured errors (why / fix / link)YesNoNoNoevlog

Production features

FeatureevlogpinoconsolawinstonWinner
Built-in PII redaction (auto in prod)Built-inManualNoNoevlog
Head + tail samplingBuilt-inManualNoNoevlog
Async I/O for shipping logsvia drainsWorker threadNoWorker threadpino, winston
Drain pipeline (batch / retry / fan-out)Built-invia transportsNovia transportsevlog
Multi-destination fan-outYesYesNoYesevlog, pino, winston
Audit trail (tamper-evident chain)Built-inNoNoNoevlog
Built-in enrichers (UA / Geo / Trace / Size)Built-inNoNoNoevlog
Sensitive header filteringBuilt-inManualNoManualevlog
W3C trace context (traceparent)Built-inNoNoNoevlog
AI SDK integration (tokens / tools / streaming)Built-inNoNoNoevlog
Better Auth integrationBuilt-inNoNoNoevlog
Self-hosted storage (NuxtHub adapter)Built-inNoNoNoevlog
Edge / Workers runtimeBuilt-inPartialNoNoevlog

Footprint and ecosystem

FeatureevlogpinoconsolawinstonWinner
Zero transitive dependenciesYes1 depNoNoevlog
Bundle size (gzip)~6 kB~6 kB~12 kB~50 kBevlog
Wide event lifecycle throughput1.58M ops/s206K ops/sn/a112K ops/sevlog
Framework auto-init (13+ integrations)YesHTTP onlyNoNoevlog
Client → server log transportYesNoNoNoevlog
Vite plugin (auto-replace console.log)YesNoNoNoevlog
Path filtering (include / exclude globs)Built-inManualNoManualevlog
AI agent skills (Cursor / Claude / ChatGPT)YesNoNoNoevlog

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
}

All four become this — same code regardless of the source library:

After (evlog)
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.set accumulations and one log.emit at the end. Your dashboard gets one queryable row instead of stitching by request id.
  • Errors carry why and fix. Throwing createError instead of new Error means your client (and on-call) get actionable context, not just a stack.
  • Setup is one line. No formatter wiring, no transport assembly, no pino-pretty peer dep. initLogger once 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-stream plugins) 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