Structured Logging in Node.js
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 })
log.set({ user: { id: 'u_42' } })
log.set({ cart: { items: 3, total: 2999 } })
log.set({ payment: { provider: 'stripe' } })
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:
{
"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
{
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
}| Line logs | One wide event | |
|---|---|---|
| Records per request | One per log call | One, emitted at the end |
| Context | Repeated on every line, or missing on some | Collected once, next to the outcome |
| Debugging a failure | Find every line for the request ID, then rebuild the story | Read one event: inputs, outcome, error, duration |
| Volume | Grows with the number of calls | Fixed 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
npm install evlog
yarn add evlog
bun 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
npx evlog init
yarn dlx evlog init
bunx evlog init
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
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' }
}
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.
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:
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:
{
"level": "info",
"service": "checkout",
"method": "POST",
"path": "/checkout",
"status": 200,
"orderId": "order-123",
"payment": { "amount": 2999, "currency": "EUR", "chargeId": "ch_123" },
"outcome": "paid"
}
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.
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 } })
}
import type { H3Event } from 'h3'
export async function charge(event: H3Event, amount: number) {
useLogger(event).set({ payment: { provider: 'stripe', amount } }) // auto-imported
}
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 } })
}
import { useLogger } from '@/lib/evlog'
export async function charge(amount: number) {
useLogger().set({ payment: { provider: 'stripe', amount } })
}
import { useLogger } from 'evlog/sveltekit'
export async function charge(amount: number) {
useLogger().set({ payment: { provider: 'stripe', amount } })
}
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 } })
}
import { useLogger } from 'evlog/react-router'
export async function charge(amount: number) {
useLogger().set({ payment: { provider: 'stripe', amount } })
}
import { useLogger } from 'evlog/hono'
export async function charge(amount: number) {
useLogger().set({ payment: { provider: 'stripe', amount } })
}
import { useLogger } from 'evlog/express'
export async function charge(amount: number) {
useLogger().set({ payment: { provider: 'stripe', amount } })
}
import { useLogger } from 'evlog/fastify'
export async function charge(amount: number) {
useLogger().set({ payment: { provider: 'stripe', amount } })
}
import { useLogger } from 'evlog/elysia'
export async function charge(amount: number) {
useLogger().set({ payment: { provider: 'stripe', amount } })
}
import { Injectable } from '@nestjs/common'
import { useLogger } from 'evlog/nestjs'
@Injectable()
export class PaymentsService {
async charge(amount: number) {
useLogger().set({ payment: { provider: 'stripe', amount } })
}
}
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). The Logging Lifecycle shows each stage, from logger creation to the drain.
Errors that explain themselves
throw new Error("Payment failed")
err.message → "Payment failed"
err.status → undefined
err.why → undefined
err.fix → undefined Something went wrong.
Please try again.
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
}){ message, status, why, fix, link }
all fields available · safe by defaultcreateError() 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.
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
{
level: "info",
method: "POST",
path: "/checkout",
duration: 234,
user: {...},
cart: {...}
}- axiompending
- otlppending
- sentrypending
- nuxthubpending
- fspending
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:
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).
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:
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 })
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 for batch size, intervals and retries.
Production checklist
- Sample by outcome. Keep a share of successful
infoevents and usekeeprules so errors and slow requests are always kept (Sampling).evlog.config.tsimport { 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 doctorwhen nothing shows up, andevlog mapto find handlers that still emit no event.pnpm evlog doctornpx evlog doctoryarn evlog doctorbunx evlog doctor
FAQ
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).evlog.config.ts (Redaction).waitUntil to the logger, and in short-lived processes call flush() on a pipeline drain. See AWS Lambda, Cloudflare Workers and Standalone TypeScript.traceId and spanId to the event. evlog and OpenTelemetry shows the setup.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).To keep field names consistent across handlers, continue with Typed Fields. For your framework's full options, see the framework guides.
eve
One evlog wide event per eve agent turn: token usage, tool executions, and business context, through your own drains and tail sampling.
Overview
Ask a model one question about each request and get the answer back as a column: who broke it, is it worth paging, did the customer get what they came for.