Simple Logging
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.
log is auto-imported. No import statement needed.Setup
For standalone projects (non-Nuxt), initialize once at startup:
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:
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')
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:
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 })
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.
log.info('user %s logged in', 'alice') // emits [log] user alice logged in
| Specifier | Formats the argument as |
|---|---|
%s | string, objects and arrays as JSON |
%d | number, coerced with Number(), bigints as 10n |
%j | JSON, 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.
Structured Events
Pass an object for rich, queryable events that flow through the drain pipeline:
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' })
10:23:45.612 INFO [my-app]
├─ action: user_login
├─ userId: 42
├─ method: oauth
└─ provider: github
Choose a level, and what it costs
| Level | Method | When to use |
|---|---|---|
fatal | log.fatal() | Unrecoverable failures: the request or process cannot continue. Always kept, even when sampling would drop it |
info | log.info() | Normal operations: startup, shutdown, successful actions |
warn | log.warn() | Unexpected but recoverable situations: cache miss, retry, deprecation |
error | log.error() | Failures that need attention: API errors, timeouts, invalid state |
debug | log.debug() | Development-only details: SQL queries, intermediate state, routing |
trace | log.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
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
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
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:
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' })
import { createLogger, format, transports } from 'winston'
const log = createLogger({
defaultMeta: { service: 'checkout' },
format: format.json(),
transports: [new transports.Console()],
})
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' })
import { consola } from 'consola'
const log = consola.withTag('checkout')
log.info('Starting checkout')
log.info('cart loaded', { items: 3, total: 9999 })
log.warn('inventory low', { sku: 'SKU-42' })
log.error('payment failed', { reason: 'card_declined' })
console.log('[checkout] Starting checkout')
console.log('[checkout] cart loaded', { items: 3, total: 9999 })
console.warn('[checkout] inventory low', { sku: 'SKU-42' })
console.error('[checkout] payment failed', { reason: 'card_declined' })
All four become this, with no formatter, transport, or peer-dep wiring:
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:
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.
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.
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
- Wide Events: Accumulate context and emit comprehensive events
- Structured Errors: Throw errors with
why,fix, andlink - Configuration: All
initLoggeroptions - Adapters: Send events to Axiom, Sentry, PostHog, and more
- Standalone TypeScript: Scripts, workers, and libraries without a web framework
- evlog vs other loggers: Side-by-side with pino, winston, consola
Overview
The mental model: three logging modes, the wide event lifecycle, sampling, typed fields, and redaction. Read it in order if you are new.
Wide Events
Accumulate context over any unit of work and emit a single comprehensive event. Works for HTTP requests, scripts, background jobs, queue workers, and workflows.