Shared packages

Enrichers as packages

Package a custom enricher (geo, tenant, deploy id…) as an npm library so every app in your org gets the same derived context.

Custom enrichers (Custom enrichers) are pure functions over the event context — perfect material for an npm package. Publish once, install everywhere.

Why package this?

  • Same context across services — every app pulls tenant_id from the same header, formatted the same way
  • Versioned semantics — bumping the package is the audit trail of context-shape changes
  • Single bug fix point — fix the enricher once, every consumer gets it

Scaffold

src/index.ts
import { defineEnricher } from 'evlog/toolkit'

export interface TenantEnricherOptions {
  /** Header name carrying the tenant id. Default: `x-tenant-id`. */
  header?: string
  /** Whether to fail-soft when the header is absent. Default: true. */
  optional?: boolean
}

export function createTenantEnricher(options: TenantEnricherOptions = {}) {
  const header = options.header ?? 'x-tenant-id'
  const optional = options.optional ?? true

  return defineEnricher({
    name: 'tenant',
    enrich: ({ event, headers }) => {
      const tenantId = headers?.[header]
      if (!tenantId) {
        if (!optional) {
          console.warn('[evlog/tenant] missing header', header)
        }
        return
      }
      event.tenant = { id: tenantId }
    },
  })
}

Package layout

package.json
{
  "name": "@my-org/evlog-tenant-enricher",
  "version": "0.1.0",
  "type": "module",
  "main": "./dist/index.mjs",
  "types": "./dist/index.d.mts",
  "exports": {
    ".": {
      "types": "./dist/index.d.mts",
      "import": "./dist/index.mjs"
    }
  },
  "files": ["dist"],
  "peerDependencies": {
    "evlog": "^2"
  }
}

If your enricher writes a structured field, augment the wide event type so consumers get autocomplete on it:

src/index.ts
declare module 'evlog' {
  interface BaseWideEvent {
    tenant?: { id: string }
  }
}

The augmentation flows transitively to consumers via the published .d.mts — they don't have to redeclare it.

Consuming it

import { createTenantEnricher } from '@my-org/evlog-tenant-enricher'

nitroApp.hooks.hook('evlog:enrich', createTenantEnricher())

That's it — every wide event now carries tenant.id when the request had the header.

Publishing checklist

  • Enricher is a pure function of (event, headers, request) — no side effects
  • Failing soft when the header is missing (don't crash production over a missing tenant id)
  • declare module 'evlog' augmenting BaseWideEvent for type-level discoverability
  • Test with vitest using a fake EnrichContext
  • README explaining the header / config and the field shape

Real examples to ship

  • @my-org/evlog-tenant-enricher — extracts x-tenant-id from request headers
  • @my-org/evlog-deploy-info — adds commitHash / region / version from env at boot
  • @my-org/evlog-feature-flags — attaches the resolved feature flags for the request
  • @my-org/evlog-trace-context — pulls traceparent and exposes parsed trace_id / span_id
  • @my-org/evlog-experiment-cohort — adds experiment.cohort based on user id

For multi-hook plugins (e.g. enrich + tail-sample + side-effect on drain), package as a plugin instead — same scaffolding, swap defineEnricher for definePlugin.