# Create Evlog Framework Integration

> Create a new evlog framework integration to add automatic wide-event logging to an HTTP framework. Use when adding middleware/plugin support for a framework (e.g., Koa, H3 standalone, Deno Fresh, etc.) to the evlog package. Covers source code, build config, package exports, tests, example app, and all documentation.

- Skill: `hugorcd/create-evlog-framework-integration` (Agent Skill)
- Install (CLI): `npx skillmds@latest add hugorcd/create-evlog-framework-integration`
- Raw SKILL.md: https://api.skillmd.com/api/skills/hugorcd/create-evlog-framework-integration/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Docs & Writing
- Author: hugorcd (https://skillmd.com/u/hugorcd)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/hugorcd/create-evlog-framework-integration

---


# Create evlog Framework Integration

Add a new framework integration to evlog. The recommended path is the **manifest mode** built on `defineFrameworkIntegration` from `evlog/toolkit`, for any framework with a request/response middleware shape. For frameworks with a fundamentally different lifecycle you'll fall back to the lower-level `createMiddlewareLogger`.

## Two paths

- **Manifest mode** (preferred, ~30–80 lines of glue). Call `defineFrameworkIntegration({ name, extractRequest, attachLogger, storage? })` once at module level, then write a tiny middleware that calls `integration.start(ctx, options)` and runs the framework's `next()` inside `runWith`. Reference implementations: all of `packages/evlog/src/{hono,express,fastify,elysia,nestjs,orpc,react-router,sveltekit,workers}/index.ts` use it.
- **Custom mode**: use `createMiddlewareLogger` directly when the framework's lifecycle doesn't fit a standard middleware. Current custom-mode integrations: Next.js (`src/next/`), Nitro v2/v3 (`src/nitro/`, `src/nitro-v3/`), Eve (`src/eve/`).

Manifest mode now covers all classic HTTP frameworks. Use custom mode only when you can't extract a request synchronously at the start of the lifecycle (server actions, module-level hooks, agent turns).

## Required API surface (from AGENTS.md)

Every framework integration must expose:

1. `evlog()` middleware/plugin accepting the full `BaseEvlogOptions` (`drain`, `enrich`, `keep`, `include`, `exclude`, `routes`, `plugins`)
2. `useLogger()` (ALS-backed). Workers is the one sanctioned exception (ALS needs a compat flag there; `defineWorkerFetch` attaches the logger instead)
3. `log.fork()` support (automatic when `storage` is provided to the manifest)
4. The framework-native accessor (`c.get('log')`, `req.log`, `event.locals.log`, …)

## PR Title

```
feat({framework}): add {Framework} middleware integration
```

**Scope timing caveat**: the semantic PR check reads its scope list from the **base branch**, so a brand-new scope can't validate the very PR that introduces it. Either register the scope in a small preceding PR, or use an unscoped title (`feat: add {Framework} middleware integration`) on the introducing PR.

## Touchpoints Checklist

| # | File | Action |
|---|------|--------|
| 1 | `packages/evlog/src/{framework}/index.ts` | Create integration source |
| 2 | `packages/evlog/tsdown.config.ts` | Add build entry + external |
| 3 | `packages/evlog/package.json` | Add `exports` + `typesVersions` + optional peer dep + keyword |
| 4 | `packages/evlog/test/frameworks/{framework}.test.ts` | Create tests (real request driver + `describeStandardHttpMatrix`) |
| 5 | `packages/evlog/test/toolkit/__snapshots__/api-surface.test.ts.snap` | Regenerated by `pnpm run build` + `pnpm test` |
| 6 | `apps/docs/content/4.integrate/frameworks/{NN}.{framework}.md` | Create framework docs page |
| 7 | `apps/docs/content/4.integrate/frameworks/00.overview.md` | Add table row + card |
| 8 | `apps/docs/content/1.start/3.installation.md` | Add card in "Choose Your Framework" |
| 9 | `apps/docs/content/0.landing.md` | Add framework code snippet slot |
| 10 | `apps/docs/app/components/features/FeatureFrameworks.vue` | Add framework tab |
| 11 | `skills/review-logging-patterns/SKILL.md` | Add framework setup section + update frontmatter description |
| 12 | `packages/evlog/README.md` | Add framework section + row in the Framework Support table |
| 13 | `examples/{framework}/` | Create example app with test UI (auto-discovered by `pnpm example {framework}` — no root script needed) |
| 14 | `.changeset/{framework}-integration.md` | Create changeset (`minor`) |
| 15 | `.github/workflows/semantic-pull-request.yml` + `.github/pull_request_template.md` | Register `{framework}` as a PR scope in both files |

**Important**: Do NOT consider the task complete until all 15 touchpoints have been addressed.

## Naming Conventions

| Placeholder | Example (Hono) | Usage |
|-------------|----------------|-------|
| `{framework}` | `hono` | Directory names, import paths, file names, PR scope |
| `{Framework}` | `Hono` | PascalCase in type/interface names |

## Shared Utilities

All integrations share the same core utilities. **Never reimplement logic that exists in shared/**. These are also publicly available as `evlog/toolkit` for community-built integrations (see [Custom Integration docs](https://evlog.dev/extend/custom-framework)).

| Utility | Location | Purpose |
|---------|----------|---------|
| `defineFrameworkIntegration` | `../shared/integration` | Manifest factory — extract request, create logger, attach, run with ALS |
| `createMiddlewareLogger` | `../shared/middleware` | Lower-level lifecycle (custom mode): logger creation, route filtering, tail sampling, emit, enrich, drain |
| `BaseEvlogOptions` | `../shared/middleware` | Base user-facing options type with `drain`, `enrich`, `keep`, `include`, `exclude`, `routes`, `plugins` |
| `createLoggerStorage` | `../shared/storage` (`evlog/toolkit/storage`) | Factory returning `{ storage, useLogger }` for `AsyncLocalStorage`-backed `useLogger()`. Prefer `evlog/toolkit/storage` on Workers / edge |
| `shouldDeferEmitForResponse` | `../shared/streamResponse` | Defer the wide event until a streaming body closes (see Hono/Elysia) |

`defineFrameworkIntegration` automatically:

- normalizes both Web `Headers` and Node `IncomingHttpHeaders` (so you don't need to pick a header extractor)
- generates a `requestId` when none is present
- calls `createMiddlewareLogger` and surfaces its `{ logger, finish, skipped, middlewareOptions }`
- attaches `log.fork()` automatically when `storage` is provided
- exposes `runWith(fn)` to run downstream handlers inside the integration's ALS
- forwards `waitUntil` when the runtime provides one (Workers, Hono on Workers)

## Step 1: Integration Source

Create `packages/evlog/src/{framework}/index.ts`.

### Template Structure (manifest mode)

```typescript
import type { AuditableLogger } from '../audit'
import { defineFrameworkIntegration } from '../shared/integration'
import type { BaseEvlogOptions } from '../shared/middleware'
import { createLoggerStorage } from '../shared/storage'

const { storage, useLogger } = createLoggerStorage(
  'middleware context. Make sure the evlog middleware is registered before your routes.',
  'evlog:{framework}',
)

export type Evlog{Framework}Options = BaseEvlogOptions
export { useLogger }

// Type augmentation for typed logger access (framework-specific):
// - Express: declare module 'express-serve-static-core' { interface Request { log: AuditableLogger } }
// - Hono:    export type EvlogVariables = { Variables: { log: AuditableLogger } }

const integration = defineFrameworkIntegration<{Framework}Context>({
  name: '{framework}',
  extractRequest: (ctx) => ({
    method: /* ctx.method */,
    path: /* ctx.path */,
    headers: /* Web Headers OR Node headers OR plain object */,
    requestId: /* x-request-id header or undefined → auto-generated */,
  }),
  attachLogger: (ctx, logger) => {
    // Store in framework-idiomatic location:
    // - Hono:    c.set('log', logger)
    // - Express: req.log = logger
  },
  storage,
})

export function evlog(options: Evlog{Framework}Options = {}): FrameworkMiddleware {
  return async (ctx, next) => {
    const { skipped, finish, runWith } = integration.start(ctx, options)
    if (skipped) {
      await next()
      return
    }
    try {
      await runWith(() => next())
      await finish({ status: /* extract status from ctx */ })
    } catch (error) {
      await finish({ error: error as Error })
      throw error
    }
  }
}
```

### Reference Implementations

- **Hono**: `src/hono/index.ts`. `c.set('log', logger)` + ALS `useLogger()`, streaming deferral via `shouldDeferEmitForResponse`, `waitUntil` detection
- **Express**: `src/express/index.ts`. `req.log`, ALS storage, `res.on('finish')` for terminal status
- **Fastify**: `src/fastify/index.ts`. Fastify hooks (`onRequest` / `onResponse` / `onError`), `fastify-plugin` wrapper
- **Elysia**: `src/elysia/index.ts`. Plugin with `.derive({ as: 'global' })`, `storage.enterWith`-style ALS, streaming deferral
- **NestJS**: `src/nestjs/index.ts`. `EvlogModule.forRoot()` / `forRootAsync()` on top of the manifest
- **oRPC**: `src/orpc/index.ts`. `evlog()` procedure middleware + `withEvlog(handler)` wrapper
- **React Router**: `src/react-router/index.ts`. `loggerContext = createContext<AuditableLogger>()`
- **SvelteKit**: `src/sveltekit/index.ts`. `evlog()` handle + `evlogHandleError()` + `createEvlogHooks()`
- **Workers**: `src/workers/index.ts`. `defineWorkerFetch` / `withEvlog`, no ALS `useLogger()` (compat-flag constraint)

For integrations that bind with `enterWith()`, use `createSharedEnterWithStorage` from `src/shared/asyncStorageScope.ts`. Its capability probe runs in a temporary scope with a distinct store, preserving the importer's async context on Bun and Node.

### Key Architecture Rules

1. **Prefer `defineFrameworkIntegration`**: it handles header normalization, request-id generation, ALS, fork attachment, and `waitUntil`.
2. **Status / error reporting stays framework-side**: call `finish({ status })` on success and `finish({ error })` on failure. `finish` runs emit + enrich + drain + plugin hooks.
3. **Re-throw errors** after `finish({ error })` so the framework's own error handler still runs.
4. **Streaming responses**: if the framework can return streaming bodies, defer the emit until the stream closes (`shouldDeferEmitForResponse`; see Hono and Elysia).
5. **Framework SDK is an optional peer dependency**: never bundle it.
6. **Never duplicate pipeline logic**: `runEnrichAndDrain` is internal to `createMiddlewareLogger`/`finish`.
7. **Export type helpers** for typed context access (e.g., `EvlogVariables` for Hono).

### When to fall back to custom mode

Use `createMiddlewareLogger` directly (skipping `defineFrameworkIntegration`) when:

- The middleware doesn't have a clear "request entry / response exit" pair (Next.js App Router server actions, Eve agent turns).
- Logger creation spans multiple lifecycle phases owned by a module system (Nitro plugins + hooks).
- The status is not knowable until after the response stream completes and the framework gives you no hook for it.

## Step 2: Build Config

Add a build entry in `packages/evlog/tsdown.config.ts`:

```typescript
'{framework}/index': 'src/{framework}/index.ts',
```

Also add the framework SDK to the `external` array (e.g., `'elysia'`, `'fastify'`).

## Step 3: Package Exports

In `packages/evlog/package.json`:

**In `exports`** (after the last framework entry):

```json
"./{framework}": {
  "types": "./dist/{framework}/index.d.mts",
  "import": "./dist/{framework}/index.mjs"
}
```

**In `typesVersions["*"]`**: `"{framework}": ["./dist/{framework}/index.d.mts"]`

**In `peerDependencies`** (version range) + **`peerDependenciesMeta`** (`"optional": true`), and add the framework name to `keywords`.

Exports without matching `tsdown.config.ts` entries fail `test/toolkit/api-surface.test.ts`.

## Step 4: Tests

Create `packages/evlog/test/frameworks/{framework}.test.ts`. Read `packages/evlog/test/README.md` first, especially the **Framework runtime fidelity** table.

Two non-negotiables:

1. **Real request driver.** Use the framework's own driver: supertest (Express/NestJS), `app.request()` (Hono), `app.inject()` (Fastify), `app.handle(new Request(...))` (Elysia). If no Node-friendly driver exists, call the user-facing contract directly with realistic input shapes (see the SvelteKit and React Router tests). Never extract internals to test a substitute.
2. **Wire the shared matrix.** Call `describeStandardHttpMatrix({ name, mount })` from `test/helpers/frameworkMatrix.ts`. It covers the standard sweep (event emission, `x-request-id`, route service) for every HTTP framework.

On top of the matrix, cover the framework-specific surface:

1. Framework-native accessor returns the logger (`c.get('log')`, `req.log`, …)
2. Error handling. Errors captured, event has error level + details, error re-thrown
3. Route filtering. Skipped routes don't create a logger, skip drain/enrich
4. Context accumulation. `logger.set()` data appears in the emitted event
5. Drain / enrich / keep callbacks (use `createPipelineSpies()`, `assertHttpEventEmitted`, `waitForDrainCalls`, `findEventViaDrain` from `test/helpers/framework.ts`)
6. Drain/enrich error resilience. Errors there never break the request
7. `useLogger()`: same logger as the native accessor, works across async boundaries, throws outside context. Skip it for an integration without ALS, and test the accessor it ships instead: on Workers that is the handler's fourth argument, from `defineWorkerFetch` / `withEvlog`
8. Streaming (if applicable). Event deferred until the body closes

Use fake timers for anything time-based; `defined()` instead of `!`.

## Step 5: Framework Docs Page

Create `apps/docs/content/4.integrate/frameworks/{NN}.{framework}.md` with a comprehensive, self-contained guide. Check existing files for the next zero-padded number.

**Frontmatter**:

```yaml
---
title: {Framework}
description: Using evlog with {Framework} — automatic wide events, structured errors, drain adapters, enrichers, and tail sampling in {Framework} applications.
navigation:
  title: {Framework}
  icon: i-simple-icons-{framework}
links:
  - label: Source Code
    icon: i-simple-icons-github
    to: https://github.com/evloghq/evlog/tree/main/examples/{framework}
    color: neutral
    variant: subtle
---
```

**Sections** (follow the Express/Hono/Elysia pages as reference):

1. **Quick Start**: install + register middleware (copy-paste minimum setup)
2. **Wide Events**: progressive `log.set()` usage
3. **useLogger()**: accessing the logger from services without passing the request, or, for an integration without ALS, the accessor it ships in its place
4. **Error Handling**: `createError()` + `parseError()` + framework error handler
5. **Drain & Enrichers**: middleware options with inline example
6. **Pipeline (Batching & Retry)**: `createDrainPipeline` example
7. **Tail Sampling**: `keep` callback
8. **Route Filtering**: `include` / `exclude` / `routes`
9. **Client-Side Logging**: HTTP drain (`evlog/http`) (only if the framework has a client-side story)
10. **Run Locally**: clone + `pnpm example {framework}`
11. **Card group** linking to GitHub source

## Step 6: Overview & Installation Cards

**In `apps/docs/content/4.integrate/frameworks/00.overview.md`**:

1. Add a row to the **Overview table**: framework name, import, type, logger access, status
2. Add a card in the appropriate section, and a row in the **API cheat sheet** if the bootstrap/access pattern is new

**In `apps/docs/content/1.start/3.installation.md`**: add a card in the "Choose Your Framework" `::card-group`, in the same order as the frameworks overview.

## Step 7: Landing Page

In `apps/docs/content/0.landing.md`, find the `FeatureFrameworks` MDC section (slots `#nuxt`, `#nextjs`, `#hono`, …) and add a new slot:

```markdown
  #{framework}
  ```ts [src/index.ts]
  // Framework-specific code example showing evlog usage
  ```
```

## Step 8: FeatureFrameworks Component

Update `apps/docs/app/components/features/FeatureFrameworks.vue`:

1. Add the framework to the `frameworks` array with its icon and the next available `tab` index
2. Add a `<div v-if="activeTab === {N}">` with `<slot name="{framework}" />` in the template

Icons use Simple Icons format: `i-simple-icons-{name}`.

## Step 9: Update the Public Skill

In `skills/review-logging-patterns/SKILL.md` (published on evlog.dev):

1. Add `### {Framework}` in the **"Framework Setup"** section, in the same order as the docs
2. Include: import + `initLogger` + middleware setup; native logger access; a `useLogger()` snippet, or the accessor that replaces it when the integration has no ALS; full pipeline example (`drain`, `enrich`, `keep`)
3. Update the `description:` line in the YAML frontmatter to mention the new framework name

## Step 10: Update README

In `packages/evlog/README.md` (root `README.md` is a symlink):

1. Add a `## {Framework}` section near the other framework sections with a minimal setup snippet and a link to the example app
2. Add a row to the **Framework Support** table

Keep the snippet short: init, register middleware, one route handler showing logger access.

## Step 11: Example App

Create `examples/{framework}/` with a runnable app demonstrating all evlog features. It is auto-discovered by the root runner: `pnpm example {framework}` (which loads the root `.env` via dotenv, so no root `package.json` change is needed).

The app must include:

1. **`evlog()` middleware** with `drain` (PostHog) and `enrich` callbacks
2. **Health route**: basic `log.set()` usage
3. **Data route**: context accumulation with user/business data, using `useLogger()` in a service function, or the integration's own accessor when it has no ALS
4. **Error route**: `createError()` with status/why/fix/link
5. **Error handler**: framework's error handler with `parseError()` + manual `log.error()`
6. **Test UI**: served at `/`, a self-contained HTML page with buttons to hit each route and display JSON responses

**Drain must use PostHog** (`createPostHogDrain()` from `evlog/posthog`). `POSTHOG_API_KEY` is set in the root `.env` (maintainer's key, not committed), so every example exercises a real external drain. Without the env var the drain resolves to `null` and skips, so someone cloning the repo sends nothing anywhere unless they opt in with their own key. Enable pretty printing for readable local output.

**Type the `enrich` callback parameter explicitly**, as `(ctx: EnrichContext) => ...` with `type EnrichContext` imported from `evlog`.

### Test UI

Reference: `examples/hono/src/ui.ts`, a single `src/ui.ts` exporting `testUI()` returning a self-contained dark-theme HTML string (route list with method badges, click-to-fetch, JSON display, status colors, response time). Register the `/` route **before** the evlog middleware so it isn't logged.

### Required files

| File | Purpose |
|------|---------|
| `src/index.ts` | App with all features demonstrated |
| `src/ui.ts` | Test UI |
| `package.json` | `dev` and `start` scripts (`bun --watch src/index.ts` / `bun src/index.ts`) |
| `tsconfig.json` | TypeScript config (if needed) |
| `README.md` | How to run + link to the UI |

There is also `examples/community-framework-skeleton/` showing the community-facing (toolkit-only) variant, so keep it in mind if the new integration changes the toolkit contract.

## Step 12: Changeset

Create `.changeset/{framework}-integration.md`:

```markdown
---
"evlog": minor
---

feat({framework}): add {Framework} middleware integration (`evlog/{framework}`) with automatic wide-event logging, drain, enrich, and tail sampling support
```

## Step 13: PR Scopes

Add `{framework}` to the `scopes` list in `.github/workflows/semantic-pull-request.yml` **and** to the Scopes section of `.github/pull_request_template.md`, in alphabetical order. Remember the timing caveat from the PR Title section: this registration only takes effect for PRs whose base branch already contains it.

## Verification

After a clean install, run `pnpm run dev:prepare` from the repo root first, then:

```bash
pnpm run dev:prepare
cd packages/evlog
pnpm run build    # required before test — api-surface snapshot is gated on dist/
pnpm run test
pnpm run lint
```

Then type-check the example:

```bash
cd examples/{framework}
pnpm exec tsc --noEmit
```

