- 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 (e.g. enrich on every event + side-effect on drain + keep decision on tail sampling, all reading the same shared state).
When the extension only does one thing, prefer the single-purpose enricherPlugin() / drainPlugin() wrappers. Reach for definePlugin when several hooks share state.
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.
A multi-hook example
Plugins shine when several concerns share state. Here, a single request-metrics plugin tracks per-request timing through setup, onRequestStart, and drain:
import { definePlugin } from 'evlog/toolkit'
export const requestMetricsPlugin = definePlugin({
name: 'request-metrics',
setup({ env }) {
statsd.init({ service: env.service })
},
enrich({ event }) {
event.tier = event.duration && event.duration > 1000 ? 'slow' : 'fast'
},
drain({ event }) {
statsd.timing('http.request', event.duration as number, { path: event.path as string })
},
onRequestStart({ logger, request }) {
logger.set({ trace: { startedAt: Date.now() } })
},
onRequestFinish({ event, durationMs }) {
if (event && (event.level === 'error' || durationMs > 5000)) {
// alert / forward / etc.
}
},
})
Sugar plugins
For single-hook extensions, the toolkit offers drainPlugin() and enricherPlugin() wrappers:
import { drainPlugin, enricherPlugin } from 'evlog/toolkit'
const drainOnly = drainPlugin('axiom', createAxiomDrain())
const enricherOnly = enricherPlugin('user-agent', createUserAgentEnricher())
These are equivalent to a definePlugin({ name, drain | enrich }) shape but read more clearly when intent is obvious.
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).
Next steps
- Custom Enrichers — single-hook enrichment
- Custom Drains — single-destination output
- Tail Sampling — outcome-aware keep decisions
- Identity Headers — tag every drain request
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.
Custom enrichers
Write custom enrichers to add derived context to your wide events. Add deployment metadata, tenant IDs, feature flags, geo, or any computed data — the toolkit handles error isolation, undefined skipping, and the merge step.