Core Concepts

Request Lifecycle

Understand the full lifecycle of a request in evlog — from creation to drain. Every step from logger creation, context accumulation, sampling, enrichment, to external delivery.

Every request that passes through evlog follows the same pipeline. Understanding this pipeline helps you know exactly when your hooks fire, where context is added, and how events reach your observability platform.

   Request In
  ┌──────────┐     Route excluded?
  │  Filter  │────── yes ──▶ skip (no logging)
  └──────────┘
       │ no
  ┌──────────────────┐
  │  Create Logger   │  requestId, method, path, startTime
  └──────────────────┘
  ┌──────────────────┐
  │  Handler runs    │  log.set() accumulates context
  │                  │  log.error() records errors
  └──────────────────┘
  ┌──────────────────┐
  │  Request ends    │  status + duration computed
  └──────────────────┘
  ┌──────────────────┐
  │  Tail Sampling   │  evlog:emit:keep hook
  │  (keep?)         │  force-keep based on outcome
  └──────────────────┘
  ┌──────────────────┐
  │  Head Sampling   │  random % per level
  │  (sample?)       │  skipped if tail said "keep"
  └──────────────────┘
       │ sampled out? ──▶ discard (no output)
  ┌──────────────────┐
  │  Emit            │  WideEvent built + console output
  └──────────────────┘
  ┌──────────────────┐
  │  Enrich          │  evlog:enrich hook
  │                  │  user-agent, geo, trace, custom
  └──────────────────┘
  ┌──────────────────┐
  │  Drain           │  evlog:drain hook
  │                  │  Axiom, OTLP, Sentry, custom
  └──────────────────┘
   Done

Step by Step

1. Route Filtering

When a request arrives, evlog checks whether the path matches the configured include / exclude patterns. If the route is excluded, no logger is created and the request proceeds without any logging overhead.

nuxt.config.ts
export default defineNuxtConfig({
  modules: ['evlog/nuxt'],
  evlog: {
    include: ['/api/**'],
  },
})

2. Logger Creation

For matched routes, evlog creates a RequestLogger and attaches it to the request context. The logger is pre-populated with:

FieldSource
methodHTTP method (GET, POST, ...)
pathRequest path
requestIdAuto-generated UUID (or cf-ray on Cloudflare)
startTimeDate.now() for duration calculation

The logger is stored on the event context so it's accessible via useLogger(event).

3. Context Accumulation

During the handler, you call log.set() to attach context. Each call deep-merges into the existing context — you can call it as many times as needed:

server/api/checkout.post.ts
const log = useLogger(event)

const user = await getUser(event)
log.set({ user: { id: user.id, plan: user.plan } })

const cart = await getCart(user.id)
log.set({ cart: { items: cart.items.length, total: cart.total } })

If an error is thrown, evlog's error hook captures it automatically and records it on the logger with the status code.

4. Request End

When the response is sent (or an error is thrown), evlog computes:

  • Status code from the response (or from the error's status / statusCode)
  • Duration from Date.now() - startTime
  • Levelerror if an error was recorded, warn if status >= 400, otherwise info

If an error triggered the emit, the request is marked as already emitted to prevent double-emission in the response hook.

5. Tail Sampling (evlog:emit:keep)

Before the event is sampled, evlog evaluates tail sampling rules. These run after the request completes, so they can inspect the outcome:

nuxt.config.ts
evlog: {
  sampling: {
    keep: [
      { duration: 1000 },          // slow requests
      { status: 400 },             // client/server errors
      { path: '/api/critical/**' }, // critical paths
    ],
  },
}

The evlog:emit:keep hook also fires, letting you force-keep based on custom business logic:

server/plugins/evlog-custom.ts
export default defineNitroPlugin((nitroApp) => {
  nitroApp.hooks.hook('evlog:emit:keep', (ctx) => {
    if (ctx.context.user?.premium) {
      ctx.shouldKeep = true
    }
  })
})

If any rule or hook sets shouldKeep = true, the event bypasses head sampling entirely.

6. Head Sampling

If the event wasn't force-kept by tail sampling, head sampling applies. This is a random coin flip per log level:

nuxt.config.ts
evlog: {
  sampling: {
    rates: { info: 10, warn: 50, debug: 0 },
  },
}
  • info: 10 — keep 10% of info-level events
  • warn: 50 — keep 50% of warnings
  • error defaults to 100% (never sampled out)

If the event is sampled out, processing stops — no console output, no enrichment, no drain.

7. Emit

The WideEvent object is built from the accumulated context:

{
  "timestamp": "2025-01-15T10:30:00.000Z",
  "level": "info",
  "service": "my-app",
  "method": "POST",
  "path": "/api/checkout",
  "requestId": "abc-123",
  "duration": 234,
  "status": 200,
  "user": { "id": 1, "plan": "pro" },
  "cart": { "items": 3, "total": 9999 }
}

The event is printed to the console — pretty-formatted in development, JSON in production.

8. Enrich (evlog:enrich)

After emission, enrichers add derived context to the event. Built-in enrichers extract data from request headers:

EnricherAddsSource
User AgentuserAgent (browser, OS, device)User-Agent header
Geogeo (country, region, city)Platform headers (Vercel, Cloudflare)
Request SizerequestSize (request/response bytes)Content-Length headers
Trace ContexttraceContext (traceId, spanId)traceparent header
server/plugins/evlog-enrich.ts
import { createUserAgentEnricher, createGeoEnricher } from 'evlog/enrichers'

export default defineNitroPlugin((nitroApp) => {
  const enrichers = [createUserAgentEnricher(), createGeoEnricher()]

  nitroApp.hooks.hook('evlog:enrich', (ctx) => {
    for (const enricher of enrichers) enricher(ctx)
  })
})

Enrichers receive the full EnrichContext with the mutable event, request metadata, safe headers, and response info.

9. Drain (evlog:drain)

The final step sends the enriched event to your observability platform. The evlog:drain hook receives a DrainContext with the complete event:

server/plugins/evlog-drain.ts
import { createAxiomDrain } from 'evlog/axiom'

export default defineNitroPlugin((nitroApp) => {
  nitroApp.hooks.hook('evlog:drain', createAxiomDrain())
})

On platforms with waitUntil (Cloudflare Workers, Vercel Edge), the drain runs after the response is sent to avoid adding latency. On traditional servers, the drain is awaited to prevent losing events in serverless cold shutdowns.

Hook Execution Order

OrderHookWhenPurpose
1evlog:emit:keepAfter request ends, before samplingForce-keep events based on outcome
2evlog:enrichAfter emit, before drainAdd derived context to the event
3evlog:drainAfter enrichmentSend event to external services

Error vs Success Path

Both paths converge at the same emit/enrich/drain pipeline. The only difference is when the emit is triggered:

SuccessError
TriggerafterResponse / response hookerror hook
Levelinfo (or warn if status >= 400)error
StatusFrom responseFrom error's status field (default 500)
Error contextNoneerror field with message, stack, why, fix
Double-emit guardChecks _evlogEmitted flagSets _evlogEmitted = true

Next Steps