Pipeline

Plugins

definePlugin is the canonical extension contract for evlog. One plugin can opt into any subset of lifecycle hooks — enrich, drain, tail sampling, request lifecycle, client log observation.

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-id from 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's extendLogger

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

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 response, includes the emitted event (or null if sampled out)Per-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.

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 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).

Going further