Build on top

Pipeline extension

Hook into the evlog pipeline — plugins for cross-cutting concerns, enrichers for derived context, tail sampling for outcome-aware keep decisions, identity headers on every drain request.

evlog runs a pipeline on every request : request lifecycle → enrich → tail sample → drain. This page covers every way you can hook into it.

You want to…Use
React to lifecycle events from a single cohesive objectPlugins
Add a derived field on every event automaticallyCustom enrichers
Decide post-hoc whether to keep an eventTail sampling
Identify your evlog traffic on the receiver sideIdentity headers

Plugins

request lifecycle
IN PIPELINE
  1. filterroute allowed

    /api/checkout matches include

  2. create loggerrequestId · startTime

    POST · /api/checkout · req_8a2c

  3. handlerlog.set() x3

    context accumulates

  4. tail sampleevlog:emit:keep

    no rule matched

  5. head sampleinfo: 100% kept

    random < rate

  6. emitWideEvent built

    logger sealed · ready to ship

  7. enrichevlog:enrich

    + userAgent · + geo

  8. drainevlog:drain

    → axiom · → fs

context
$POST/api/checkout
log.set(user:{ id: 1, plan: "pro" })
log.set(cart:{ items: 3, total: 9999 })
log.set(payment:{ method: "card", status: "ok" })
wide event
{
  level:    "info",
  method:   "POST",
  path:     "/api/checkout",
  duration: 234,
  status:   200,
  user:     { id: 1, plan: "pro" },
  cart:     { items: 3, total: 9999 },
  payment:  { method: "card", status: "ok" },
  userAgent: { browser: "chrome" },
  geo:      { country: "FR" }
}

definePlugin() is the canonical extension point for evlog. Drains and enrichers are special cases of plugins, but a single plugin can opt into multiple hooks at once — the right shape for any non-trivial extension that mixes several concerns.

Build a multi-hook evlog plugin

Minimal example

import { definePlugin } from 'evlog'

export const tenantPlugin = definePlugin({
  name: 'tenant',
  onRequestStart({ logger, headers }) {
    const tenantId = headers?.['x-tenant-id']
    if (tenantId) logger.set({ tenant: { id: tenantId } })
  },
  enrich({ event }) {
    event.region = process.env.REGION
  },
})

Register the plugin where you bootstrap evlog. The shape depends on your runtime:

import { initLogger } from 'evlog'
import { tenantPlugin } from './plugins/tenant'

initLogger({ plugins: [tenantPlugin] })

Hooks

HookWhenUse it for
setup(ctx)Once when registeredRead env, set up shared state
onRequestStart(ctx)Each request, before any handler runsPull values from headers into logger
enrich(ctx)Every event, before drainAdd derived fields (geo, deploy id…)
keep(ctx)Tail sampling decisionForce-keep based on outcome (status >= 400, duration > 500, …)
drain(ctx)Every emitted eventSide-effect: alert, mirror to a queue, etc.
onRequestFinish(ctx)After responsePer-request post-processing
onClientLog(ctx)Browser-submitted event hits the ingest endpointObserve / reject client traffic
extendLogger(logger)Each requestAdd custom methods (e.g. logger.audit.refund())

Every hook is optional. A plugin can implement any subset. The full type lives in packages/evlog/src/shared/plugin.ts.

Common pitfalls

  • Don't throw from a hook. The plugin runner catches and logs errors with the plugin name, but a thrown error from enrich won't propagate the event downstream. Keep hooks defensive.
  • drain runs for every event — not just per-request. If you only care about per-request lifecycle, use onRequestFinish instead.
  • extendLogger mutates the logger object — augment RequestLogger in a .d.ts so useLogger(event) exposes the new methods to TypeScript. See typed fields.
  • Plugins are de-duplicated by name. Re-registering with the same name replaces the previous version (last registration wins).

Custom enrichers

enrich pipeline·idle
UserAgent
+3 fields
Geo
+3 fields
RequestSize
+2 fields
TraceContext
+2 fields
wide event·4 fields
{
method:"POST",
path:"/api/checkout",
status:200,
duration:234,
userAgent.browser:"chrome 142",+UserAgent
userAgent.os:"macOS 26",+UserAgent
userAgent.device:"desktop",+UserAgent
geo.country:"FR",+Geo
geo.city:"Paris",+Geo
geo.region:"Île-de-France",+Geo
request.size:1248,+RequestSize
response.size:8412,+RequestSize
trace.traceId:"4bf92f3577b34da6a3ce…",+TraceContext
trace.spanId:"00f067aa0ba902b7",+TraceContext
}
base fields4
enriched fields+0
app code touched0 lines

An enricher runs on every emitted event before it reaches drains. It's the right tool when you want a field on every event without touching every call site — geo, user agent, trace context, deploy id, tenant id…

Add a custom evlog enricher

The full reference — defineEnricher() API, async enrichers, combining built-in + custom, framework wiring — lives at Custom enrichers.

When to reach for a plugin instead

If your feature mixes enrichment with other hooks (e.g. enrich + tail-sample + side-effect on drain), use a plugin instead — one cohesive object covering several lifecycle points.

Tail sampling

sampling decision· tail → head
tail rules: status ≥ 400 duration ≥ 1000 path: /api/payments/**
#1POST/api/users·200·45ms
dropped
#2POST/api/users·500·45ms
kept
#3GET/api/products·200·2300ms
kept
#4POST/api/payments/charge·200·120ms
kept
#5POST/api/checkout·200·120ms
kept
#6GET/api/health·200·12ms
dropped
tail-kept0 / 0
head-kept0 / 0
dropped0

Tail sampling is a decision made after the request runs, with full knowledge of its outcome (status, duration, errors, custom flags). It's how you keep all errors and slow requests while throwing away the bulk of healthy traffic — the opposite of head sampling, which decides up front before knowing what happens.

Configure tail sampling on evlog

The full theory and config reference — built-in keep rules, custom predicates via evlog:emit:keep, combining head + tail sampling — lives at Sampling.

Custom keep hook

The built-in declarative keep rules cover the typical cases. Drop to a custom hook when you need:

  • Conditional logic on more than one field (e.g. "keep if status >= 500 AND user.plan === 'enterprise'")
  • Keep based on a derived value (e.g. "keep if event.audit?.context.actor.role === 'admin'")
  • Stateful decisions (rare; needs care since sampling runs in the hot path)
nitroApp.hooks.hook('evlog:emit:keep', (ctx) => {
  if (ctx.context.user?.plan === 'enterprise' && ctx.status >= 500) {
    ctx.shouldKeep = true
  }
})

Or as a plugin (preferred for non-trivial logic):

import { definePlugin } from 'evlog/toolkit'

definePlugin({
  name: 'keep-enterprise-errors',
  keep(ctx) {
    if (ctx.context.user?.plan === 'enterprise' && ctx.status >= 500) {
      ctx.shouldKeep = true
    }
  },
})

Identity headers

Every drain request sent by evlog is tagged with two identity headers so receivers can identify the traffic:

HeaderValue
User-Agentevlog/<version> (Node / server runtimes only — browsers strip this header)
X-Evlog-SourceThe adapter name (axiom, datadog, otlp, posthog, sentry, better-stack, client, …)

The browser-side evlog/http drain (used by the client transport) sets X-Evlog-Source: client instead, since browsers cannot override User-Agent.

Why

  • Quickly distinguish evlog traffic from other clients in the receiving system's logs
  • Track adapter usage / version drift centrally
  • Identify the source when debugging a specific drain

Reading the version

Both constants are exported from evlog/toolkit:

import { EVLOG_USER_AGENT, EVLOG_VERSION } from 'evlog/toolkit'

console.log(EVLOG_VERSION)    // → "2.16.0"
console.log(EVLOG_USER_AGENT) // → "evlog/2.16.0"

Custom drains

When you build a drain on top of httpPost from evlog/toolkit, identity headers are injected automatically. To override or suppress them:

import { httpPost } from 'evlog/toolkit'

await httpPost({
  url: 'https://my-platform.example.com/ingest',
  headers: { 'Content-Type': 'application/json' },
  body: '[]',
  timeout: 5000,
  label: 'my-platform',
  source: 'my-platform',           // sent as X-Evlog-Source
  userAgent: 'my-fork/1.0',        // overrides the default User-Agent
  // userAgent: false,             // suppress the header entirely
})

Adapters built with defineHttpDrain() automatically pass the drain name as source.