Extend

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.
enrich pipeline·idle
UserAgent
+3 fields
Geo
+3 fields
RequestSize
+2 fields
TraceContext
+2 fields
wide event·4 fields
{
method:"POST",
path:"/api/checkout",
status:200,
duration:234,
userAgent.browser:"chrome 142",+UserAgent
userAgent.os:"macOS 26",+UserAgent
userAgent.device:"desktop",+UserAgent
geo.country:"FR",+Geo
geo.city:"Paris",+Geo
geo.region:"Île-de-France",+Geo
request.size:1248,+RequestSize
response.size:8412,+RequestSize
trace.traceId:"4bf92f3577b34da6a3ce…",+TraceContext
trace.spanId:"00f067aa0ba902b7",+TraceContext
}
base fields4
enriched fields+0
app code touched0 lines

An enricher runs on every emitted event before it reaches drains. It's the right tool when you want a field on every event without touching every call site — geo, user agent, trace context, deploy id, tenant id, feature flags, performance tier.

Use defineEnricher from evlog/toolkit — provide a single compute() function returning the value you want to merge into the event, and the toolkit handles error isolation, undefined skipping, and the merge step. Every built-in enricher is built on this same factory.

Write a custom evlog enricher

Basic example

Add deployment metadata to every event. The enricher is the same function everywhere — only the wiring step differs per framework.

// server/plugins/evlog-enrich.ts
export default defineNitroPlugin((nitroApp) => {
  nitroApp.hooks.hook('evlog:enrich', (ctx) => {
    ctx.event.deploymentId = process.env.DEPLOYMENT_ID
    ctx.event.deployedBy = process.env.DEPLOYED_BY
  })
})

EnrichContext

The evlog:enrich hook receives an EnrichContext:

enrich-context.ts
interface EnrichContext {
  /** The emitted wide event (mutable) */
  event: WideEvent
  /** Request metadata */
  request?: {
    method?: string
    path?: string
    requestId?: string
  }
  /** Safe HTTP request headers (sensitive headers filtered out) */
  headers?: Record<string, string>
  /** Response metadata */
  response?: {
    status?: number
    headers?: Record<string, string>
  }
}
Security: Sensitive headers (authorization, cookie, x-api-key, etc.) are automatically filtered and never passed to enrichers.

Every built-in enricher uses this same factory. Provide compute() and you're done:

server/utils/enrichers.ts
import { defineEnricher, getHeader, type EnricherOptions } from 'evlog/toolkit'

interface TenantInfo {
  id: string
  org?: string
}

export function createTenantEnricher(options: EnricherOptions & { headerName?: string } = {}) {
  const headerName = options.headerName ?? 'x-tenant-id'

  return defineEnricher<TenantInfo>({
    name: 'tenant',
    field: 'tenant',
    compute: ({ headers }) => {
      const id = getHeader(headers, headerName)
      if (!id) return undefined
      return { id }
    },
  }, options)
}

defineEnricher automatically:

  • skips when compute() returns undefined
  • merges the result into ctx.event[field] via mergeEventField (respecting options.overwrite)
  • catches errors and logs them as [evlog/<name>] instead of breaking the pipeline

Wire it like any other enricher:

// server/plugins/evlog-enrich.ts
import { createTenantEnricher } from '~/server/utils/enrichers'

export default defineNitroPlugin((nitroApp) => {
  const enrichTenant = createTenantEnricher({ headerName: 'x-org-id' })
  nitroApp.hooks.hook('evlog:enrich', enrichTenant)
})

Combining with built-in enrichers

Custom and built-in enrichers compose freely — they're all just (ctx: EnrichContext) => void functions. Use composeEnrichers from evlog/toolkit to combine them into a single callable:

enrichers.ts
import { composeEnrichers, defineEnricher } from 'evlog/toolkit'
import { createDefaultEnrichers } from 'evlog/enrichers'

const region = defineEnricher({
  name: 'region',
  field: 'region',
  compute: () => process.env.FLY_REGION ?? process.env.AWS_REGION,
})

export const enrich = composeEnrichers([
  createDefaultEnrichers(), // userAgent + geo + requestSize + traceContext
  region,
])

More examples

Each example below is a plain defineEnricher call — wire it the same way as the basic example, regardless of framework.

Feature flags

enricher-feature-flags.ts
import { defineEnricher } from 'evlog/toolkit'

export const featureFlags = defineEnricher({
  name: 'feature-flags',
  field: 'featureFlags',
  compute: () => ({
    newCheckout: isEnabled('new-checkout'),
    betaApi: isEnabled('beta-api'),
  }),
})

Response time classification

enricher-perf-tier.ts
import { defineEnricher } from 'evlog/toolkit'

export const performanceTier = defineEnricher<string>({
  name: 'performance-tier',
  field: 'performanceTier',
  compute: ({ event }) => {
    const duration = event.duration as number | undefined
    if (duration === undefined) return undefined
    if (duration < 100) return 'fast'
    if (duration < 500) return 'normal'
    if (duration < 2000) return 'slow'
    return 'critical'
  },
})

When to reach for a plugin instead

If your feature mixes enrichment with other hooks (e.g. enrich + tail-sample + side-effect on drain), use a plugin instead — one cohesive object covering several lifecycle points.

Next steps

  • Built-in Enrichers — User Agent, Geo, Request Size, Trace Context
  • Plugins — multi-hook extensions (drain + enrich + keep in one object)
  • Adapters — send enriched events to external services