Extend
definePlugin is the canonical extension point for evlog — opt into any subset of setup, onRequestStart, enrich, keep, drain, onRequestFinish, onClientLog, extendLogger from a single cohesive object.
request lifecycle
IN PIPELINE
  1. filterroute allowed

    /api/checkout matches include

  2. create loggerrequestId · startTime

    POST · /api/checkout · req_8a2c

  3. handlerlog.set() x3

    context accumulates

  4. tail sampleevlog:emit:keep

    no rule matched

  5. head sampleinfo: 100% kept

    random < rate

  6. emitWideEvent built

    logger sealed · ready to ship

  7. enrichevlog:enrich

    + userAgent · + geo

  8. drainevlog:drain

    → axiom · → fs

context
$POST/api/checkout
log.set(user:{ id: 1, plan: "pro" })
log.set(cart:{ items: 3, total: 9999 })
log.set(payment:{ method: "card", status: "ok" })
wide event
{
  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] })

Hooks

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

Next steps