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!' })
})

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.