Use Cases

Structured Logging in Node.js

One JSON wide event per Node.js request with evlog: a two-minute setup for node:http, Express, Fastify, Hono, NestJS or Nitro, then errors and drains.

Give every request one structured JSON event, a wide event, instead of a trail of log lines. This guide takes a Node.js server there in about two minutes.

The 30-second version

console.log('checkout started', { requestId })
console.log('user loaded', { userId: 'u_42' })
console.log('cart loaded', { items: 3, total: 2999 })
console.log('charging card', { provider: 'stripe' })
console.log('checkout done', { status: 200, ms: 182 })

Your handler calls log.set(). evlog adds the method, path, status, duration and a request ID, and writes one event when the response is sent:

Output
{
  "level": "info",
  "method": "POST",
  "path": "/checkout",
  "status": 200,
  "durationMs": 182,
  "requestId": "4f1c…",
  "user": { "id": "u_42" },
  "cart": { "items": 3, "total": 2999 },
  "payment": { "provider": "stripe" }
}

When a request fails, you read one record instead of searching for five, and every field can be queried: payment.provider = 'stripe' AND status >= 500.

Why wide events

narrow logs → wide event·idle
before·6 narrow log linesstdout
10:23:45.001INFOrequest started
10:23:45.012INFOauthenticated
10:23:45.024DEBUGdb query
10:23:45.069INFOstripe charge
10:23:45.180INFOorder created
10:23:45.234INFOresponse sent
matching to one request needs IDs everywhere
after·1 wide eventdrain
{
  requestId: "req_8a2c",
  status:    200,
  method: "POST",
  user: { id: 42, plan: "pro" },
  cart: { items: 3, total: 9999 },
  charge: { id: "ch_…", amount: 9999 },
  order: { id: "ord_889" },
  duration: 234
}
filter on any field, no joins, no IDs to chase
collapsed0 / 6 lines
emitted—
storage cost17% vs narrow
Line logsOne wide event
Records per requestOne per log callOne, emitted at the end
ContextRepeated on every line, or missing on someCollected once, next to the outcome
Debugging a failureFind every line for the request ID, then rebuild the storyRead one event: inputs, outcome, error, duration
VolumeGrows with the number of callsFixed per operation, then reduced by sampling

You can still write individual records for things outside a request, such as startup messages, with the global log API (Simple Logging). Wide Events covers when to use each.

Set up in 2 minutes

Every code block below opens on Standalone: a plain Node.js script built on node:http, with no framework, so it runs anywhere Node does. Using a framework? Pick it from the menu on any block. The choice applies to every block on this page and is remembered across the docs.

Install evlog

pnpm add evlog

Create evlog.config.ts

evlog init detects your framework, writes this file, wires the integration and runs evlog doctor to check the result (evlog init). You can also write the file by hand.

pnpm dlx evlog init
evlog.config.ts
import { defineEvlog } from 'evlog'

export default defineEvlog({
  service: 'checkout',
})

Every option lives in this file: sampling, drains, enrichers, redaction. They are listed in the Configuration reference.

Wire it into your server

src/server.tsnuxt.config.tsnitro.config.tslib/evlog.tssrc/hooks.server.tsnitro.config.tsapp/root.tsxsrc/server.tssrc/server.tssrc/server.tssrc/server.tssrc/main.tssrc/worker.tssrc/middleware.tssrc/handler.ts
import { createServer } from 'node:http'
import { initLogger, toLoggerConfig } from 'evlog'
import config from '../evlog.config'

initLogger(toLoggerConfig(config))

createServer((req, res) => {
  // handlers create a request logger, see the next section
  res.end('ok')
}).listen(3000)
// The module finds evlog.config.ts and loads it
export default defineNuxtConfig({
  modules: ['evlog/nuxt'],
})
import { defineConfig } from 'nitro'
import evlog from 'evlog/nitro/v3'

// The module finds evlog.config.ts and loads it
export default defineConfig({
  modules: [evlog()],
})
import { createEvlog } from 'evlog/next'
import config from '../evlog.config'

// Wrap route handlers with withEvlog; the other exports are request-aware
export const { withEvlog, useLogger, log, createError } = createEvlog(config)
import { initLogger, toLoggerConfig, toMiddlewareOptions } from 'evlog'
import { createEvlogHooks } from 'evlog/sveltekit'
import config from '../evlog.config'

initLogger(toLoggerConfig(config))

export const { handle, handleError } = createEvlogHooks(toMiddlewareOptions(config))
import { defineConfig } from 'nitro'
import evlog from 'evlog/nitro/v3'

// The module finds evlog.config.ts; asyncContext lets handlers call useRequest()
export default defineConfig({
  experimental: { asyncContext: true },
  modules: [evlog()],
})
import { initLogger, toLoggerConfig, toMiddlewareOptions } from 'evlog'
import { evlog } from 'evlog/react-router'
import config from '../evlog.config'
import type { Route } from './+types/root'

initLogger(toLoggerConfig(config))

// Needs future.v8_middleware: true in react-router.config.ts
export const middleware: Route.MiddlewareFunction[] = [
  evlog(toMiddlewareOptions(config)),
]
import { serve } from '@hono/node-server'
import { Hono } from 'hono'
import { initLogger, toLoggerConfig, toMiddlewareOptions } from 'evlog'
import { evlog, type EvlogVariables } from 'evlog/hono'
import config from '../evlog.config'

initLogger(toLoggerConfig(config))

const app = new Hono<EvlogVariables>()
app.use(evlog(toMiddlewareOptions(config)))

serve({ fetch: app.fetch, port: 3000 })
import express from 'express'
import { initLogger, toLoggerConfig, toMiddlewareOptions } from 'evlog'
import { evlog } from 'evlog/express'
import config from '../evlog.config'

initLogger(toLoggerConfig(config))

const app = express()
app.use(evlog(toMiddlewareOptions(config)))

app.listen(3000)
import Fastify from 'fastify'
import { initLogger, toLoggerConfig, toMiddlewareOptions } from 'evlog'
import { evlog } from 'evlog/fastify'
import config from '../evlog.config'

initLogger(toLoggerConfig(config))

const app = Fastify({ logger: false })
await app.register(evlog, toMiddlewareOptions(config))

await app.listen({ port: 3000 })
import { Elysia } from 'elysia'
import { initLogger, toLoggerConfig, toMiddlewareOptions } from 'evlog'
import { evlog } from 'evlog/elysia'
import config from '../evlog.config'

initLogger(toLoggerConfig(config))

export const app = new Elysia()
  .use(evlog(toMiddlewareOptions(config)))
  .listen(3000)
import 'reflect-metadata'
import { Module } from '@nestjs/common'
import { NestFactory } from '@nestjs/core'
import { initLogger, toLoggerConfig, toMiddlewareOptions } from 'evlog'
import { EvlogModule } from 'evlog/nestjs'
import { CheckoutController } from './checkout.controller'
import config from '../evlog.config'

initLogger(toLoggerConfig(config))

@Module({
  // forRoot() registers a global middleware for every route
  controllers: [CheckoutController],
  imports: [EvlogModule.forRoot(toMiddlewareOptions(config))],
})
class AppModule {}

const app = await NestFactory.create(AppModule)
await app.listen(3000)
import { toLoggerConfig, toMiddlewareOptions } from 'evlog'
import { initWorkersLogger, withEvlog } from 'evlog/workers'
import config from '../evlog.config'

initWorkersLogger(toLoggerConfig(config))

// withEvlog emits the event and keeps drains alive with waitUntil
export default withEvlog(async (request, _env, _ctx, log) => {
  return Response.json({ ok: true })
}, toMiddlewareOptions(config))
import { defineMiddleware } from 'astro:middleware'
import { createRequestLogger, initLogger, parseError, toLoggerConfig } from 'evlog'
import config from '../evlog.config'

initLogger(toLoggerConfig(config))

// Type locals.log as RequestLogger in src/env.d.ts
export const onRequest = defineMiddleware(async ({ request, locals }, next) => {
  const log = createRequestLogger({ method: request.method, path: new URL(request.url).pathname })
  locals.log = log
  try {
    const response = await next()
    log.emit({ status: response.status })
    return response
  } catch (error) {
    log.error(error instanceof Error ? error : new Error(String(error)))
    log.emit({ status: parseError(error).status }) // 500 unless the error carries one
    throw error
  }
})
import type { APIGatewayProxyEventV2 } from 'aws-lambda'
import { initLogger, toLoggerConfig } from 'evlog'
import config from '../evlog.config'

// Once per cold start, outside the handler
initLogger(toLoggerConfig(config))

export async function handler(event: APIGatewayProxyEventV2) {
  // one request logger per invocation, see the next section
  return { statusCode: 200, body: 'ok' }
}
Using oRPC? It runs inside one of the servers above, and its integration tags each event with the procedure's operation. Every guide is listed in Frameworks.

Log your first wide event

Add context with log.set() as the handler learns things. Nested objects merge, so payment can be filled in two steps. With a framework integration, method, path, status, duration and a request ID are recorded for you, and the event is emitted when the response finishes.

src/server.tsserver/api/checkout.post.tsroutes/checkout.post.tsapp/api/checkout/route.tssrc/routes/api/checkout/+server.tssrc/routes/api/checkout.tsapp/routes/checkout.tsxsrc/server.tssrc/server.tssrc/server.tssrc/server.tssrc/checkout.controller.tssrc/worker.tssrc/pages/api/checkout.tssrc/handler.ts
import { randomUUID } from 'node:crypto'
import { createServer } from 'node:http'
import { createRequestLogger, initLogger, toLoggerConfig } from 'evlog'
import config from '../evlog.config'

initLogger(toLoggerConfig(config))

createServer((req, res) => {
  const log = createRequestLogger({ method: req.method, path: new URL(req.url ?? '/', 'http://localhost').pathname, requestId: randomUUID() })

  // Without a framework, you emit the event yourself: once, after the response is sent
  let emitted = false
  const emit = () => {
    if (emitted) return
    emitted = true
    log.emit({ status: res.statusCode })
  }
  res.once('finish', emit)
  res.once('close', emit)

  log.set({ orderId: 'order-123', payment: { amount: 2999, currency: 'EUR' } })
  log.set({ payment: { chargeId: 'ch_123' }, outcome: 'paid' })
  res.statusCode = 200
  res.end(JSON.stringify({ orderId: 'order-123' }))
}).listen(3000)
export default defineEventHandler((event) => {
  const log = useLogger(event) // auto-imported
  log.set({ orderId: 'order-123', payment: { amount: 2999, currency: 'EUR' } })
  log.set({ payment: { chargeId: 'ch_123' }, outcome: 'paid' })
  return { orderId: 'order-123' }
})
import { defineHandler } from 'nitro/h3'
import { useLogger } from 'evlog/nitro/v3'

export default defineHandler((event) => {
  const log = useLogger(event)
  log.set({ orderId: 'order-123', payment: { amount: 2999, currency: 'EUR' } })
  log.set({ payment: { chargeId: 'ch_123' }, outcome: 'paid' })
  return { orderId: 'order-123' }
})
import { useLogger, withEvlog } from '@/lib/evlog'

export const POST = withEvlog(async () => {
  const log = useLogger()
  log.set({ orderId: 'order-123', payment: { amount: 2999, currency: 'EUR' } })
  log.set({ payment: { chargeId: 'ch_123' }, outcome: 'paid' })
  return Response.json({ orderId: 'order-123' })
})
import { json } from '@sveltejs/kit'
import type { RequestHandler } from './$types'

// Type locals.log as RequestLogger in src/app.d.ts
export const POST: RequestHandler = async ({ locals }) => {
  locals.log.set({ orderId: 'order-123', payment: { amount: 2999, currency: 'EUR' } })
  locals.log.set({ payment: { chargeId: 'ch_123' }, outcome: 'paid' })
  return json({ orderId: 'order-123' })
}
import { createFileRoute } from '@tanstack/react-router'
import type { RequestLogger } from 'evlog'
import { useRequest } from 'nitro/context'

export const Route = createFileRoute('/api/checkout')({
  server: {
    handlers: {
      POST: async () => {
        const log = useRequest().context?.log as RequestLogger
        log.set({ orderId: 'order-123', payment: { amount: 2999, currency: 'EUR' } })
        log.set({ payment: { chargeId: 'ch_123' }, outcome: 'paid' })
        return Response.json({ orderId: 'order-123' })
      },
    },
  },
})
import { loggerContext } from 'evlog/react-router'
import type { Route } from './+types/checkout'

export async function action({ context }: Route.ActionArgs) {
  const log = context.get(loggerContext)
  log.set({ orderId: 'order-123', payment: { amount: 2999, currency: 'EUR' } })
  log.set({ payment: { chargeId: 'ch_123' }, outcome: 'paid' })
  return { orderId: 'order-123' }
}
app.post('/checkout', (c) => {
  const log = c.get('log')
  log.set({ orderId: 'order-123', payment: { amount: 2999, currency: 'EUR' } })
  log.set({ payment: { chargeId: 'ch_123' }, outcome: 'paid' })
  return c.json({ orderId: 'order-123' })
})
import { useLogger } from 'evlog/express'

app.post('/checkout', (req, res) => {
  const log = useLogger() // the same logger as req.log
  log.set({ orderId: 'order-123', payment: { amount: 2999, currency: 'EUR' } })
  log.set({ payment: { chargeId: 'ch_123' }, outcome: 'paid' })
  res.json({ orderId: 'order-123' })
})
import { useLogger } from 'evlog/fastify'

app.post('/checkout', async () => {
  const log = useLogger() // the same logger as request.log
  log.set({ orderId: 'order-123', payment: { amount: 2999, currency: 'EUR' } })
  log.set({ payment: { chargeId: 'ch_123' }, outcome: 'paid' })
  return { orderId: 'order-123' }
})
app.post('/checkout', ({ log }) => {
  log.set({ orderId: 'order-123', payment: { amount: 2999, currency: 'EUR' } })
  log.set({ payment: { chargeId: 'ch_123' }, outcome: 'paid' })
  return { orderId: 'order-123' }
})
import { Controller, Post } from '@nestjs/common'
import { useLogger } from 'evlog/nestjs'

@Controller('checkout')
export class CheckoutController {
  @Post()
  pay() {
    const log = useLogger()
    log.set({ orderId: 'order-123', payment: { amount: 2999, currency: 'EUR' } })
    log.set({ payment: { chargeId: 'ch_123' }, outcome: 'paid' })
    return { orderId: 'order-123' }
  }
}
import { toLoggerConfig, toMiddlewareOptions } from 'evlog'
import { initWorkersLogger, withEvlog } from 'evlog/workers'
import config from '../evlog.config'

initWorkersLogger(toLoggerConfig(config))

export default withEvlog(async (request, _env, _ctx, log) => {
  log.set({ orderId: 'order-123', payment: { amount: 2999, currency: 'EUR' } })
  log.set({ payment: { chargeId: 'ch_123' }, outcome: 'paid' })
  return Response.json({ orderId: 'order-123' })
}, toMiddlewareOptions(config))
import type { APIRoute } from 'astro'

export const POST: APIRoute = async ({ locals }) => {
  locals.log.set({ orderId: 'order-123', payment: { amount: 2999, currency: 'EUR' } })
  locals.log.set({ payment: { chargeId: 'ch_123' }, outcome: 'paid' })
  return Response.json({ orderId: 'order-123' })
}
import type { APIGatewayProxyEventV2 } from 'aws-lambda'
import { createRequestLogger, initLogger, toLoggerConfig } from 'evlog'
import config from '../evlog.config'

initLogger(toLoggerConfig(config))

export async function handler(event: APIGatewayProxyEventV2) {
  const { method, path } = event.requestContext.http
  const log = createRequestLogger({ method, path, requestId: event.requestContext.requestId })
  let statusCode = 500
  try {
    log.set({ orderId: 'order-123', payment: { amount: 2999, currency: 'EUR' } })
    log.set({ payment: { chargeId: 'ch_123' }, outcome: 'paid' })
    statusCode = 200
    return { statusCode, body: JSON.stringify({ orderId: 'order-123' }) }
  } finally {
    // Lambda has no response lifecycle to hook: emit before the handler returns
    log.emit({ status: statusCode })
  }
}

Send a request from another terminal:

Terminal
curl -X POST http://localhost:3000/checkout

In development the event prints as a readable tree. In production it is one JSON line, with these fields plus environment metadata:

Output
{
  "level": "info",
  "service": "checkout",
  "method": "POST",
  "path": "/checkout",
  "status": 200,
  "orderId": "order-123",
  "payment": { "amount": 2999, "currency": "EUR", "chargeId": "ch_123" },
  "outcome": "paid"
}
Log what you would want while debugging this operation: IDs, outcomes, amounts, counts, timings. Leave out credentials and personal data you don't need.

Errors that explain themselves

error context·idle
vanilla·throw new Error()
throw new Error("Payment failed")
↓ caller catches
err.message → "Payment failed"
err.status  → undefined
err.why     → undefined
err.fix     → undefined
error

Something went wrong.
Please try again.

structured·createError()
throw createError({
  message: "Payment failed", // what went wrong
  status: 402, // HTTP status
  why: "Card declined by issuer", // technical reason
  fix: "Try a different card", // actionable advice
  link: "/docs/payments/declined" // docs link
})
↓ parseError(err)
{ message, status, why, fix, link }
all fields available · safe by default
payment failed · 402

Card declined by issuer. Try a different card.

read more
1 field · user has to guess
5 fields · actionable end-to-end

createError() gives an error a status, a why and a fix. evlog records them on the request event, next to the context you set before the failure, and parseError() reads them back to build the HTTP response.

src/server.tsserver/api/checkout.post.tsroutes/checkout.post.tsapp/api/checkout/route.tssrc/routes/api/checkout/+server.tssrc/routes/api/checkout.tsapp/routes/checkout.tsxsrc/server.tssrc/server.tssrc/server.tssrc/server.tssrc/evlog-exception.filter.tssrc/worker.tssrc/pages/api/checkout.tssrc/handler.ts
import { createError, parseError } from 'evlog'

// inside the request handler from the previous section, after the listeners
try {
  throw createError({
    message: 'Payment declined',
    status: 402,
    why: 'The card issuer declined the charge',
    fix: 'Try another payment method',
  })
} catch (error) {
  log.error(error instanceof Error ? error : new Error(String(error)))
  const parsed = parseError(error)
  res.statusCode = parsed.status
  res.end(JSON.stringify({ message: parsed.message, why: parsed.why, fix: parsed.fix }))
}
// the 'finish' listener emits the event with status 402
// Import createError from evlog: h3's version drops why and fix
import { createError } from 'evlog'

export default defineEventHandler((event) => {
  useLogger(event).set({ orderId: 'order-123' })

  // Nuxt's error handler records it and returns why and fix as JSON
  throw createError({
    message: 'Payment declined',
    status: 402,
    why: 'The card issuer declined the charge',
    fix: 'Try another payment method',
  })
})
import { defineHandler } from 'nitro/h3'
import { createError, useLogger } from 'evlog/nitro/v3'

export default defineHandler((event) => {
  useLogger(event).set({ orderId: 'order-123' })

  // Nitro's error handler records it and builds the response
  throw createError({
    message: 'Payment declined',
    status: 402,
    why: 'The card issuer declined the charge',
    fix: 'Try another payment method',
  })
})
import { createError, useLogger, withEvlog } from '@/lib/evlog'

export const POST = withEvlog(async () => {
  useLogger().set({ orderId: 'order-123' })

  // withEvlog records it and returns why and fix as JSON
  throw createError({
    message: 'Payment declined',
    status: 402,
    why: 'The card issuer declined the charge',
    fix: 'Try another payment method',
  })
})
import { createError } from 'evlog'
import type { RequestHandler } from './$types'

export const POST: RequestHandler = async ({ locals }) => {
  locals.log.set({ orderId: 'order-123' })

  // The handleError hook records it on the event
  throw createError({
    message: 'Payment declined',
    status: 402,
    why: 'The card issuer declined the charge',
    fix: 'Try another payment method',
  })
}
import { createFileRoute } from '@tanstack/react-router'
import { createError } from 'evlog'

// For a JSON response with why and fix, add the evlogErrorHandler
// middleware from 'evlog/nitro/v3' to the root route
export const Route = createFileRoute('/api/checkout')({
  server: {
    handlers: {
      POST: async () => {
        throw createError({
          message: 'Payment declined',
          status: 402,
          why: 'The card issuer declined the charge',
          fix: 'Try another payment method',
        })
      },
    },
  },
})
import { createError } from 'evlog'
import { loggerContext } from 'evlog/react-router'
import type { Route } from './+types/checkout'

export async function action({ context }: Route.ActionArgs) {
  context.get(loggerContext).set({ orderId: 'order-123' })

  throw createError({
    message: 'Payment declined',
    status: 402,
    why: 'The card issuer declined the charge',
    fix: 'Try another payment method',
  })
}
import type { ContentfulStatusCode } from 'hono/utils/http-status'
import { createError, parseError } from 'evlog'

app.post('/checkout', () => {
  throw createError({
    message: 'Payment declined',
    status: 402,
    why: 'The card issuer declined the charge',
    fix: 'Try another payment method',
  })
})

app.onError((error, c) => {
  c.get('log').error(error)
  const parsed = parseError(error)
  return c.json({ message: parsed.message, why: parsed.why, fix: parsed.fix }, parsed.status as ContentfulStatusCode)
})
import type { ErrorRequestHandler } from 'express'
import { createError, parseError } from 'evlog'

app.post('/checkout', () => {
  throw createError({
    message: 'Payment declined',
    status: 402,
    why: 'The card issuer declined the charge',
    fix: 'Try another payment method',
  })
})

const onError: ErrorRequestHandler = (err, req, res, _next) => {
  req.log?.error(err)
  const parsed = parseError(err)
  res.status(parsed.status).json({ message: parsed.message, why: parsed.why, fix: parsed.fix })
}
app.use(onError)
import { createError, parseError } from 'evlog'

app.post('/checkout', async () => {
  throw createError({
    message: 'Payment declined',
    status: 402,
    why: 'The card issuer declined the charge',
    fix: 'Try another payment method',
  })
})

// The plugin records the error on the event through Fastify's onError hook
app.setErrorHandler((error, _request, reply) => {
  const parsed = parseError(error)
  reply.status(parsed.status).send({ message: parsed.message, why: parsed.why, fix: parsed.fix })
})
import { createError, parseError } from 'evlog'

app
  .post('/checkout', () => {
    throw createError({
      message: 'Payment declined',
      status: 402,
      why: 'The card issuer declined the charge',
      fix: 'Try another payment method',
    })
  })
  .onError(({ error, set }) => {
    const parsed = parseError(error)
    set.status = parsed.status
    return { message: parsed.message, why: parsed.why, fix: parsed.fix }
  })
import { Catch } from '@nestjs/common'
import type { ArgumentsHost, ExceptionFilter } from '@nestjs/common'
import { parseError } from 'evlog'
import { useLogger } from 'evlog/nestjs'

// Register with app.useGlobalFilters(new EvlogExceptionFilter()) in src/main.ts
@Catch()
export class EvlogExceptionFilter implements ExceptionFilter {
  catch(exception: unknown, host: ArgumentsHost) {
    useLogger().error(exception instanceof Error ? exception : new Error(String(exception)))
    const parsed = parseError(exception)
    host.switchToHttp().getResponse().status(parsed.status).json({ message: parsed.message, why: parsed.why, fix: parsed.fix })
  }
}
import { createError, parseError, toLoggerConfig, toMiddlewareOptions } from 'evlog'
import { initWorkersLogger, withEvlog } from 'evlog/workers'
import config from '../evlog.config'

initWorkersLogger(toLoggerConfig(config))

export default withEvlog(async (request, _env, _ctx, log) => {
  try {
    throw createError({
      message: 'Payment declined',
      status: 402,
      why: 'The card issuer declined the charge',
      fix: 'Try another payment method',
    })
  } catch (error) {
    log.error(error instanceof Error ? error : new Error(String(error)))
    const parsed = parseError(error)
    return Response.json({ message: parsed.message, why: parsed.why, fix: parsed.fix }, { status: parsed.status })
  }
}, toMiddlewareOptions(config))
import type { APIRoute } from 'astro'
import { createError, parseError } from 'evlog'

export const POST: APIRoute = async ({ locals }) => {
  const error = createError({
    message: 'Payment declined',
    status: 402,
    why: 'The card issuer declined the charge',
    fix: 'Try another payment method',
  })
  locals.log.error(error)
  const parsed = parseError(error)
  return Response.json({ message: parsed.message, why: parsed.why, fix: parsed.fix }, { status: parsed.status })
}
import { createError, parseError } from 'evlog'

// inside the handler from the previous section
try {
  throw createError({
    message: 'Payment declined',
    status: 402,
    why: 'The card issuer declined the charge',
    fix: 'Try another payment method',
  })
} catch (error) {
  log.error(error instanceof Error ? error : new Error(String(error)))
  const parsed = parseError(error)
  statusCode = parsed.status
  return { statusCode, body: JSON.stringify({ message: parsed.message, why: parsed.why, fix: parsed.fix }) }
} finally {
  log.emit({ status: statusCode })
}

The declined request emits an error event with status: 402, the context set before the failure, and the error's message, why and fix. Structured Errors covers link, error codes and catalogs for failures that recur.

Send logs somewhere

drain pipeline
0/5 delivered
wide event
{
  level:    "info",
  method:   "POST",
  path:     "/checkout",
  duration: 234,
  user:     {...},
  cart:     {...}
}
BUILDING
  1. axiom
    pending
  2. otlp
    pending
  3. sentry
    pending
  4. nuxthub
    pending
  5. fs
    pending
fire-and-forget retry with backoff one failure ≠ blocked response ✓ all destinations resolved

Locally, events print to the terminal. In production, a drain ships them to a backend, without changing any handler. Add it to the config file, and every integration on this page picks it up:

evlog.config.ts
import { defineEvlog } from 'evlog'
import { createAxiomDrain } from 'evlog/axiom'

export default defineEvlog({
  service: 'checkout',
  drain: createAxiomDrain(), // reads AXIOM_API_KEY and AXIOM_DATASET
})

Axiom is one option. Datadog, Sentry, PostHog, Better Stack, Loki, ClickHouse, OTLP and the filesystem are in the adapters overview. An enrich function in the same file adds fields to every event, such as the user agent or the deployed version (Enrichers).

Production checklist

  • Sample by outcome. Keep a share of successful info events and use keep rules so errors and slow requests are always kept (Sampling).
    evlog.config.ts
    import { defineEvlog } from 'evlog'
    
    export default defineEvlog({
      service: 'checkout',
      sampling: { rates: { info: 10 }, keep: [{ status: 400 }, { duration: 1000 }] },
    })
    
  • Redact before shipping. Redaction is on by default in production. Add paths for fields specific to your domain (Redaction).
  • Flush on exit. Scripts, workers and serverless functions must wait for pending delivery before the process stops (Standalone TypeScript).
  • Check the wiring. Run evlog doctor when nothing shows up, and evlog map to find handlers that still emit no event.
    pnpm evlog doctor
    

FAQ

To keep field names consistent across handlers, continue with Typed Fields. For your framework's full options, see the framework guides.