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 configuration —
tsdownemits.d.mtsso 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
vitestagainst a stub HTTP endpoint -
peerDependencyonevlog: ^2(or whatever range you support) -
defineHttpDrain(not barehttpPost) 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