Shared packages

Drains as packages

Package a custom drain as a reusable npm library — `@my-org/evlog-internal-loki`, `@my-org/evlog-pubsub`, etc. Same scaffolding pattern as catalogs.

A custom drain (Custom drains) is plain TypeScript — once it works in one project, you can publish it as an npm package and reuse it everywhere. This page shows the minimum scaffolding.

Why package this?

  • Single source of truth for a destination shared across many services (your internal Loki, your audit ingest API)
  • Tested in isolation with vitest, then versioned and bumped per release
  • Type-safe configurationtsdown emits .d.mts so consumers get autocomplete on options

Scaffold

src/index.ts
import { defineHttpDrain } from 'evlog/toolkit'
import { resolveAdapterConfig, type ConfigField } from 'evlog/toolkit'

export interface InternalLokiConfig {
  url: string
  token: string
  timeout?: number
  retries?: number
}

const FIELDS: ConfigField<InternalLokiConfig>[] = [
  { key: 'url', env: ['LOKI_URL'] },
  { key: 'token', env: ['LOKI_TOKEN'] },
  { key: 'timeout' },
  { key: 'retries' },
]

export function createInternalLokiDrain(overrides?: Partial<InternalLokiConfig>) {
  return defineHttpDrain<InternalLokiConfig>({
    name: 'internal-loki',
    resolve: async () => {
      const cfg = await resolveAdapterConfig<InternalLokiConfig>('internalLoki', FIELDS, overrides)
      if (!cfg.url || !cfg.token) {
        console.error('[evlog/internal-loki] Missing url or token')
        return null
      }
      return cfg as InternalLokiConfig
    },
    encode: (events, config) => ({
      url: `${config.url}/loki/api/v1/push`,
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${config.token}`,
      },
      body: JSON.stringify({
        streams: events.map(e => ({
          stream: { service: e.service, level: e.level },
          values: [[String(Date.parse(e.timestamp) * 1e6), JSON.stringify(e)]],
        })),
      }),
    }),
  })
}

Package layout

package.json
{
  "name": "@my-org/evlog-internal-loki",
  "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"
  }
}
tsdown.config.ts
import { defineConfig } from 'tsdown'

export default defineConfig({
  entry: { 'index': 'src/index.ts' },
  format: 'esm',
  dts: true,
  external: ['evlog', 'evlog/toolkit'],
})

Consuming it

import { createInternalLokiDrain } from '@my-org/evlog-internal-loki'
import { createDrainPipeline } from 'evlog/pipeline'

const drain = createDrainPipeline()(createInternalLokiDrain())

The consumer's evlog version is pinned by their peerDependencies resolution. The drain's identity headers (User-Agent: evlog/<consumer-version> + X-Evlog-Source: internal-loki) work the same as built-in drains.

Publishing checklist

  • Tested with vitest against a stub HTTP endpoint
  • peerDependency on evlog: ^2 (or whatever range you support)
  • defineHttpDrain (not bare httpPost) so identity headers are auto-injected
  • README with config table + an example usage
  • Changeset for the first release

Real examples to ship

  • @my-org/evlog-internal-loki — your self-hosted Loki
  • @my-org/evlog-newrelic — New Relic Logs API
  • @my-org/evlog-internal-audit — your compliance backend
  • @my-org/evlog-pubsub — Google Cloud Pub/Sub topic
  • @my-org/evlog-webhook — generic POST-to-URL drain with templating