Next.js
Practical patterns for using evlog with Next.js (App Router). Each section shows how to activate a feature and the recommended way to use it.
Production Configuration
A real-world lib/evlog.ts with enrichers, batched drain, tail sampling, and route-based service names:
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:
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:
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:
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):
{
"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:
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 — fetch responses, EvlogError, or plain Error objects:
'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.
Tail Sampling
Combine rule-based and custom tail sampling to always capture what matters, even when head sampling drops most logs:
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:
import { evlogMiddleware } from 'evlog/next'
export const proxy = evlogMiddleware()
export const config = {
matcher: ['/api/:path*'],
}
middleware.ts instead of proxy.ts. The evlog middleware works with both — import from evlog/next regardless.Client Provider
Wrap your root layout with EvlogProvider to enable client-side logging and transport:
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:
'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:
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.