- name
- create-evlog-framework-integration
- description
- 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.
- metadata
- {"internal":true}
# 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
在 GitHub 查看