Custom Framework Integration
When the framework you use doesn't have an evlog/<framework> package yet, you build the integration yourself. evlog/toolkit ships the same building blocks that power every built-in integration (Hono, Express, Fastify, Elysia, NestJS, SvelteKit) — you only write the framework-specific glue.
The mental model is always the same: request lifecycle → logger creation → enrich → drain. The toolkit handles the request-context plumbing.
| Surface | What it does | When to use |
|---|---|---|
defineFrameworkIntegration() | Declaratively wire request extraction + logger attachment | HTTP frameworks with a (ctx, next) middleware shape (Hono, Express, Fastify, Elysia, NestJS-shaped) |
createMiddlewareLogger() | Imperative path: create the logger at request start, emit on response end | Frameworks whose lifecycle doesn't fit (ctx, next) (NestJS interceptors, Next.js App Router, SvelteKit handle) |
createRequestLogger() | Wrap any unit of work in a logger lifecycle | Non-HTTP runtimes (queue workers, CLI, cron, durable workflows) |
Build an evlog integration for a custom framework
Install
pnpm add evlog
bun add evlog
yarn add evlog
npm install evlog
What's in the toolkit
| Export | Purpose |
|---|---|
defineFrameworkIntegration(spec) | Manifest factory — extract request, create logger, attach, run with ALS |
createMiddlewareLogger(opts) | Lower-level lifecycle (custom mode) |
waitUntil on middleware options | Defer drain on Cloudflare Workers / Vercel Edge (see Serverless) |
createRequestLogger(opts) | Wrap a non-HTTP unit of work in a logger lifecycle |
BaseEvlogOptions | Base user-facing options — drain, enrich, keep, include, exclude, routes, plugins |
MiddlewareLoggerResult | Return type: { logger, finish, skipped } |
extractSafeHeaders(headers) | Filter sensitive headers from a Web API Headers object |
extractSafeNodeHeaders(headers) | Filter sensitive headers from Node.js IncomingHttpHeaders |
createLoggerStorage(hint) | Factory returning { storage, useLogger } backed by AsyncLocalStorage |
attachForkToLogger(storage, parent, opts) | Wires log.fork(label, fn) onto the request logger so consumers can spawn correlated background work — used by manifest mode automatically; call manually in custom mode after createMiddlewareLogger returns the logger and before the lifecycle finishes |
defineEvlog(config) | Canonical config object — works for initLogger and middleware options |
definePlugin(plugin) | Plugin contract — opt into any subset of setup, enrich, drain, keep, onRequestStart, onRequestFinish, onClientLog, extendLogger |
composeEnrichers / composeDrains / composeKeep / composePlugins | Combine multiple extensions into one |
Types like RequestLogger, DrainContext, EnrichContext, WideEvent, and TailSamplingContext are exported from the main evlog package.
Manifest mode (recommended)
Most frameworks fit a (ctx, next) middleware shape. For those, write a manifest describing how to extract the request and attach the logger — defineFrameworkIntegration does the rest.
import type { IncomingMessage, ServerResponse } from 'node:http'
import {
createLoggerStorage,
defineFrameworkIntegration,
type BaseEvlogOptions,
} from 'evlog/toolkit'
import type { RequestLogger } from 'evlog'
export type MyFrameworkEvlogOptions = BaseEvlogOptions
const { storage, useLogger } = createLoggerStorage(
'Cannot access logger outside of middleware context. Make sure evlog middleware is registered before your routes.',
)
export { useLogger }
const integration = defineFrameworkIntegration<IncomingMessage>({
name: 'my-framework',
extractRequest: (req) => ({
method: req.method || 'GET',
path: req.url || '/',
headers: req.headers,
requestId: typeof req.headers['x-request-id'] === 'string'
? req.headers['x-request-id']
: undefined,
}),
attachLogger: (req, logger) => {
(req as IncomingMessage & { log: RequestLogger }).log = logger
},
storage,
})
export function evlog(options: MyFrameworkEvlogOptions = {}) {
return async (req: IncomingMessage, res: ServerResponse, next: () => Promise<void>) => {
const { skipped, finish, runWith } = integration.start(req, options)
if (skipped) {
await next()
return
}
try {
await runWith(() => next())
await finish({ status: res.statusCode })
} catch (error) {
await finish({ error: error as Error })
throw error
}
}
}
That's it. This middleware gets every feature for free: route filtering, drain adapters, enrichers, tail sampling, error capture, plugin lifecycle hooks, log.fork(), and duration tracking.
What defineFrameworkIntegration does
Given the manifest above, the helper:
- Normalizes headers (auto-detects
HeadersvsIncomingHttpHeaders). - Generates a
requestIdifextractRequestdoesn't return one. - Calls
createMiddlewareLoggerwith the merged options. - Calls
attachLogger(ctx, logger). - Attaches
log.fork()to the logger whenstorageis provided (so users can spawn correlated background work). - Exposes
runWith(fn)— runsfn()insidestorage.run(logger, …)if storage is configured, otherwise just callsfn().
You're left with only the framework-specific glue: where to read the request from, where to attach the logger, and how to compute the response status.
Custom mode
If your framework's lifecycle doesn't fit a clean (ctx, next) shape (NestJS interceptors, Next.js App Router, SvelteKit handle), drop one level lower and call createMiddlewareLogger directly:
import { createMiddlewareLogger, extractSafeNodeHeaders } from 'evlog/toolkit'
const { logger, finish, skipped } = createMiddlewareLogger({
method,
path,
requestId,
headers: extractSafeNodeHeaders(rawHeaders),
...options,
})
You'll be responsible for ALS wrapping (storage.run), log.fork() attachment (via attachForkToLogger), and finishing the lifecycle — but you keep the full pipeline (route filtering, sampling, emit, enrich, drain, plugins) for free.
Serverless (Workers / Edge)
On Cloudflare Workers and Vercel Edge, the runtime can terminate as soon as the response is returned. If your drain sends HTTP to an observability backend, pass waitUntil so enrich still runs inline but drain work survives after the response — the same behavior as evlog/workers and the Nitro plugin.
Custom mode — pass waitUntil per request:
import { waitUntil } from '@vercel/functions'
// import { waitUntil } from 'cloudflare:workers' // Vercel-style global on some runtimes
const { logger, finish, skipped } = createMiddlewareLogger({
method,
path,
requestId,
headers: extractSafeNodeHeaders(rawHeaders),
waitUntil, // or ctx.waitUntil.bind(ctx) on Cloudflare
...options,
})
Manifest mode — either pass waitUntil in integration.start(ctx, options) or declare extractWaitUntil on the manifest when the hook lives on the framework context:
const integration = defineFrameworkIntegration<WorkerContext>({
name: 'my-framework',
extractRequest: (ctx) => ({ /* … */ }),
attachLogger: (ctx, logger) => { /* … */ },
extractWaitUntil: ctx => ctx.executionCtx.waitUntil.bind(ctx.executionCtx),
})
export function evlog(options: BaseEvlogOptions = {}) {
return async (ctx, next) => {
const { skipped, finish, runWith } = integration.start(ctx, options)
// Per-request override still works:
// integration.start(ctx, { ...options, waitUntil: ctx.executionCtx.waitUntil.bind(ctx.executionCtx) })
// …
}
}
Per-request options.waitUntil takes precedence over extractWaitUntil. Without either, drains are awaited (correct for traditional Node.js servers).
Non-HTTP runtimes
For queue workers, CLI drivers, cron jobs, or durable execution engines, skip the HTTP-shaped helpers and use createRequestLogger from evlog/toolkit directly:
import { createRequestLogger } from 'evlog/toolkit'
async function processJob(job: Job) {
const logger = createRequestLogger({
service: 'jobs',
context: { jobId: job.id, queue: job.queue },
})
try {
await runJob(job)
logger.set({ status: 'success' })
} catch (err) {
logger.error(err)
throw err
} finally {
await logger.emit()
}
}
Same enrichers, same drain hook, same identity headers on outbound HTTP drain requests — only the entry point shape changes.
Reference implementations
Study these built-in integrations for framework-specific patterns:
| Framework | Lines | Mode | Source |
|---|---|---|---|
| Hono | ~50 | manifest | hono/index.ts |
| Express | ~50 | manifest + ALS | express/index.ts |
| Fastify | ~70 | manifest + Fastify hooks | fastify/index.ts |
| Elysia | ~80 | manifest + custom ALS scoping | elysia/index.ts |
| NestJS | ~120 | custom (interceptor) | nestjs/ |
| SvelteKit | ~90 | custom (handle hook) | sveltekit/ |
Next steps
- Custom Drains — same toolkit shape for drain destinations
- Custom Enrichers — same toolkit shape for derived event fields
- Plugins — multi-hook extensions (drain + enrich + keep in one object)
- Wide Events — design comprehensive events with context layering
- Sampling — control log volume with head and tail sampling
- Adapters — send logs to Axiom, Sentry, PostHog, and more