Pipeline extension
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 object | Plugins |
| Add a derived field on every event automatically | Custom enrichers |
| Decide post-hoc whether to keep an event | Tail sampling |
| Identify your evlog traffic on the receiver side | Identity headers |
Plugins
- filterroute allowed
/api/checkout matches include
- create loggerrequestId · startTime
POST · /api/checkout · req_8a2c
- handlerlog.set() x3
context accumulates
- tail sampleevlog:emit:keep
no rule matched
- head sampleinfo: 100% kept
random < rate
- emitWideEvent built
logger sealed · ready to ship
- enrichevlog:enrich
+ userAgent · + geo
- drainevlog:drain
→ axiom · → fs
{
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] })
import { evlogMiddleware } from 'evlog/<framework>'
import { tenantPlugin } from './plugins/tenant'
app.use(evlogMiddleware({ plugins: [tenantPlugin] }))
// Register the hooks you actually use directly:
nitroApp.hooks.hook('evlog:enrich', tenantPlugin.enrich!)
nitroApp.hooks.hook('evlog:request:start', tenantPlugin.onRequestStart!)
Hooks
| Hook | When | Use it for |
|---|---|---|
setup(ctx) | Once when registered | Read env, set up shared state |
onRequestStart(ctx) | Each request, before any handler runs | Pull values from headers into logger |
enrich(ctx) | Every event, before drain | Add derived fields (geo, deploy id…) |
keep(ctx) | Tail sampling decision | Force-keep based on outcome (status >= 400, duration > 500, …) |
drain(ctx) | Every emitted event | Side-effect: alert, mirror to a queue, etc. |
onRequestFinish(ctx) | After response | Per-request post-processing |
onClientLog(ctx) | Browser-submitted event hits the ingest endpoint | Observe / reject client traffic |
extendLogger(logger) | Each request | Add 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
enrichwon't propagate the event downstream. Keep hooks defensive. drainruns for every event — not just per-request. If you only care about per-request lifecycle, useonRequestFinishinstead.extendLoggermutates the logger object — augmentRequestLoggerin a.d.tssouseLogger(event)exposes the new methods to TypeScript. See typed fields.- Plugins are de-duplicated by
name. Re-registering with the samenamereplaces the previous version (last registration wins).
Custom enrichers
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
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 >= 500ANDuser.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:
| Header | Value |
|---|---|
User-Agent | evlog/<version> (Node / server runtimes only — browsers strip this header) |
X-Evlog-Source | The 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.
Recipes
Concrete copy-paste recipes — build your own minimal devtool, pipe to curl + jq, replay history then go live, and aggregate on the consumer side.
Sinks
Build, batch, and fan out drains — write a custom drain for any backend, wrap it in createDrainPipeline for batch + retry, and compose multiple destinations through one pipeline.