Plugins
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.
What is this and when do I want it?
Use a plugin when your feature touches more than one point in the pipeline:
- "On every request, copy
x-tenant-idfrom headers into a context field, AND tag the event with the tenant's plan, AND alert when an error event has plan=enterprise" → one plugin - "Add Sentry breadcrumbs every time we drain a wide event with level=error" → one plugin
- "Decorate the request logger with a custom
audit.refund(...)method" → one plugin'sextendLogger
Single-purpose extensions (just an enricher, just a drain) can use the dedicated wrappers enricherPlugin() / drainPlugin() instead.
Minimal example
import { definePlugin, getGlobalPluginRunner } from 'evlog/toolkit'
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 on the global runner (typically once at startup):
getGlobalPluginRunner().add(tenantPlugin)
Full API
| 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, includes the emitted event (or null if sampled out) | 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.
export interface EvlogPlugin {
name: string
setup?: (ctx: PluginSetupContext) => void | Promise<void>
enrich?: (ctx: EnrichContext) => void | Promise<void>
drain?: (ctx: DrainContext) => void | Promise<void>
keep?: (ctx: TailSamplingContext) => void | Promise<void>
onRequestStart?: (ctx: RequestLifecycleContext) => void
onRequestFinish?: (ctx: RequestFinishContext) => void
onClientLog?: (ctx: ClientLogContext) => void
extendLogger?: (logger: RequestLogger) => void
}
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).
Going further
- Single-purpose enricher → Custom enrichers
- Tail-only logic → Tail sampling
- Drain-only side effect → Custom drains
- Package your plugin for reuse → Enrichers as packages (same scaffolding pattern)
Overview
The pipeline is everything that happens between an event being emitted and a drain receiving it — plugins, enrichers, sampling, redaction, fork.
Custom enrichers
defineEnricher derives context from request headers, env, or anything else, and adds it to every wide event before drain — without touching call sites.