Frameworks

Next.js

Source Code
Wide events, structured errors, drain pipeline, tail sampling, route-based services, error handling, and client-side logging in Next.js applications.

evlog integrates with Next.js App Router via a createEvlog() factory that provides withEvlog() handler wrapper, useLogger(), and typed exports. One file, zero global state.

Prompt
Set up evlog in my Next.js app with wide events and structured errors.

- Install evlog: pnpm add evlog
- Create lib/evlog.ts with createEvlog() to export withEvlog, useLogger, createError
- Set service name and optional sampling/drain config
- Wrap API route handlers with withEvlog()
- Use useLogger() inside handlers to build wide events with log.set()
- Throw errors with createError({ message, status, why, fix })
- Wide events are auto-emitted when each request completes

Docs: https://www.evlog.dev/frameworks/nextjs
Adapters: https://www.evlog.dev/adapters

Quick Start

1. Install

bun add evlog

2. Create your evlog instance

lib/evlog.ts
import { createEvlog } from 'evlog/next'

export const { withEvlog, useLogger, log, createError } = createEvlog({
  service: 'my-app',
})

3. Wrap a route handler

app/api/hello/route.ts
import { withEvlog, useLogger } from '@/lib/evlog'

export const GET = withEvlog(async () => {
  const log = useLogger()
  log.set({ action: 'hello' })
  return Response.json({ message: 'Hello!' })
})

Instrumentation

Next.js supports an instrumentation.ts file at the project root for server startup hooks and error reporting. evlog provides createInstrumentation() to integrate with this pattern.

These two APIs serve different purposes and can be used independently or together:
  • createEvlog() — per-request wide events via withEvlog()
  • createInstrumentation() — server startup (register()) + unhandled error reporting (onRequestError()) across all routes, including SSR and RSC
  • Both can coexist: register() initializes and locks the logger first, so createEvlog() respects it. Each can have its own drain.

1. Add instrumentation exports to your evlog instance

lib/evlog.ts
import { createInstrumentation } from 'evlog/next/instrumentation'
import { createFsDrain } from 'evlog/fs'

export const { register, onRequestError } = createInstrumentation({
  service: 'my-app',
  drain: createFsDrain(),
  captureOutput: true,
})

2. Wire up instrumentation.ts

Next.js evaluates instrumentation.ts in both Node.js and Edge runtimes. Load your real lib/evlog.ts only when NEXT_RUNTIME === 'nodejs' so Edge bundles never pull Node-only drains (fs, adapters, etc.).

RecommendeddefineNodeInstrumentation gates the Node runtime, dynamic-imports your module once (cached), and forwards register / onRequestError:

instrumentation.ts
import { defineNodeInstrumentation } from 'evlog/next/instrumentation'

export const { register, onRequestError } = defineNodeInstrumentation(() => import('./lib/evlog'))

Manual — same behavior with explicit handlers; use this if you want full control in the root file (extra branches, per-error logic, or a different import strategy). Without a shared helper, each onRequestError typically re-runs import('./lib/evlog') unless you add your own cache.

instrumentation.ts
export async function register() {
  if (process.env.NEXT_RUNTIME === 'nodejs') {
    const { register } = await import('./lib/evlog')
    await register()
  }
}

export async function onRequestError(
  error: { digest?: string } & Error,
  request: { path: string; method: string; headers: Record<string, string> },
  context: { routerKind: string; routePath: string; routeType: string; renderSource: string },
) {
  if (process.env.NEXT_RUNTIME === 'nodejs') {
    const { onRequestError } = await import('./lib/evlog')
    await onRequestError(error, request, context)
  }
}

Both styles are supported: the helper is optional sugar, not a takeover. defineNodeInstrumentation only forwards Next’s two hooks to whatever you export from lib/evlog — it does not prevent other work in your app.

Custom behavior (evlog + your code)

  • Root instrumentation.ts — Next’s stable surface here is register and onRequestError. The evlog helper exports exactly those; it does not reserve the whole file. If you need additional top-level exports later (when Next documents them), use the manual wiring and compose by hand, or keep evlog’s hooks minimal and put everything else in lib/evlog.ts.
  • lib/evlog.ts (recommended for composition) — wrap evlog’s handlers so you stay free to add startup work, metrics, or extra logging without fighting the helper:
lib/evlog.ts
import { createInstrumentation } from 'evlog/next/instrumentation'

const { register: evlogRegister, onRequestError: evlogOnRequestError } = createInstrumentation({
  service: 'my-app',
  drain: myDrain,
})

export async function register() {
  await evlogRegister()
  // e.g. OpenTelemetry, feature flags, custom one-off init
}

export function onRequestError(
  error: { digest?: string } & Error,
  request: { path: string; method: string; headers: Record<string, string> },
  context: { routerKind: string; routePath: string; routeType: string; renderSource: string },
) {
  evlogOnRequestError(error, request, context)
  // optional: your own side effects (metrics, etc.)
}

Then keep instrumentation.ts as a thin import (defineNodeInstrumentation or manual) that only loads ./lib/evlog on Node — your customization lives next to createEvlog() in one place.

Next.js automatically calls these exports:

  • register() — Runs once when the server starts. Initializes the evlog logger with your configured drain, sampling, and options. When captureOutput is enabled, stdout and stderr writes are captured as structured log events.
  • onRequestError() — Called on every unhandled request error. Emits a structured error log with the error message, digest, stack trace, request path/method, and routing context (routerKind, routePath, routeType, renderSource).
captureOutput only activates in the Node.js runtime (NEXT_RUNTIME === 'nodejs'). It patches process.stdout.write and process.stderr.write to emit structured log.info / log.error events alongside the original output.

Configuration

The createInstrumentation() factory accepts global logger options (enabled, service, env, pretty, silent, sampling, stringify, drain) plus:

OptionTypeDefaultDescription
captureOutputbooleanfalseCapture stdout/stderr as structured log events

Production Configuration

A real-world lib/evlog.ts with enrichers, batched drain, tail sampling, and route-based service names:

lib/evlog.ts
import type { DrainContext } from 'evlog'
import { createEvlog } from 'evlog/next'
import { createUserAgentEnricher, createRequestSizeEnricher } from 'evlog/enrichers'
import { createAxiomDrain } from 'evlog/axiom'
import { createDrainPipeline } from 'evlog/pipeline'

// 1. Enrichers - add derived context to every event
const enrichers = [createUserAgentEnricher(), createRequestSizeEnricher()]

// 2. Pipeline - batch events before sending
const pipeline = createDrainPipeline<DrainContext>({ batch: { size: 50, intervalMs: 5000 } })

// 3. Drain - send batched events to Axiom
const drain = pipeline(createAxiomDrain({
  dataset: 'logs',
  token: process.env.AXIOM_TOKEN!,
}))

export const { withEvlog, useLogger, log, createError } = createEvlog({
  service: 'my-app',

  // 4. Head sampling - keep 10% of info logs
  sampling: {
    rates: { info: 10 },
    keep: [
      { status: 400 },              // Always keep errors
      { duration: 1000 },           // Always keep slow requests
      { path: '/api/critical/**' }, // Always keep critical paths
    ],
  },

  // 5. Route-based service names
  routes: {
    '/api/auth/**': { service: 'auth-service' },
    '/api/payment/**': { service: 'payment-service' },
    '/api/booking/**': { service: 'booking-service' },
  },

  // 6. Custom tail sampling - business logic
  keep: (ctx) => {
    const user = ctx.context.user as { premium?: boolean } | undefined
    if (user?.premium) ctx.shouldKeep = true
  },

  // 7. Enrich every event with user agent, request size, and deployment info
  enrich: (ctx) => {
    for (const enricher of enrichers) enricher(ctx)
    ctx.event.deploymentId = process.env.VERCEL_DEPLOYMENT_ID
    ctx.event.region = process.env.VERCEL_REGION
  },

  drain,
})

Wide Events

Build up context progressively through your handler. One request = one wide event:

app/api/checkout/route.ts
import { withEvlog, useLogger } from '@/lib/evlog'

export const POST = withEvlog(async (request: Request) => {
  const log = useLogger()
  const body = await request.json()

  // Stage 1: User context
  log.set({
    user: { id: body.userId, plan: 'enterprise' },
  })

  // Stage 2: Cart context
  log.set({
    cart: { items: body.items.length, total: body.total, currency: 'USD' },
  })

  // Stage 3: Payment context
  const payment = await processPayment(body)
  log.set({
    payment: { method: payment.method, cardLast4: payment.last4 },
  })

  return Response.json({ success: true, orderId: payment.orderId })
})

All fields are merged into a single wide event emitted when the handler completes:

Output (Pretty)
10:23:45.612 INFO [my-app] POST /api/checkout 200 in 145ms
  ├─ user: id=usr_123 plan=enterprise
  ├─ cart: items=3 total=14999 currency=USD
  ├─ payment: method=card cardLast4=4242
  └─ requestId: a1b2c3d4-...

Error Handling

Use createError for structured errors with why, fix, and link fields that help developers debug in both logs and API responses:

app/api/payment/process/route.ts
import { withEvlog, useLogger, createError } from '@/lib/evlog'

export const POST = withEvlog(async (request: Request) => {
  const log = useLogger()
  const body = await request.json()

  log.set({ payment: { amount: body.amount } })

  if (body.amount <= 0) {
    throw createError({
      status: 400,
      message: 'Invalid payment amount',
      why: 'The amount must be a positive number',
      fix: 'Pass a positive integer in cents (e.g. 4999 for $49.99)',
      link: 'https://docs.example.com/api/payments#amount',
    })
  }

  const result = await chargeCard(body)

  if (!result.success) {
    log.error(new Error(`Payment declined: ${result.reason}`))
    throw createError({
      status: 402,
      message: 'Payment declined',
      why: `Card declined by issuer: ${result.reason}`,
      fix: 'Try a different payment method or contact your bank',
    })
  }

  return Response.json({ success: true })
})

withEvlog() catches EvlogError and returns a structured JSON response (like Nitro does for Nuxt):

Response (402)
{
  "name": "EvlogError",
  "message": "Payment declined",
  "status": 402,
  "data": {
    "why": "Card declined by issuer: insufficient_funds",
    "fix": "Try a different payment method or contact your bank"
  }
}

In the terminal, the error renders with colored output:

Terminal output
Error: Payment declined
Why: Card declined by issuer: insufficient_funds
Fix: Try a different payment method or contact your bank

Parsing Errors on the Client

Use parseError to extract the structured fields from any error, whether it's a fetch response, an EvlogError, or a plain Error object:

app/components/PaymentForm.tsx
'use client'
import { parseError } from 'evlog'

async function handleSubmit(formData: FormData) {
  try {
    const res = await fetch('/api/payment/process', {
      method: 'POST',
      body: JSON.stringify({ amount: Number(formData.get('amount')) }),
    })
    if (!res.ok) throw { data: await res.json(), status: res.status }
  } catch (error) {
    const { message, status, why, fix, link } = parseError(error)
    // message: "Payment declined"
    // why: "Card declined by issuer: insufficient_funds"
    // fix: "Try a different payment method or contact your bank"
  }
}

parseError normalizes any error shape into a flat { message, status, why?, fix?, link? } object, so your UI code never has to dig through nested data.data or check for different error formats.

Configuration

See the Configuration reference for the full list of shared options (enabled, pretty, silent, sampling, middleware options, etc.).

The createEvlog() factory accepts the following options:

OptionTypeDefaultDescription
servicestring'app'Service name shown in logs
environmentstringAuto-detectedEnvironment name
includestring[]undefinedRoute patterns to log
excludestring[]undefinedRoute patterns to exclude
routesRecord<string, RouteConfig>undefinedRoute-specific service configuration
sampling.ratesobjectundefinedHead sampling rates per log level
sampling.keeparrayundefinedTail sampling conditions
keep(ctx: TailSamplingContext) => voidundefinedCustom tail sampling callback
drainDrainFunctionundefinedDrain adapter for external services
enrich(ctx: EnrichContext) => voidundefinedEvent enrichment callback

Tail Sampling

Combine rule-based and custom tail sampling to always capture what matters, even when head sampling drops most logs:

lib/evlog.ts
export const { withEvlog, useLogger } = createEvlog({
  service: 'my-app',
  sampling: {
    rates: { info: 10 }, // Only keep 10% of info logs
    keep: [
      { status: 400 },              // Always keep 4xx/5xx
      { duration: 1000 },           // Always keep slow requests
      { path: '/api/critical/**' }, // Always keep critical paths
    ],
  },
  // Custom: always keep premium user requests
  keep: (ctx) => {
    const user = ctx.context.user as { premium?: boolean } | undefined
    if (user?.premium) ctx.shouldKeep = true
  },
})

The keep rules use OR logic: any match forces the event through regardless of head sampling.

Middleware

Set x-request-id and x-evlog-start headers so withEvlog() can correlate timing across the middleware -> handler chain:

proxy.ts
import { evlogMiddleware } from 'evlog/next'

export const proxy = evlogMiddleware()

export const config = {
  matcher: ['/api/:path*'],
}
Older versions of Next.js use middleware.ts instead of proxy.ts. The evlog middleware works with both, so just import from evlog/next regardless.

Server Actions

withEvlog() also works with Server Actions. Wrap your action to get full request-scoped logging:

app/actions/checkout.ts
'use server'
import { withEvlog, useLogger } from '@/lib/evlog'

export const checkout = withEvlog(async (formData: FormData) => {
  const log = useLogger()
  log.set({ action: 'checkout', cartId: formData.get('cartId') })
  // ...
})

Client Provider

Wrap your root layout with EvlogProvider to enable client-side logging and transport:

app/layout.tsx
import { EvlogProvider } from 'evlog/next/client'

export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <EvlogProvider service="my-app" transport={{ enabled: true }}>
          {children}
        </EvlogProvider>
      </body>
    </html>
  )
}

Client Logging

Use log in any client component. Identity is preserved across all logs and transported to the server:

app/components/Dashboard.tsx
'use client'
import { log, setIdentity, clearIdentity } from 'evlog/next/client'

export function Dashboard({ user }: { user: { id: string } }) {
  // Set identity once - all subsequent logs include it
  useEffect(() => {
    setIdentity({ userId: user.id })
    return () => clearIdentity()
  }, [user.id])

  return (
    <button onClick={() => log.info({ action: 'export_clicked', format: 'csv' })}>
      Export
    </button>
  )
}

Browser Drain

For advanced use cases, send structured DrainContext events directly from the browser to a custom endpoint:

import { createBrowserLogDrain } from 'evlog/browser'

const drain = createBrowserLogDrain({
  drain: { endpoint: '/api/evlog/browser-ingest' },
  pipeline: { batch: { size: 10, intervalMs: 5000 } },
})

drain(drainEvent)
await drain.flush()

The server endpoint receives batched events:

app/api/evlog/browser-ingest/route.ts
export async function POST(request: Request) {
  const events = await request.json()
  // Forward to your drain pipeline, Axiom, etc.
  return new Response(null, { status: 204 })
}

Run Locally

git clone https://github.com/HugoRCD/evlog.git
cd evlog/examples/nextjs
bun install
bun run dev

Open http://localhost:3000 to explore the example.

Source Code

Browse the complete Next.js example source on GitHub.