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
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
defineEnricher derives context from request headers, env, or anything else, and adds it to every wide event before drain — without touching call sites.