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) |
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.
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