Install evlog
evlog supports multiple environments: Nuxt, Next.js, Nitro, Cloudflare Workers, and standalone TypeScript.
Install
pnpm add evlog
npm install evlog
yarn add evlog
bun add evlog
Setup
Nuxt
Add evlog to your Nuxt config:
export default defineNuxtConfig({
modules: ['evlog/nuxt'],
evlog: {
env: {
service: 'my-app',
},
},
})
That's it. useLogger, createError, and parseError are auto-imported.
Next.js
Create a shared evlog instance with createEvlog():
import { createEvlog } from 'evlog/next'
export const { withEvlog, useLogger, log, createError } = createEvlog({
service: 'my-app',
})
Wrap your route handlers with withEvlog():
import { NextRequest } from 'next/server'
import { withEvlog, useLogger } from '@/lib/evlog'
export const POST = withEvlog(async (request: NextRequest) => {
const log = useLogger() // Zero arguments — AsyncLocalStorage
log.set({ user: { id: '123' } })
return Response.json({ success: true })
})
Server Actions
withEvlog() works with server actions — just wrap the function:
'use server'
import { withEvlog, useLogger } from '@/lib/evlog'
export const checkout = withEvlog(async (formData: FormData) => {
const log = useLogger()
log.set({ action: 'checkout', source: 'server-action' })
log.set({ item: formData.get('item') })
return { success: true }
})
Request, withEvlog() still creates a logger but with method: 'UNKNOWN' and path: '/'. Use log.set() to add context.Middleware
The optional evlogMiddleware() sets x-request-id and x-evlog-start headers so withEvlog() can reuse them for consistent timing and request correlation across the middleware → handler chain.
import { evlogMiddleware } from 'evlog/next'
export const proxy = evlogMiddleware()
export const config = {
matcher: ['/api/:path*'],
}
Without the middleware, withEvlog() generates its own requestId and measures timing internally.
middleware.ts instead of proxy.ts. The evlog middleware works with both — import from evlog/next regardless.Client Provider
For client-side logging, wrap your root layout with EvlogProvider:
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>
)
}
Then use log in any client component:
'use client'
import { log } from 'evlog/next/client'
export function CheckoutButton() {
return (
<button onClick={() => log.info({ action: 'checkout_click' })}>
Checkout
</button>
)
}
Configuration Reference
All options for createEvlog():
| Option | Type | Default | Description |
|---|---|---|---|
service | string | 'app' | Service name shown in logs |
enabled | boolean | true | Globally enable/disable all logging |
pretty | boolean | Auto | Pretty print (true in dev, false in prod) |
env | Partial<EnvironmentContext> | Auto-detected | Environment, version, commitHash, region |
sampling.rates | SamplingRates | undefined | Head sampling rates per level (0-100%) |
sampling.keep | TailSamplingCondition[] | undefined | Tail sampling conditions (OR logic) |
include | string[] | undefined | Route patterns to log (glob) |
exclude | string[] | undefined | Route patterns to exclude (glob, takes precedence) |
routes | Record<string, RouteConfig> | undefined | Route-specific service names |
drain | (ctx: DrainContext) => void | undefined | Drain callback for external services |
enrich | (ctx: EnrichContext) => void | undefined | Enrich callback for derived context |
keep | (ctx: TailSamplingContext) => void | undefined | Custom tail sampling callback. Set ctx.shouldKeep = true to force-keep |
stringify | boolean | true | JSON stringify output (false for raw objects) |
Comparison with Nuxt
| Feature | Nuxt (evlog/nuxt) | Next.js (evlog/next) |
|---|---|---|
| Setup | modules: ['evlog/nuxt'] | createEvlog() factory |
| Logger | useLogger(event) | useLogger() (zero args) |
| Auto-import | Yes | No (import from @/lib/evlog) |
| Request scoping | Nitro hooks | AsyncLocalStorage |
| Drain | evlog:drain hook | drain callback |
| Enrich | evlog:enrich hook | enrich callback |
| Tail sampling (rules) | sampling.keep conditions | sampling.keep conditions |
| Tail sampling (custom) | evlog:emit:keep hook | keep callback |
| Client | Auto-configured Vue plugin | EvlogProvider React component |
| Emit | Automatic (afterResponse hook) | Automatic (withEvlog wrapper) |
Nitro v3
Register evlog as a Nitro module using the nitro package:
import { defineConfig } from 'nitro'
import evlog from 'evlog/nitro/v3'
export default defineConfig({
modules: [
evlog({
env: { service: 'my-api' },
})
],
})
Then use useLogger in your routes:
import { defineHandler } from 'nitro/h3'
import { useLogger } from 'evlog/nitro/v3'
export default defineHandler(async (event) => {
const log = useLogger(event)
log.set({ action: 'hello' })
return { ok: true }
})
Nitro v2
Same approach with nitropack:
import { defineNitroConfig } from 'nitropack/config'
import evlog from 'evlog/nitro'
export default defineNitroConfig({
modules: [
evlog({
env: { service: 'my-api' },
})
],
})
Then use useLogger in your routes:
import { defineEventHandler } from 'h3'
import { useLogger } from 'evlog/nitro'
export default defineEventHandler(async (event) => {
const log = useLogger(event)
log.set({ action: 'hello' })
return { ok: true }
})
createError is always imported from evlog regardless of Nitro version. Only the module and useLogger imports differ: evlog/nitro/v3 for v3, evlog/nitro for v2.Cloudflare Workers
Use the Workers adapter for structured logs and correct platform severity.
import { initWorkersLogger, createWorkersLogger } from 'evlog/workers'
initWorkersLogger({
env: { service: 'edge-api' },
})
export default {
async fetch(request: Request) {
const log = createWorkersLogger(request)
try {
log.set({ route: 'health' })
const response = new Response('ok', { status: 200 })
log.emit({ status: response.status })
return response
} catch (error) {
log.error(error as Error)
log.emit({ status: 500 })
throw error
}
},
}
Disable invocation logs to avoid duplicate request logs:
[observability.logs]
invocation_logs = false
Notes:
requestIddefaults tocf-raywhen availablerequest.cfis included (colo, country, asn) unless disabled- Use
headerAllowlistto avoid logging sensitive headers
Hono
Use the standalone API to create one wide event per request from a Hono middleware.
import { serve } from '@hono/node-server'
import { Hono } from 'hono'
import { createRequestLogger, initLogger } from 'evlog'
initLogger({
env: { service: 'hono-api' },
})
const app = new Hono()
app.use('*', async (c, next) => {
const startedAt = Date.now()
const log = createRequestLogger({ method: c.req.method, path: c.req.path })
try {
await next()
} catch (error) {
log.error(error as Error)
throw error
} finally {
log.emit({
status: c.res.status,
duration: Date.now() - startedAt,
})
}
})
app.get('/health', (c) => c.json({ ok: true }))
serve({ fetch: app.fetch, port: 3000 })
Standalone TypeScript
Use evlog in scripts, CLI tools, workers, or any TypeScript project:
import { initLogger, createRequestLogger } from 'evlog'
initLogger({
env: {
service: 'my-worker',
environment: 'production',
},
})
const log = createRequestLogger({ jobId: job.id })
log.set({ source: job.source, target: job.target })
log.set({ recordsSynced: 150 })
log.emit() // Manual emit required in standalone mode
log.emit() manually. In Nuxt/Nitro, this happens automatically at request end.Draining Logs to External Services
Use the drain option in initLogger to automatically send every emitted event to an external service. This works with all built-in adapters and the pipeline for batching and retry.
import type { DrainContext } from 'evlog'
import { initLogger, log, createRequestLogger } from 'evlog'
import { createAxiomDrain } from 'evlog/axiom'
import { createDrainPipeline } from 'evlog/pipeline'
const pipeline = createDrainPipeline<DrainContext>({ batch: { size: 10 } })
const drain = pipeline(createAxiomDrain())
initLogger({
env: { service: 'my-script', environment: 'production' },
drain,
})
// Every log is automatically drained
log.info({ action: 'sync_started' })
const reqLog = createRequestLogger({ method: 'POST', path: '/sync' })
reqLog.set({ recordsSynced: 150 })
reqLog.emit() // drained automatically
// Flush remaining events before exit
await drain.flush()
Configuration Options
These options apply to Nuxt, Nitro v2, and Nitro v3. The evlog module accepts the same options across all environments.
| Option | Type | Default | Description |
|---|---|---|---|
enabled | boolean | true | Globally enable/disable all logging. When false, all operations become no-ops |
env.service | string | 'app' | Service name shown in logs |
env.environment | string | Auto-detected | Environment name |
include | string[] | undefined | Route patterns to log. Supports glob (/api/**). If not set, all routes are logged |
exclude | string[] | undefined | Route patterns to exclude from logging. Supports glob. Exclusions take precedence over inclusions |
routes | Record<string, RouteConfig> | undefined | Route-specific service configuration |
pretty | boolean | true in dev | Pretty print with tree formatting |
sampling.rates | object | undefined | Head sampling rates per log level (0-100%). See Sampling |
sampling.keep | array | undefined | Tail sampling conditions to force-keep logs. See Sampling |
keep | (ctx: TailSamplingContext) => void | undefined | Custom tail sampling callback (Next.js). Equivalent to evlog:emit:keep hook in Nuxt |
Route Filtering
Use include and exclude to control which routes are logged. Both support glob patterns.
export default defineNuxtConfig({
modules: ['evlog/nuxt'],
evlog: {
include: ['/api/**', '/auth/**'],
exclude: [
'/api/_nuxt_icon/**',
'/api/_content/**',
'/api/health',
],
},
})
import { defineConfig } from 'nitro'
import evlog from 'evlog/nitro/v3'
export default defineConfig({
modules: [
evlog({
include: ['/api/**'],
exclude: ['/api/health'],
})
],
})
import { defineNitroConfig } from 'nitropack/config'
import evlog from 'evlog/nitro'
export default defineNitroConfig({
modules: [
evlog({
include: ['/api/**'],
exclude: ['/api/health'],
})
],
})
include and exclude, it will be excluded.Route-Based Service Configuration
In multi-service architectures, configure different service names for different routes:
export default defineNuxtConfig({
modules: ['evlog/nuxt'],
evlog: {
env: {
service: 'default-service',
},
routes: {
'/api/auth/**': { service: 'auth-service' },
'/api/payment/**': { service: 'payment-service' },
'/api/booking/**': { service: 'booking-service' },
},
},
})
import { defineConfig } from 'nitro'
import evlog from 'evlog/nitro/v3'
export default defineConfig({
modules: [
evlog({
env: { service: 'default-service' },
routes: {
'/api/auth/**': { service: 'auth-service' },
'/api/payment/**': { service: 'payment-service' },
},
})
],
})
import { defineNitroConfig } from 'nitropack/config'
import evlog from 'evlog/nitro'
export default defineNitroConfig({
modules: [
evlog({
env: { service: 'default-service' },
routes: {
'/api/auth/**': { service: 'auth-service' },
'/api/payment/**': { service: 'payment-service' },
},
})
],
})
You can also override the service name per handler using useLogger(event, 'service-name'). See Quick Start - Service Identification for details.
Sampling
At scale, logging everything can become expensive. evlog supports two sampling strategies:
Head Sampling (rates)
Random sampling based on log level, decided before the request completes:
export default defineNuxtConfig({
modules: ['evlog/nuxt'],
evlog: {
sampling: {
rates: {
info: 10, // Keep 10% of info logs
warn: 50, // Keep 50% of warning logs
debug: 5, // Keep 5% of debug logs
error: 100, // Always keep errors (default)
},
},
},
})
import { defineConfig } from 'nitro'
import evlog from 'evlog/nitro/v3'
export default defineConfig({
modules: [
evlog({
sampling: {
rates: { info: 10, warn: 50, debug: 5 },
},
})
],
})
import { defineNitroConfig } from 'nitropack/config'
import evlog from 'evlog/nitro'
export default defineNitroConfig({
modules: [
evlog({
sampling: {
rates: { info: 10, warn: 50, debug: 5 },
},
})
],
})
error: 100, error logs are never sampled out unless you explicitly set error: 0.Tail Sampling (keep)
Force-keep logs based on request outcome, evaluated after the request completes. Useful to always capture slow requests or critical paths even when head sampling would drop them:
// Works the same in Nuxt, Nitro v2, and Nitro v3
sampling: {
rates: { info: 10 },
keep: [
{ duration: 1000 }, // Always keep if duration >= 1000ms
{ status: 400 }, // Always keep if status >= 400
{ path: '/api/critical/**' }, // Always keep critical paths
],
}
Conditions use >= comparison and follow OR logic (any match = keep).
Custom Tail Sampling
For business-specific conditions (premium users, feature flags, etc.), use the evlog:emit:keep hook (Nuxt/Nitro) or the keep callback (Next.js):
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('evlog:emit:keep', (ctx) => {
const user = ctx.context.user as { premium?: boolean } | undefined
if (user?.premium) {
ctx.shouldKeep = true
}
})
})
import { createEvlog } from 'evlog/next'
export const { withEvlog, useLogger } = createEvlog({
service: 'my-app',
sampling: {
rates: { info: 10 },
keep: [{ status: 400 }, { duration: 1000 }],
},
keep: (ctx) => {
const user = ctx.context.user as { premium?: boolean } | undefined
if (user?.premium) ctx.shouldKeep = true
},
})
The callback receives a TailSamplingContext with status, duration, path, method, and the full accumulated context.
Log Draining
Send logs to external services like Axiom, Loki, or custom endpoints. The drain is called in fire-and-forget mode, meaning it never blocks the HTTP response.
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('evlog:drain', async (ctx) => {
await fetch('https://api.axiom.co/v1/datasets/logs/ingest', {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.AXIOM_TOKEN}` },
body: JSON.stringify([ctx.event]),
})
})
})
import { createEvlog } from 'evlog/next'
import { createAxiomDrain } from 'evlog/axiom'
import { createDrainPipeline } from 'evlog/pipeline'
const pipeline = createDrainPipeline({ batch: { size: 50 } })
export const { withEvlog, useLogger } = createEvlog({
service: 'my-app',
drain: pipeline(createAxiomDrain({
dataset: 'logs',
token: process.env.AXIOM_TOKEN!,
})),
})
The hook receives a DrainContext with:
event: The completeWideEvent(timestamp, level, service, and all accumulated context)request: Optional request metadata (method,path,requestId)headers: HTTP headers from the original request (useful for correlation with external services)
authorization, cookie, set-cookie, x-api-key, x-auth-token, proxy-authorization) are automatically filtered out and never passed to the drain hook.Using Headers for External Service Correlation
The headers field allows you to correlate logs with external services like PostHog, Sentry, or custom analytics:
export default defineNitroPlugin((nitroApp) => {
const posthog = usePostHog()
nitroApp.hooks.hook('evlog:drain', (ctx) => {
if (!posthog) return
const sessionId = ctx.headers?.['x-posthog-session-id']
const distinctId = ctx.headers?.['x-posthog-distinct-id']
if (!distinctId) return
posthog.capture({
distinctId,
event: 'server_log',
properties: {
...ctx.event,
$session_id: sessionId,
},
})
})
})
Event Enrichment
Enrich your wide events with derived context like user agent, geo data, request size, and trace context. Enrichers run after emit, before drain.
import {
createUserAgentEnricher,
createGeoEnricher,
createRequestSizeEnricher,
createTraceContextEnricher,
} from 'evlog/enrichers'
export default defineNitroPlugin((nitroApp) => {
const enrichers = [
createUserAgentEnricher(),
createGeoEnricher(),
createRequestSizeEnricher(),
createTraceContextEnricher(),
]
nitroApp.hooks.hook('evlog:enrich', (ctx) => {
for (const enricher of enrichers) enricher(ctx)
})
})
import { createEvlog } from 'evlog/next'
import { createUserAgentEnricher, createGeoEnricher } from 'evlog/enrichers'
const enrichers = [createUserAgentEnricher(), createGeoEnricher()]
export const { withEvlog, useLogger } = createEvlog({
service: 'my-app',
enrich: (ctx) => {
for (const enricher of enrichers) enricher(ctx)
ctx.event.deploymentId = process.env.VERCEL_DEPLOYMENT_ID
},
})
| Enricher | Event Field | Description |
|---|---|---|
createUserAgentEnricher() | userAgent | Browser, OS, device type from User-Agent header |
createGeoEnricher() | geo | Country, region, city from platform headers (Vercel, Cloudflare) |
createRequestSizeEnricher() | requestSize | Request/response payload sizes from Content-Length |
createTraceContextEnricher() | traceContext | W3C trace context (traceId, spanId) from traceparent header |
You can also write custom enrichers to add any derived context:
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('evlog:enrich', (ctx) => {
ctx.event.deploymentId = process.env.DEPLOYMENT_ID
})
})
See the Enrichers guide for full documentation.
Client Transport
Send browser logs to your server for centralized logging. When enabled, client-side log.info(), log.error(), etc. calls are automatically sent to the server.
export default defineNuxtConfig({
modules: ['evlog/nuxt'],
evlog: {
transport: {
enabled: true,
endpoint: '/api/_evlog/ingest', // default
},
},
})
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>
)
}
How it works
- Client calls
log.info({ action: 'click', button: 'submit' }) - Log is sent to
/api/_evlog/ingestvia POST - Server enriches with environment context (service, version, region, etc.)
evlog:drainhook is called withsource: 'client'- External services receive the log (Axiom, Loki, etc.)
service, environment, or version from the client.In your drain hook, you can identify client logs by the source: 'client' field:
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('evlog:drain', async (ctx) => {
if (ctx.event.source === 'client') {
console.log('[CLIENT]', ctx.event)
}
})
})
Client Identity
Attach user identity to all client logs with setIdentity(). Identity fields are automatically included in every log and transported to the server, where all drains (Axiom, PostHog, Sentry, etc.) receive them.
// After login
setIdentity({ userId: 'usr_123', orgId: 'org_456' })
log.info({ action: 'checkout' })
// -> { userId: 'usr_123', orgId: 'org_456', action: 'checkout', ... }
// After logout
clearIdentity()
import { setIdentity, clearIdentity, log } from 'evlog/next/client'
// After login
setIdentity({ userId: 'usr_123', orgId: 'org_456' })
log.info({ action: 'checkout' })
// -> { userId: 'usr_123', orgId: 'org_456', action: 'checkout', ... }
// After logout
clearIdentity()
In Nuxt, setIdentity and clearIdentity are auto-imported. In Next.js, import them from evlog/next/client.
Per-event fields override identity fields, so you can always pass explicit values:
setIdentity({ userId: 'usr_123' })
log.info({ userId: 'usr_admin_override' })
// -> { userId: 'usr_admin_override', ... }
Syncing identity with auth
Use a global route middleware to automatically sync identity with your auth state:
export default defineNuxtRouteMiddleware(() => {
const { user } = useAuth() // better-auth, supabase, clerk, etc.
if (user.value) {
setIdentity({ userId: user.value.id, email: user.value.email })
} else {
clearIdentity()
}
})
$production override to sample only in production while keeping full visibility in development:export default defineNuxtConfig({
modules: ['evlog/nuxt'],
evlog: {
env: { service: 'my-app' },
},
$production: {
evlog: {
sampling: {
rates: { info: 10, warn: 50, debug: 0 },
keep: [{ duration: 1000 }, { status: 400 }],
},
},
},
})
TypeScript Configuration
evlog ships with full TypeScript type definitions. No additional configuration is required.
Next Steps
- Quick Start - Learn the core concepts and start using evlog
- Next.js Example - Full Next.js App Router example with enrichers, pipeline, and drain
Introduction
A TypeScript logging library focused on wide events and structured error handling. Replace scattered logs with one comprehensive event per request.
Quick Start
Get up and running with evlog in minutes. Learn useLogger, createError, parseError, and the log API for wide events and structured errors.