Learn

Simple Logging

Use evlog for individual structured messages, with the same drains and redaction as wide events, and adapt existing logger call sites.

The log API is evlog's general-purpose logger. Use it the way you'd use pino, consola, or console.log: every call emits a structured event through the same drain pipeline as wide events. The two modes coexist; neither is an upgrade of the other.

Looking for the same API in CLIs, libraries, jobs, and edge? Start with Standalone TypeScript and Cloudflare Workers.
In Nuxt, log is auto-imported. No import statement needed.

Setup

For standalone projects (non-Nuxt), initialize once at startup:

src/index.ts
import { initLogger, log } from 'evlog'

initLogger({
  env: { service: 'my-app' },
})

log.info('app', 'Server started')
env.service defaults to 'app' if not specified. Only set it if you want a custom service name.

log also works with no initLogger() call at all: every call prints with the defaults. That is fine for a scratch script, and it is how the default import below behaves. In production it means no redaction and no drain, so the first emitted event logs a one-time warning pointing at initLogger({ drain }).

Call it two ways

Tagged Logs

Pass a tag and a message for quick, readable output:

src/index.ts
import { log } from 'evlog'

log.info('auth', 'User logged in')
log.warn('cache', 'Cache miss for key user:42')
log.error('payment', 'Stripe webhook failed')
log.debug('router', 'Matched route /api/checkout')
Output (Pretty)
10:23:45.612 [auth] User logged in
10:23:45.613 [cache] Cache miss for key user:42
10:23:45.614 ERROR [payment] Stripe webhook failed
10:23:45.615 [router] Matched route /api/checkout

Interpolate values into the message

When the message contains %s, %d, or %j and extra arguments follow, evlog formats it before emitting. Call sites that come from pino or consola keep their printf style instead of becoming template strings:

src/index.ts
log.info('auth', 'User %s logged in from %s', 'alice', '10.0.0.7')
log.info('payment', 'Retrying charge for order %d', 4091)
log.info('debug', 'Payload %j', { orderId: 4091, total: 9999 })
Output (Pretty)
10:23:45.612 [auth] User alice logged in from 10.0.0.7
10:23:45.613 [payment] Retrying charge for order 4091
10:23:45.614 [debug] Payload {"orderId":4091,"total":9999}

The same form works without a tag: when the first string contains a specifier, evlog treats it as the message and the call as untagged.

src/index.ts
log.info('user %s logged in', 'alice') // emits [log] user alice logged in
SpecifierFormats the argument as
%sstring, objects and arrays as JSON
%dnumber, coerced with Number(), bigints as 10n
%jJSON, with a string fallback for values JSON cannot serialize

Interpolation runs only when extra arguments are present. A message with a % and no arguments is logged unchanged; once arguments are involved, write %% for a literal percent.

Formatting applies to the message string only. Field values in a structured event are never treated as format input.

Structured Events

Pass an object for rich, queryable events that flow through the drain pipeline:

src/index.ts
import { log } from 'evlog'

log.info({ action: 'user_login', userId: 42, method: 'oauth', provider: 'github' })
log.error({ action: 'sync_failed', source: 'postgres', target: 's3', error: 'connection_timeout' })
Output (Pretty)
10:23:45.612 INFO [my-app]
  ├─ action: user_login
  ├─ userId: 42
  ├─ method: oauth
  └─ provider: github
Tagged logs are optimized for console readability. Structured events (object form) produce full wide events that flow through the drain pipeline to external services.

Choose a level, and what it costs

LevelMethodWhen to use
fatallog.fatal()Unrecoverable failures: the request or process cannot continue. Always kept, even when sampling would drop it
infolog.info()Normal operations: startup, shutdown, successful actions
warnlog.warn()Unexpected but recoverable situations: cache miss, retry, deprecation
errorlog.error()Failures that need attention: API errors, timeouts, invalid state
debuglog.debug()Development-only details: SQL queries, intermediate state, routing
tracelog.trace()Very fine-grained details below debug. Dropped by default; opt in with sampling.rates.trace
log.debug() calls can be stripped from production builds using the Vite Plugin or the Nuxt module's strip option.

Patterns worth copying

Application Lifecycle

src/index.ts
import { log } from 'evlog'

log.info('app', 'Starting server on port 3000')
log.info({ action: 'db_connected', host: 'localhost', database: 'mydb', pool: 10 })
log.info('app', 'Ready to accept connections')

Background Tasks

src/jobs/cleanup.ts
import { log } from 'evlog'

log.info({ action: 'cron_started', job: 'cleanup', schedule: '0 */6 * * *' })
log.info({ action: 'cron_completed', job: 'cleanup', deleted: 42, duration: 1200 })

Utility Functions

src/utils/webhook.ts
import { log } from 'evlog'

function processWebhook(payload: WebhookPayload) {
  log.info({ action: 'webhook_received', type: payload.type, source: payload.source })

  if (!isValid(payload)) {
    log.warn({ action: 'webhook_invalid', type: payload.type, reason: 'missing_signature' })
    return
  }
}

Send these logs to a drain

When using the object form, events are sent through the drain pipeline just like wide events:

src/index.ts
import { initLogger, log } from 'evlog'
import { createAxiomDrain } from 'evlog/axiom'

initLogger({
  env: { service: 'my-app' },
  drain: createAxiomDrain(),
})

log.info({ action: 'deploy', version: '1.2.3', region: 'us-east-1' })

Migrating from console / pino / consola / winston

Pick the tab matching your current logger to see the before call style. The after (evlog) snippet underneath is the same regardless of where you came from.

import pino from 'pino'

const log = pino({ name: 'checkout' })

log.info({ event: 'checkout_started' })
log.info({ event: 'cart_loaded', items: 3, total: 9999 })
log.warn({ event: 'inventory_low', sku: 'SKU-42' })
log.error({ event: 'payment_failed', reason: 'card_declined' })

All four become this, with no formatter, transport, or peer-dep wiring:

After (evlog)
import { initLogger, log } from 'evlog'

initLogger({ env: { service: 'checkout' } })

log.info({ event: 'checkout_started' })
log.info({ event: 'cart_loaded', items: 3, total: 9999 })
log.warn({ event: 'inventory_low', sku: 'SKU-42' })
log.error({ event: 'payment_failed', reason: 'card_declined' })

initLogger is one line at boot. The drain, redaction, sampling, pretty/JSON switching, and level filtering are all wired by default: no pino-pretty peer dep, no winston transport assembly, no consola reporter setup.

If you want the drop-in feel of import pino from 'pino' while you evaluate evlog, the default export is the same log API with no setup line:

src/index.ts
import logger from 'evlog'

logger.info('checkout', 'Starting checkout')
logger.info({ event: 'checkout_started' })

Every call prints immediately. Once you want a drain, redaction, or wide events, add the initLogger line and keep calling the same methods; the default import and log are the same object, so nothing else moves.

Want the full side-by-side (feature comparison tables, honest gaps, per-feature mapping)? See evlog vs pino, winston, consola.

Pairing with wide events

log and createLogger live inside the same logger. Use log.* for events that stand alone (startup messages, ad-hoc warnings, debug traces) and reach for createLogger when you want one event that captures an entire operation. They share the global drain, redaction, and types, so pick per call.

scripts/sync-data.ts
import { initLogger, log, createLogger } from 'evlog'

initLogger({ env: { service: 'sync-worker' } })

log.info('sync', 'Worker starting')

const run = createLogger({ source: 'postgres', target: 's3' })
try {
  const records = await fetchRecords()
  run.set({ found: records.length })

  for (const record of records) {
    await syncOne(record)
    log.debug({ event: 'record_synced', id: record.id })
  }

  run.set({ status: 'complete', synced: records.length })
} catch (err) {
  log.error({ event: 'sync_failed' })
  run.error(err as Error)
  throw err
} finally {
  run.emit()
}

log.info('sync', 'Worker finished')

The log.* calls give you a real-time trail in development; the createLogger block gives your dashboard one queryable row per run. Both go through the same drain.

Next Steps