Getting Started

Install evlog

Quick Start
Install evlog in your Nuxt, Nitro, Cloudflare Workers, or standalone TypeScript project. Configure sampling, log draining, and client transport.

evlog supports multiple environments: Nuxt, Nitro, Cloudflare Workers, and standalone TypeScript.

Install

pnpm add evlog

Setup

Nuxt

Add evlog to your Nuxt config:

nuxt.config.ts
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:

nitro.config.ts
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:

routes/api/hello.ts
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:

nitro.config.ts
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:

routes/api/hello.ts
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 }
})
Note: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.

src/index.ts
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:

wrangler.toml
[observability.logs]
invocation_logs = false

Notes:

  • requestId defaults to cf-ray when available
  • request.cf is included (colo, country, asn) unless disabled
  • Use headerAllowlist to avoid logging sensitive headers
See the full workers example for a complete working project.

Hono

Use the standalone API to create one wide event per request from a Hono middleware.

src/index.ts
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 })
See the full hono example for a complete working project.

Standalone TypeScript

Use evlog in scripts, CLI tools, workers, or any TypeScript project:

scripts/sync-job.ts
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
In standalone mode, you must call 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.

scripts/sync-job.ts
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()
See the full bun-script example for a complete working script.

Configuration Options

These options apply to Nuxt, Nitro v2, and Nitro v3. The evlog module accepts the same options across all environments.

OptionTypeDefaultDescription
enabledbooleantrueGlobally enable/disable all logging. When false, all operations become no-ops
env.servicestring'app'Service name shown in logs
env.environmentstringAuto-detectedEnvironment name
includestring[]undefinedRoute patterns to log. Supports glob (/api/**). If not set, all routes are logged
excludestring[]undefinedRoute patterns to exclude from logging. Supports glob. Exclusions take precedence over inclusions
routesRecord<string, RouteConfig>undefinedRoute-specific service configuration
prettybooleantrue in devPretty print with tree formatting
sampling.ratesobjectundefinedHead sampling rates per log level (0-100%). See Sampling
sampling.keeparrayundefinedTail sampling conditions to force-keep logs. See Sampling
Nuxt also supports transport.enabled and transport.endpoint for Client Transport.

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',
    ],
  },
})
Exclusions take precedence. If a path matches both 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' },
    },
  },
})

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)
      },
    },
  },
})
Errors are always logged by default. Even if you don't specify 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:

server/plugins/evlog-sampling.ts
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.

server/plugins/evlog-drain.ts
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 complete WideEvent (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)
Security: Sensitive headers (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:

server/plugins/evlog-posthog.ts
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.

server/plugins/evlog-enrich.ts
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)
  })
})
EnricherEvent FieldDescription
createUserAgentEnricher()userAgentBrowser, OS, device type from User-Agent header
createGeoEnricher()geoCountry, region, city from platform headers (Vercel, Cloudflare)
createRequestSizeEnricher()requestSizeRequest/response payload sizes from Content-Length
createTraceContextEnricher()traceContextW3C trace context (traceId, spanId) from traceparent header

You can also write custom enrichers to add any derived context:

server/plugins/evlog-enrich.ts
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.

nuxt.config.ts
export default defineNuxtConfig({
  modules: ['evlog/nuxt'],
  evlog: {
    transport: {
      enabled: true,
      endpoint: '/api/_evlog/ingest',  // default
    },
  },
})

How it works

  1. Client calls log.info({ action: 'click', button: 'submit' })
  2. Log is sent to /api/_evlog/ingest via POST
  3. Server enriches with environment context (service, version, region, etc.)
  4. evlog:drain hook is called with source: 'client'
  5. External services receive the log (Axiom, Loki, etc.)
Client logs are automatically enriched with the server's environment context. You don't need to send service, environment, or version from the client.

In your drain hook, you can identify client logs by the source: 'client' field:

server/plugins/evlog-drain.ts
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:

middleware/identity.global.ts
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()
  }
})
Tip: Use Nuxt's $production override to sample only in production while keeping full visibility in development:
nuxt.config.ts
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.

evlog requires TypeScript 5.0 or higher for optimal type inference.

Next Steps

  • Quick Start - Learn the core concepts and start using evlog
Copyright © 2026