# Custom Framework Integration

> Build evlog support for a framework or runtime that has no integration, with defineFrameworkIntegration or the lower-level helpers.

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

<callout color="warning" icon="i-lucide-flask-conical">

The toolkit API is marked as **beta**. The surface is stable (used by all built-in integrations) but may evolve based on community feedback.

</callout>

<table>
<thead>
  <tr>
    <th>
      Surface
    </th>
    
    <th>
      What it does
    </th>
    
    <th>
      When to use
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <a href="#manifest-mode-recommended">
        <code>
          defineFrameworkIntegration()
        </code>
      </a>
    </td>
    
    <td>
      Declaratively wire request extraction + logger attachment
    </td>
    
    <td>
      HTTP frameworks with a <code>
        (ctx, next)
      </code>
      
       middleware shape (Hono, Express, Fastify, Elysia, NestJS-shaped)
    </td>
  </tr>
  
  <tr>
    <td>
      <a href="#custom-mode">
        <code>
          createMiddlewareLogger()
        </code>
      </a>
    </td>
    
    <td>
      Imperative path: create the logger at request start, emit on response end
    </td>
    
    <td>
      Frameworks whose lifecycle doesn't fit <code>
        (ctx, next)
      </code>
      
       (NestJS interceptors, Next.js App Router, SvelteKit <code>
        handle
      </code>
      
      )
    </td>
  </tr>
  
  <tr>
    <td>
      <a href="#non-http-runtimes">
        <code>
          createRequestLogger()
        </code>
      </a>
    </td>
    
    <td>
      Wrap any unit of work in a logger lifecycle
    </td>
    
    <td>
      Non-HTTP runtimes (queue workers, CLI, cron, durable workflows)
    </td>
  </tr>
</tbody>
</table>

<prompt :actions="["copy","cursor","claude"]" description="Build an evlog integration for a custom framework" icon="i-lucide-puzzle">

Wire evlog into an HTTP framework (or non-HTTP runtime) that doesn't have a built-in integration.

- For HTTP frameworks with `(ctx, next)`, use `defineFrameworkIntegration` from `evlog/toolkit`, declare `extractRequest(ctx)` returning `{ method, path, headers, requestId? }`, `attachLogger(ctx, logger)`, and an optional storage from `createLoggerStorage()` (prefer `evlog/toolkit/storage` on Workers / edge)
- Headers may be either Web `Headers` or Node `IncomingHttpHeaders`. `defineFrameworkIntegration` normalizes both
- In your middleware, call `integration.start(ctx, options)` which returns `{ skipped, finish, runWith, logger, middlewareOptions }`
- If `skipped` is `true`, skip directly to `next`
- Run downstream handlers inside `runWith(() => next())` so `AsyncLocalStorage` and `log.fork()` work
- On success: `await finish({ status })`; on error: `await finish({ error })` then re-throw
- Expose `drain`, `enrich`, `keep`, `include`, `exclude`, `routes`, and `plugins` options
- On Cloudflare Workers / Vercel Edge, pass `waitUntil` (or `extractWaitUntil` on the manifest) so async drains complete after the response
- For non-HTTP runtimes (queue workers, CLI, cron), use `createRequestLogger` from `evlog/toolkit` directly. Wrap each unit of work in a logger lifecycle

Docs: [https://www.evlog.dev/extend/custom-framework](https://www.evlog.dev/extend/custom-framework)

</prompt>

## Install

<code-group>

```bash [pnpm]
pnpm add evlog
```

```bash [bun]
bun add evlog
```

```bash [yarn]
yarn add evlog
```

```bash [npm]
npm install evlog
```

</code-group>

## What's in the toolkit

<table>
<thead>
  <tr>
    <th>
      Export
    </th>
    
    <th>
      Purpose
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        defineFrameworkIntegration(spec)
      </code>
    </td>
    
    <td>
      Manifest factory — extract request, create logger, attach, run with ALS
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        createMiddlewareLogger(opts)
      </code>
    </td>
    
    <td>
      Lower-level lifecycle (custom mode)
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        waitUntil
      </code>
      
       on middleware options
    </td>
    
    <td>
      Defer drain on Cloudflare Workers / Vercel Edge (see <a href="#serverless-workers-and-edge">
        Serverless
      </a>
      
      )
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        createRequestLogger(opts)
      </code>
    </td>
    
    <td>
      Wrap a non-HTTP unit of work in a logger lifecycle
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        BaseEvlogOptions
      </code>
    </td>
    
    <td>
      Base user-facing options — <code>
        drain
      </code>
      
      , <code>
        enrich
      </code>
      
      , <code>
        keep
      </code>
      
      , <code>
        include
      </code>
      
      , <code>
        exclude
      </code>
      
      , <code>
        routes
      </code>
      
      , <code>
        plugins
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        MiddlewareLoggerResult
      </code>
    </td>
    
    <td>
      Return type: <code>
        { logger, finish, skipped }
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        extractSafeHeaders(headers)
      </code>
    </td>
    
    <td>
      Filter sensitive headers from a Web API <code>
        Headers
      </code>
      
       object
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        extractSafeNodeHeaders(headers)
      </code>
    </td>
    
    <td>
      Filter sensitive headers from Node.js <code>
        IncomingHttpHeaders
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        createLoggerStorage(hint)
      </code>
    </td>
    
    <td>
      Factory returning <code>
        { storage, useLogger }
      </code>
      
       backed by <code>
        AsyncLocalStorage
      </code>
      
       — also on <code>
        evlog/toolkit/storage
      </code>
      
       (prefer that entry on Workers / edge to isolate <code>
        node:async_hooks
      </code>
      
      )
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        attachForkToLogger(storage, parent, opts)
      </code>
    </td>
    
    <td>
      Wires <code>
        log.fork(label, fn)
      </code>
      
       onto the request logger so consumers can spawn correlated background work — used by manifest mode automatically; call manually in custom mode after <code>
        createMiddlewareLogger
      </code>
      
       returns the logger and before the lifecycle finishes
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        defineEvlog(config)
      </code>
    </td>
    
    <td>
      Canonical config object — works for <code>
        initLogger
      </code>
      
       and middleware options
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        definePlugin(plugin)
      </code>
    </td>
    
    <td>
      Plugin contract — opt into any subset of <code>
        setup
      </code>
      
      , <code>
        enrich
      </code>
      
      , <code>
        drain
      </code>
      
      , <code>
        keep
      </code>
      
      , <code>
        onRequestStart
      </code>
      
      , <code>
        onRequestFinish
      </code>
      
      , <code>
        onClientLog
      </code>
      
      , <code>
        extendLogger
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        composeEnrichers / composeDrains / composeKeep / composePlugins
      </code>
    </td>
    
    <td>
      Combine multiple extensions into one
    </td>
  </tr>
</tbody>
</table>

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.

<code-collapse>

```typescript [my-framework-evlog.ts]
import type { IncomingMessage, ServerResponse } from 'node:http'
import {
  createLoggerStorage,
  defineFrameworkIntegration,
  type BaseEvlogOptions,
} from 'evlog/toolkit'
// On Workers / edge, prefer: import { createLoggerStorage } from 'evlog/toolkit/storage'
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
    }
  }
}
```

</code-collapse>

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)`, which 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:

```typescript
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 and 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`](/integrate/frameworks/cloudflare-workers) and the Nitro plugin.

**Custom mode.** Pass `waitUntil` per request:

```typescript
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:

```typescript
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:

```ts
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](/extend/identity-headers) on outbound HTTP drain requests. Only the entry point shape changes.

## Reference implementations

Study these built-in integrations for framework-specific patterns:

<table>
<thead>
  <tr>
    <th>
      Framework
    </th>
    
    <th>
      Lines
    </th>
    
    <th>
      Mode
    </th>
    
    <th>
      Source
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      Hono
    </td>
    
    <td>
      ~50
    </td>
    
    <td>
      manifest
    </td>
    
    <td>
      <a href="https://github.com/evloghq/evlog/blob/main/packages/evlog/src/hono/index.ts" rel="nofollow">
        hono/index.ts
      </a>
    </td>
  </tr>
  
  <tr>
    <td>
      Express
    </td>
    
    <td>
      ~50
    </td>
    
    <td>
      manifest + ALS
    </td>
    
    <td>
      <a href="https://github.com/evloghq/evlog/blob/main/packages/evlog/src/express/index.ts" rel="nofollow">
        express/index.ts
      </a>
    </td>
  </tr>
  
  <tr>
    <td>
      Fastify
    </td>
    
    <td>
      ~70
    </td>
    
    <td>
      manifest + Fastify hooks
    </td>
    
    <td>
      <a href="https://github.com/evloghq/evlog/blob/main/packages/evlog/src/fastify/index.ts" rel="nofollow">
        fastify/index.ts
      </a>
    </td>
  </tr>
  
  <tr>
    <td>
      Elysia
    </td>
    
    <td>
      ~80
    </td>
    
    <td>
      manifest + custom ALS scoping
    </td>
    
    <td>
      <a href="https://github.com/evloghq/evlog/blob/main/packages/evlog/src/elysia/index.ts" rel="nofollow">
        elysia/index.ts
      </a>
    </td>
  </tr>
  
  <tr>
    <td>
      NestJS
    </td>
    
    <td>
      ~120
    </td>
    
    <td>
      custom (interceptor)
    </td>
    
    <td>
      <a href="https://github.com/evloghq/evlog/blob/main/packages/evlog/src/nestjs/" rel="nofollow">
        nestjs/
      </a>
    </td>
  </tr>
  
  <tr>
    <td>
      SvelteKit
    </td>
    
    <td>
      ~90
    </td>
    
    <td>
      custom (<code>
        handle
      </code>
      
       hook)
    </td>
    
    <td>
      <a href="https://github.com/evloghq/evlog/blob/main/packages/evlog/src/sveltekit/" rel="nofollow">
        sveltekit/
      </a>
    </td>
  </tr>
</tbody>
</table>

<callout color="neutral" icon="i-lucide-heart">

Built an integration for a framework we don't support? [Open a PR](https://github.com/evloghq/evlog/pulls). The community will thank you.

</callout>

## Next steps

- [Custom Drains](/extend/custom-drains): same toolkit shape for drain destinations
- [Custom Enrichers](/extend/custom-enrichers): same toolkit shape for derived event fields
- [Plugins](/extend/plugins): multi-hook extensions (drain + enrich + keep in one object)
- [Wide Events](/learn/wide-events): design comprehensive events with context layering
- [Sampling](/learn/sampling): control log volume with head and tail sampling
- [Adapters](/integrate/adapters/overview): send logs to Axiom, Sentry, PostHog, and more

<style>

html pre.shiki code .sBMFI, html code.shiki .sBMFI{--shiki-light:#E2931D;--shiki-default:#FFCB6B;--shiki-dark:#FFCB6B}html pre.shiki code .sfazB, html code.shiki .sfazB{--shiki-light:#91B859;--shiki-default:#C3E88D;--shiki-dark:#C3E88D}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .s7zQu, html code.shiki .s7zQu{--shiki-light:#39ADB5;--shiki-light-font-style:italic;--shiki-default:#89DDFF;--shiki-default-font-style:italic;--shiki-dark:#89DDFF;--shiki-dark-font-style:italic}html pre.shiki code .sMK4o, html code.shiki .sMK4o{--shiki-light:#39ADB5;--shiki-default:#89DDFF;--shiki-dark:#89DDFF}html pre.shiki code .sTEyZ, html code.shiki .sTEyZ{--shiki-light:#90A4AE;--shiki-default:#EEFFFF;--shiki-dark:#BABED8}html pre.shiki code .sHwdD, html code.shiki .sHwdD{--shiki-light:#90A4AE;--shiki-light-font-style:italic;--shiki-default:#546E7A;--shiki-default-font-style:italic;--shiki-dark:#676E95;--shiki-dark-font-style:italic}html pre.shiki code .spNyl, html code.shiki .spNyl{--shiki-light:#9C3EDA;--shiki-default:#C792EA;--shiki-dark:#C792EA}html pre.shiki code .s2Zo4, html code.shiki .s2Zo4{--shiki-light:#6182B8;--shiki-default:#82AAFF;--shiki-dark:#82AAFF}html pre.shiki code .swJcz, html code.shiki .swJcz{--shiki-light:#E53935;--shiki-default:#F07178;--shiki-dark:#F07178}html pre.shiki code .sHdIc, html code.shiki .sHdIc{--shiki-light:#90A4AE;--shiki-light-font-style:italic;--shiki-default:#EEFFFF;--shiki-default-font-style:italic;--shiki-dark:#BABED8;--shiki-dark-font-style:italic}

</style>

---

- [Custom Drains](/extend/custom-drains)
- [Custom Enrichers](/extend/custom-enrichers)
