Extend

Custom Framework Integration

Build evlog support for an HTTP framework (or non-HTTP runtime) without a built-in integration. Use defineFrameworkIntegration for the (ctx, next) middleware shape, or createMiddlewareLogger / createRequestLogger for everything else.

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.

The toolkit API is marked as beta. The surface is stable (used by all built-in integrations) but may evolve based on community feedback.
SurfaceWhat it doesWhen to use
defineFrameworkIntegration()Declaratively wire request extraction + logger attachmentHTTP 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 endFrameworks whose lifecycle doesn't fit (ctx, next) (NestJS interceptors, Next.js App Router, SvelteKit handle)
createRequestLogger()Wrap any unit of work in a logger lifecycleNon-HTTP runtimes (queue workers, CLI, cron, durable workflows)

Build an evlog integration for a custom framework

Install

pnpm add evlog

What's in the toolkit

ExportPurpose
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
BaseEvlogOptionsBase user-facing options — drain, enrich, keep, include, exclude, routes, plugins
MiddlewareLoggerResultReturn 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 / composePluginsCombine multiple extensions into one

Types like RequestLogger, DrainContext, EnrichContext, WideEvent, and TailSamplingContext are exported from the main evlog package.

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.

my-framework-evlog.ts
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:

  1. Normalizes headers (auto-detects Headers vs IncomingHttpHeaders).
  2. Generates a requestId if extractRequest doesn't return one.
  3. Calls createMiddlewareLogger with the merged options.
  4. Calls attachLogger(ctx, logger).
  5. Attaches log.fork() to the logger when storage is provided (so users can spawn correlated background work).
  6. Exposes runWith(fn) — runs fn() inside storage.run(logger, …) if storage is configured, otherwise just calls fn().

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:

FrameworkLinesModeSource
Hono~50manifesthono/index.ts
Express~50manifest + ALSexpress/index.ts
Fastify~70manifest + Fastify hooksfastify/index.ts
Elysia~80manifest + custom ALS scopingelysia/index.ts
NestJS~120custom (interceptor)nestjs/
SvelteKit~90custom (handle hook)sveltekit/
Built an integration for a framework we don't support? Open a PR — the community will thank you.

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