---
title: "Structured Logging in Node.js"
description: "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."
canonical_url: "https://www.evlog.dev/use-cases/structured-logging-nodejs"
---
# 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

<code-group>

```typescript [Before: 5 log lines]
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 })
```

```typescript [After: 1 wide event]
log.set({ user: { id: 'u_42' } })
log.set({ cart: { items: 3, total: 2999 } })
log.set({ payment: { provider: 'stripe' } })
```

</code-group>

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:

```json [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

<wide-event-collapse>



</wide-event-collapse>

<table>
<thead>
  <tr>
    <th>
      
    </th>
    
    <th>
      Line logs
    </th>
    
    <th>
      One wide event
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      Records per request
    </td>
    
    <td>
      One per log call
    </td>
    
    <td>
      One, emitted at the end
    </td>
  </tr>
  
  <tr>
    <td>
      Context
    </td>
    
    <td>
      Repeated on every line, or missing on some
    </td>
    
    <td>
      Collected once, next to the outcome
    </td>
  </tr>
  
  <tr>
    <td>
      Debugging a failure
    </td>
    
    <td>
      Find every line for the request ID, then rebuild the story
    </td>
    
    <td>
      Read one event: inputs, outcome, error, duration
    </td>
  </tr>
  
  <tr>
    <td>
      Volume
    </td>
    
    <td>
      Grows with the number of calls
    </td>
    
    <td>
      Fixed per operation, then reduced by <a href="https://www.evlog.dev/learn/sampling">
        sampling
      </a>
    </td>
  </tr>
</tbody>
</table>

You can still write individual records for things outside a request, such as startup messages, with the global `log` API ([Simple Logging](https://www.evlog.dev/learn/simple-logging)). [Wide Events](https://www.evlog.dev/learn/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.

<steps level="3">

### Install evlog

<code-group sync="package-manager">

```bash [pnpm]
pnpm add evlog
```

```bash [npm]
npm install evlog
```

```bash [yarn]
yarn add evlog
```

```bash [bun]
bun add evlog
```

</code-group>

### 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](https://www.evlog.dev/cli/init)). You can also write the file by hand.

<code-group sync="package-manager">

```bash [pnpm]
pnpm dlx evlog init
```

```bash [npm]
npx evlog init
```

```bash [yarn]
yarn dlx evlog init
```

```bash [bun]
bunx evlog init
```

</code-group>

```typescript [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](https://www.evlog.dev/reference/configuration).

### Wire it into your server

<framework-tabs>

```typescript [Standalone]src/server.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)
```

```typescript [Nuxt]nuxt.config.ts
// The module finds evlog.config.ts and loads it
export default defineNuxtConfig({
  modules: ['evlog/nuxt'],
})
```

```typescript [Nitro]nitro.config.ts
import { defineConfig } from 'nitro'
import evlog from 'evlog/nitro/v3'

// The module finds evlog.config.ts and loads it
export default defineConfig({
  modules: [evlog()],
})
```

```typescript [Next.js]lib/evlog.ts
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)
```

```typescript [SvelteKit]src/hooks.server.ts
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))
```

```typescript [TanStack Start]nitro.config.ts
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()],
})
```

```typescript [React Router]app/root.tsx
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)),
]
```

```typescript [Hono]src/server.ts
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 })
```

```typescript [Express]src/server.ts
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)
```

```typescript [Fastify]src/server.ts
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 })
```

```typescript [Elysia]src/server.ts
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)
```

```typescript [NestJS]src/main.ts
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)
```

```typescript [Cloudflare Workers]src/worker.ts
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))
```

```typescript [Astro]src/middleware.ts
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
  }
})
```

```typescript [AWS Lambda]src/handler.ts
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' }
}
```

</framework-tabs>
</steps>

<callout color="neutral" icon="i-lucide-layers">

**Using oRPC?** It runs inside one of the servers above, and its [integration](https://www.evlog.dev/integrate/frameworks/orpc) tags each event with the procedure's `operation`. Every guide is listed in [Frameworks](https://www.evlog.dev/integrate/frameworks/overview).

</callout>

## 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.

<framework-tabs>

```typescript [Standalone]src/server.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)
```

```typescript [Nuxt]server/api/checkout.post.ts
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' }
})
```

```typescript [Nitro]routes/checkout.post.ts
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' }
})
```

```typescript [Next.js]app/api/checkout/route.ts
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' })
})
```

```typescript [SvelteKit]src/routes/api/checkout/+server.ts
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' })
}
```

```typescript [TanStack Start]src/routes/api/checkout.ts
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' })
      },
    },
  },
})
```

```typescript [React Router]app/routes/checkout.tsx
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' }
}
```

```typescript [Hono]src/server.ts
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' })
})
```

```typescript [Express]src/server.ts
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' })
})
```

```typescript [Fastify]src/server.ts
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' }
})
```

```typescript [Elysia]src/server.ts
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' }
})
```

```typescript [NestJS]src/checkout.controller.ts
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' }
  }
}
```

```typescript [Cloudflare Workers]src/worker.ts
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))
```

```typescript [Astro]src/pages/api/checkout.ts
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' })
}
```

```typescript [AWS Lambda]src/handler.ts
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 })
  }
}
```

</framework-tabs>

Send a request from another terminal:

```bash [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:

```json [Output]
{
  "level": "info",
  "service": "checkout",
  "method": "POST",
  "path": "/checkout",
  "status": 200,
  "orderId": "order-123",
  "payment": { "amount": 2999, "currency": "EUR", "chargeId": "ch_123" },
  "outcome": "paid"
}
```

<callout color="neutral" icon="i-lucide-lightbulb">

Log what you would want while debugging this operation: IDs, outcomes, amounts, counts, timings. Leave out credentials and personal data you don't need.

</callout>

<collapsible close-text="Hide:" name="logging from service code" open-text="Go deeper:">

Code below the handler doesn't need the logger passed in. With Next.js, SvelteKit, React Router, Hono, Express, Fastify, Elysia and NestJS, `useLogger()` returns the same request logger, through `AsyncLocalStorage`. Nuxt and Nitro bind the logger to the event, so service code receives `event`, and TanStack Start reads it from `useRequest()`. On Cloudflare Workers, Astro and AWS Lambda, pass the logger in, as in the Standalone tab.

<framework-tabs>

```typescript [Standalone]src/payments.ts
import type { RequestLogger } from 'evlog'

// No framework: pass the request logger to the code that needs it
export async function charge(log: RequestLogger, amount: number) {
  log.set({ payment: { provider: 'stripe', amount } })
}
```

```typescript [Nuxt]server/utils/payments.ts
import type { H3Event } from 'h3'

export async function charge(event: H3Event, amount: number) {
  useLogger(event).set({ payment: { provider: 'stripe', amount } }) // auto-imported
}
```

```typescript [Nitro]utils/payments.ts
import type { H3Event } from 'nitro/h3'
import { useLogger } from 'evlog/nitro/v3'

export async function charge(event: H3Event, amount: number) {
  useLogger(event).set({ payment: { provider: 'stripe', amount } })
}
```

```typescript [Next.js]lib/payments.ts
import { useLogger } from '@/lib/evlog'

export async function charge(amount: number) {
  useLogger().set({ payment: { provider: 'stripe', amount } })
}
```

```typescript [SvelteKit]src/lib/server/payments.ts
import { useLogger } from 'evlog/sveltekit'

export async function charge(amount: number) {
  useLogger().set({ payment: { provider: 'stripe', amount } })
}
```

```typescript [TanStack Start]src/server/payments.ts
import type { RequestLogger } from 'evlog'
import { useRequest } from 'nitro/context'

export async function charge(amount: number) {
  const log = useRequest().context?.log as RequestLogger
  log.set({ payment: { provider: 'stripe', amount } })
}
```

```typescript [React Router]app/services/payments.server.ts
import { useLogger } from 'evlog/react-router'

export async function charge(amount: number) {
  useLogger().set({ payment: { provider: 'stripe', amount } })
}
```

```typescript [Hono]src/payments.ts
import { useLogger } from 'evlog/hono'

export async function charge(amount: number) {
  useLogger().set({ payment: { provider: 'stripe', amount } })
}
```

```typescript [Express]src/payments.ts
import { useLogger } from 'evlog/express'

export async function charge(amount: number) {
  useLogger().set({ payment: { provider: 'stripe', amount } })
}
```

```typescript [Fastify]src/payments.ts
import { useLogger } from 'evlog/fastify'

export async function charge(amount: number) {
  useLogger().set({ payment: { provider: 'stripe', amount } })
}
```

```typescript [Elysia]src/payments.ts
import { useLogger } from 'evlog/elysia'

export async function charge(amount: number) {
  useLogger().set({ payment: { provider: 'stripe', amount } })
}
```

```typescript [NestJS]src/payments.service.ts
import { Injectable } from '@nestjs/common'
import { useLogger } from 'evlog/nestjs'

@Injectable()
export class PaymentsService {
  async charge(amount: number) {
    useLogger().set({ payment: { provider: 'stripe', amount } })
  }
}
```

</framework-tabs>

Work that finishes after the response can no longer change the request event, because the logger is sealed when it emits. Use `log.fork()` for background work ([After emit](https://www.evlog.dev/learn/wide-events#after-emit-sealing-and-background-work)). The [Logging Lifecycle](https://www.evlog.dev/learn/lifecycle) shows each stage, from logger creation to the drain.

</collapsible>

## Errors that explain themselves

<structured-error-context>



</structured-error-context>

`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.

<framework-tabs>

```typescript [Standalone]src/server.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
```

```typescript [Nuxt]server/api/checkout.post.ts
// 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',
  })
})
```

```typescript [Nitro]routes/checkout.post.ts
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',
  })
})
```

```typescript [Next.js]app/api/checkout/route.ts
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',
  })
})
```

```typescript [SvelteKit]src/routes/api/checkout/+server.ts
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',
  })
}
```

```typescript [TanStack Start]src/routes/api/checkout.ts
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',
        })
      },
    },
  },
})
```

```typescript [React Router]app/routes/checkout.tsx
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',
  })
}
```

```typescript [Hono]src/server.ts
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)
})
```

```typescript [Express]src/server.ts
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)
```

```typescript [Fastify]src/server.ts
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 })
})
```

```typescript [Elysia]src/server.ts
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 }
  })
```

```typescript [NestJS]src/evlog-exception.filter.ts
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 })
  }
}
```

```typescript [Cloudflare Workers]src/worker.ts
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))
```

```typescript [Astro]src/pages/api/checkout.ts
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 })
}
```

```typescript [AWS Lambda]src/handler.ts
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 })
}
```

</framework-tabs>

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](https://www.evlog.dev/learn/structured-errors) covers `link`, error codes and [catalogs](https://www.evlog.dev/learn/catalogs) for failures that recur.

## Send logs somewhere

<drain-fan-out>



</drain-fan-out>

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:

```typescript [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](https://www.evlog.dev/integrate/adapters/overview). An `enrich` function in the same file adds fields to every event, such as the user agent or the deployed version ([Enrichers](https://www.evlog.dev/use-cases/enrichers)).

<collapsible close-text="Hide:" name="batching and flushing" open-text="Go deeper:">

A pipeline batches events and retries failed sends. Buffered events must be flushed before the process exits, which matters most for scripts, workers and serverless functions:

```typescript [evlog.config.ts]
import type { DrainContext } from 'evlog'
import { defineEvlog } from 'evlog'
import { createAxiomDrain } from 'evlog/axiom'
import { createDrainPipeline } from 'evlog/pipeline'

const pipeline = createDrainPipeline<DrainContext>({ batch: { size: 50 } })
export const drain = pipeline(createAxiomDrain())

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

```typescript [src/shutdown.ts]
import type { Server } from 'node:http'
import { drain } from '../evlog.config'

// Stop accepting connections, let in-flight requests finish, then flush.
// Node exits on its own once nothing is left to do.
export function flushOnShutdown(server: Server) {
  process.once('SIGTERM', () => {
    server.close(async () => {
      await drain.flush()
    })
  })
}
```

See [Drain Pipeline](https://www.evlog.dev/extend/drain-pipeline) for batch size, intervals and retries.

</collapsible>

## 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](https://www.evlog.dev/learn/sampling)).```typescript [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](https://www.evlog.dev/learn/redaction)).
- **Flush on exit.** Scripts, workers and serverless functions must wait for pending delivery before the process stops ([Standalone TypeScript](https://www.evlog.dev/integrate/frameworks/standalone)).
- **Check the wiring.** Run `evlog doctor` when nothing shows up, and [`evlog map`](https://www.evlog.dev/cli/map) to find handlers that still emit no event.<code-group sync="package-manager">

```bash [pnpm]
pnpm evlog doctor
```

```bash [npm]
npx evlog doctor
```

```bash [yarn]
yarn evlog doctor
```

```bash [bun]
bunx evlog doctor
```

</code-group>

## FAQ

<accordion type="single">
<accordion-item label="How is this different from pino or winston?">

Pino and winston write a record per call. evlog's request logger collects fields through the request and writes one event at the end, with the outcome, error and duration on it. It also has structured errors, sampling, redaction and drains built in. The [Pino](https://www.evlog.dev/compare/pino), [Winston](https://www.evlog.dev/compare/winston) and [all loggers](https://www.evlog.dev/reference/vs-other-loggers) comparisons cover the differences in detail.

</accordion-item>

<accordion-item label="Does it slow down my app?">

A request builds one in-memory object and serializes it once, instead of writing several records. Drains run after the event is emitted, and a [pipeline](https://www.evlog.dev/extend/drain-pipeline) batches network sends. The cost still depends on what you log and where it goes, so measure your own configuration: [Performance](https://www.evlog.dev/reference/performance) lists the benchmarks and how to run them, and [sampling](https://www.evlog.dev/learn/sampling) reduces volume.

</accordion-item>

<accordion-item label="Can I keep console.log while I migrate?">

Yes. evlog doesn't replace or patch `console`, so existing calls keep working. Move one route at a time to the request logger, and replace one-off messages with the global `log` API when you're ready ([Simple Logging](https://www.evlog.dev/learn/simple-logging)).

</accordion-item>

<accordion-item label="How do I avoid logging secrets or personal data?">

Don't put them in the context in the first place: log IDs rather than whole objects. Redaction is on by default in production and masks common sensitive values; add your own paths and patterns in `evlog.config.ts` ([Redaction](https://www.evlog.dev/learn/redaction)).

</accordion-item>

<accordion-item label="Does it work in serverless functions and at the edge?">

Yes. Wait for delivery before the function returns: on Cloudflare Workers pass `waitUntil` to the logger, and in short-lived processes call `flush()` on a pipeline drain. See [AWS Lambda](https://www.evlog.dev/integrate/frameworks/aws-lambda), [Cloudflare Workers](https://www.evlog.dev/integrate/frameworks/cloudflare-workers) and [Standalone TypeScript](https://www.evlog.dev/integrate/frameworks/standalone).

</accordion-item>

<accordion-item label="Does it work with OpenTelemetry?">

Yes. The [OTLP adapter](https://www.evlog.dev/integrate/adapters/hybrid/otlp) exports events to any OpenTelemetry Collector or backend, and you can add the active span's `traceId` and `spanId` to the event. [evlog and OpenTelemetry](https://www.evlog.dev/compare/opentelemetry) shows the setup.

</accordion-item>

<accordion-item label="How do I correlate logs across services?">

Every request event has a `requestId`. The Hono, Express, Fastify and NestJS integrations reuse the incoming `x-request-id` header when there is one, so forwarding that header links events across services. For distributed traces, `createTraceContextEnricher()` adds the incoming `traceparent` to each event ([Enrichers](https://www.evlog.dev/use-cases/enrichers)).

</accordion-item>
</accordion>

To keep field names consistent across handlers, continue with [Typed Fields](https://www.evlog.dev/learn/typed-fields). For your framework's full options, see the [framework guides](https://www.evlog.dev/integrate/frameworks/overview).

---

- [Standalone TypeScript](https://www.evlog.dev/integrate/frameworks/standalone)
- [Quick Start](https://www.evlog.dev/start/quick-start)


## Sitemap

See the full [sitemap](https://www.evlog.dev/sitemap.md) for all pages.
