Logging
Accumulate context over any unit of work and emit a single comprehensive event. Works for HTTP requests, scripts, background jobs, queue workers, and workflows.

Wide events are the core concept behind evlog. Instead of scattering logs throughout your codebase, you accumulate context over any unit of work, whether a request, script, job, or workflow, and emit a single, comprehensive log event.

Why Wide Events?

Traditional logging creates noise:

src/service.ts
logger.info('Job started')
logger.info('User authenticated', { userId: user.id })
logger.info('Fetching data', { source: 'postgres' })
logger.info('Processing records')
logger.info('Processing complete')
logger.info('Job finished', { duration: 234 })

This approach has problems:

  • Scattered context: Information is spread across multiple log lines
  • Hard to correlate: Matching logs to operations requires IDs everywhere
  • Noise: 10+ log lines per operation makes finding issues harder
  • Incomplete: Some logs might be missing if errors occur

Wide events solve this:

import { useLogger } from 'evlog'

const log = useLogger(event)

log.set({ user: { id: 1, plan: 'pro' } })
log.set({ cart: { id: 42, items: 3, total: 9999 } })
log.set({ payment: { method: 'card', status: 'success' } })

One log, all context. Everything you need to understand what happened.

Creating Wide Events

createLogger (General Purpose)

Use createLogger() for scripts, background jobs, queue workers, cron jobs, or any operation where you manage the lifecycle:

scripts/migrate-users.ts
import { initLogger, createLogger } from 'evlog'

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

const log = createLogger({ task: 'user-migration' })

const users = await db.query('SELECT * FROM legacy_users')
log.set({ found: users.length })

let migrated = 0
for (const user of users) {
  await newDb.upsert({ id: user.id, email: user.email, plan: user.plan })
  migrated++
}

log.set({ migrated, status: 'complete' })
log.emit()

createRequestLogger (HTTP Contexts)

Use createRequestLogger() when working with HTTP requests outside of a framework integration. It's a thin wrapper around createLogger that pre-populates method, path, and requestId:

src/worker.ts
import { initLogger, createRequestLogger } from 'evlog'

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

const log = createRequestLogger({ method: 'POST', path: '/api/checkout' })

log.set({ user: { id: 1, plan: 'pro' } })
log.set({ cart: { items: 3, total: 9999 } })

log.emit()
Both createLogger and createRequestLogger require a manual log.emit() call. The event won't be emitted until you call it.

useLogger (Retrieving the Request Logger)

When using a framework integration (Nuxt, Hono, Express, etc.), the middleware creates a wide event logger automatically on each request. useLogger(event) retrieves that logger from the request context:

server/api/checkout.post.ts
import { useLogger } from 'evlog'

export default defineEventHandler(async (event) => {
  const log = useLogger(event)

  log.set({ user: { id: 1, plan: 'pro' } })
  log.set({ cart: { items: 3, total: 9999 } })

  return { success: true }
  // auto-emitted on response end
})
useLogger doesn't create a logger, it retrieves the one the framework middleware already attached to the event. The middleware handles creation and emission automatically. In Nuxt, useLogger is auto-imported.

After emit: sealing and background work

When the wide event is emitted (automatically at the end of the request, or when you call log.emit() yourself), that logger instance is sealed. Further set, error, info, and warn calls do not update the event that was already sent to your drains. They are ignored and evlog prints a [evlog] warning to the console with the keys that were dropped. This also applies when head sampling discards the event (emit() returned null): the logger is still sealed for that unit of work.

This matters for async work that outlives the handler (fire-and-forget promises, setTimeout, tasks started but not awaited). On many runtimes, AsyncLocalStorage keeps returning the same request logger, so useLogger() still succeeds even though the HTTP response — and the wide event — are already finished. Without warnings, that looks like silent data loss.

log.fork(label, fn)

For intentional background work that should produce its own wide event, use log.fork(label, fn) when your integration provides it (Express, Fastify, NestJS, SvelteKit, React Router, Next.js withEvlog, Elysia). Inside fn, useLogger() resolves to a child logger. When fn completes (or throws), the child emits an event with:

  • operation: the label you passed
  • _parentRequestId: the parent request’s requestId (for correlation in queries and dashboards)

The parent wide event may be emitted before the child event; they are two separate events ordered by time.

Not available yet: Hono (no useLogger without c.get('log') + ALS) and Nitro/Nuxt useLogger(event) — use the post-emit warnings to catch mistakes; a different API may arrive later for event-scoped forks.

server/routes/checkout.post.ts
import { evlog, useLogger } from 'evlog/express'

// Inside a route after evlog middleware:
const log = req.log
log.set({ order_dispatched: true })

log.fork?.('process_order', async () => {
  const child = useLogger()
  child.set({ inventory_checked: true })
})

Anatomy of a Wide Event

A well-designed wide event contains context from multiple layers. The examples below show what to add inside your handler or script. They assume log is already created via createLogger, createRequestLogger, or useLogger.

Operation Context

Basic information about the operation:

import { useLogger } from 'evlog'

const log = useLogger(event)
log.set({
  method: 'POST',
  path: '/api/checkout',
  requestId: 'abc-123-def',
})
In framework integrations, request context (method, path, requestId) is auto-populated by the middleware. You don't need to set these fields manually.

User / Actor Context

Who triggered the operation:

server/api/checkout.post.ts
log.set({
  userId: user.id,
  email: user.email,
  subscription: user.plan,
  accountAge: daysSince(user.createdAt),
})

Business Context

Domain-specific data relevant to the operation:

server/api/checkout.post.ts
log.set({
  cart: {
    id: cart.id,
    items: cart.items.length,
    total: cart.total,
    currency: 'USD',
  },
  shipping: {
    method: 'express',
    country: address.country,
  },
  coupon: appliedCoupon?.code,
})

Outcome

The result of the operation:

log.set({
  status: 200,
  duration: Date.now() - startTime,
  success: true,
})

Best Practices

Use Meaningful Keys

server/api/orders.post.ts
// Avoid generic keys
log.set({ data: { id: 123 } })

// Use specific, descriptive keys
log.set({ order: { id: 123, status: 'pending' } })
server/api/checkout.post.ts
// Flat structure is hard to read
log.set({
  userId: 1,
  userEmail: 'a@b.com',
  cartId: 2,
  cartTotal: 100,
})

// Grouped structure is clearer
log.set({
  user: { id: 1, email: 'a@b.com' },
  cart: { id: 2, total: 100 },
})

Add Context Incrementally

Call log.set() as you gather information:

import { useLogger } from 'evlog'

export default defineEventHandler(async (event) => {
  const log = useLogger(event)

  const user = await getUser(event)
  log.set({ user: { id: user.id, plan: user.plan } })

  const cart = await getCart(user.id)
  log.set({ cart: { items: cart.items.length, total: cart.total } })

  const payment = await processPayment(cart)
  log.set({ payment: { method: payment.method, status: payment.status } })

  return { success: true }
})

Handle Errors Gracefully

When errors occur, the wide event still emits with error context:

import { useLogger } from 'evlog'

export default defineEventHandler(async (event) => {
  const log = useLogger(event)

  try {
    const result = await processPayment(cart)
    return result
  } catch (err) {
    log.set({
      error: {
        message: err.message,
        code: err.code,
        type: err.constructor.name,
      },
    })
    throw err
  }
})

Output Formats

evlog automatically switches between formats based on environment: pretty in development, JSON in production. This is the default behavior, no configuration needed.

[INFO] POST /api/checkout (234ms)
  user: { id: 1, plan: 'pro' }
  cart: { items: 3, total: 9999 }
  payment: { method: 'card', status: 'success' }

Next Steps