| 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:
evlog() middleware/plugin accepting the full BaseEvlogOptions (drain, enrich, keep, include, exclude, routes, plugins)
useLogger() (ALS-backed). Workers is the one sanctioned exception (ALS needs a compat flag there; defineWorkerFetch attaches the logger instead)
log.fork() support (automatic when storage is provided to the manifest)
- 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).
| 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)
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 }
const integration = defineFrameworkIntegration<{Framework}Context>({
name: '{framework}',
extractRequest: (ctx) => ({
method: ,
path: ,
headers: ,
requestId: ,
}),
attachLogger: (ctx, 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: })
} 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
- Prefer
defineFrameworkIntegration: it handles header normalization, request-id generation, ALS, fork attachment, and waitUntil.
- Status / error reporting stays framework-side: call
finish({ status }) on success and finish({ error }) on failure. finish runs emit + enrich + drain + plugin hooks.
- Re-throw errors after
finish({ error }) so the framework's own error handler still runs.
- Streaming responses: if the framework can return streaming bodies, defer the emit until the stream closes (
shouldDeferEmitForResponse; see Hono and Elysia).
- Framework SDK is an optional peer dependency: never bundle it.
- Never duplicate pipeline logic:
runEnrichAndDrain is internal to createMiddlewareLogger/finish.
- 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:
'{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):
"./{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:
- 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.
- 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:
- Framework-native accessor returns the logger (
c.get('log'), req.log, …)
- Error handling. Errors captured, event has error level + details, error re-thrown
- Route filtering. Skipped routes don't create a logger, skip drain/enrich
- Context accumulation.
logger.set() data appears in the emitted event
- Drain / enrich / keep callbacks (use
createPipelineSpies(), assertHttpEventEmitted, waitForDrainCalls, findEventViaDrain from test/helpers/framework.ts)
- Drain/enrich error resilience. Errors there never break the request