Install evlog
evlog supports multiple environments: Nuxt, 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.
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 |
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 Hook
For business-specific conditions (premium users, feature flags, etc.), use the evlog:emit:keep hook:
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
}
})
})
The hook 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 using the evlog:drain hook. The hook 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]),
})
})
})
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)
})
})
| 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 (Nuxt only)
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 via the /api/_evlog/ingest endpoint.
export default defineNuxtConfig({
modules: ['evlog/nuxt'],
evlog: {
transport: {
enabled: true,
endpoint: '/api/_evlog/ingest', // default
},
},
})
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()
Both setIdentity and clearIdentity are auto-imported by the Nuxt module.
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
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.