Review logging patterns
Review and improve logging patterns in TypeScript/JavaScript codebases. Transform scattered console.logs into structured wide events and convert generic errors into self-documenting structured errors.
When to Use
- Setting up evlog in a new or existing project (any supported framework)
- Reviewing code for logging best practices
- Converting console.log statements to structured logging
- Improving error handling with better context
- Configuring log draining, sampling, or enrichment
Quick Reference
| Working on... | Resource |
|---|
| Setup (CLI) | : wire evlog into the project |
| Project conventions (CLI) | : write the evlog block into the project's AGENTS.md |
| Coverage map (CLI) | : score dark entry points |
| CI gating (CLI) | evlog map --min-score / --baseline
: gate regressions |
| Wide events patterns | references/wide-events.md |
| Error handling | references/structured-errors.md |
| Code review checklist | references/code-review.md |
| Drain pipeline | references/drain-pipeline.md |
| Audit logs | build-audit-logs skill + docs |
Audit logs
For security-sensitive actions (auth, billing, admin, data export), use evlog's audit layer: a typed
field on wide events, not a parallel logger. See the
skill for end-to-end setup (
,
, denials,
,
,
,
).
typescript
log.audit({
action: 'invoice.refund',
actor: { type: 'user', id: user.id },
target: { type: 'invoice', id: invoice.id },
outcome: 'success',
})
Installation
Use the CLI (recommended on Nuxt, Nitro, Next.js, TanStack Start, Hono, Express, Fastify)
The
executable ships with the
package:
,
,
. It runs
when installed and fetches it on demand otherwise (no dependency added to the project), so it works on a project with nothing yet. Early but worth trying. It reads the project on disk (no traffic, no config needed). On the seven supported frameworks it covers the whole loop:
wire evlog in (
),
score coverage (
),
lock the score in CI (
,
). If the CLI is unavailable, the framework has no adapter yet, or the user declines, continue with the manual sections below; the skill does not depend on it.
Ask before installing anything.
1. Setup:
On a project that doesn't use evlog yet, prefer
over hand-writing the setup, since it detects the framework, reads what the project already has, and generates config, drains, enrichers, and extras in one pass. It is fully scriptable for agents:
bash
# preview everything without writing (always start here)
npx evlog init --dry-run --yes
# then apply — flags instead of prompts
npx evlog init --yes \
--service my-app \
--drain fs \
--prodDrain axiom \
--extras enrichers,pipeline,sampling \
--sampling medium
Useful flags:
(override detection:
,
,
,
,
,
,
),
(comma-separated:
,
,
,
,
,
,
,
,
,
),
(
,
,
,
,
,
,
,
),
,
(traffic tier:
,
,
,
,
),
(monorepo: which workspace packages),
. Review the
output with the user before applying. Docs:
https://www.evlog.dev/cli/init
2. Score:
bash
npx evlog map --no-write
# agents: npx evlog map --json --no-write
What you get:
- A project score and which entry points are still dark
- FIX FIRST: the three most valuable places to fix
- GOING FURTHER: opportunities (catalogs, audit coverage, AI logging, auth identity) that never cost points
- Per-file inspect:
npx evlog map <file> --no-write
shows the shape the handler could take
- Re-run after fixes and watch the score move
Work FIX FIRST in order, keep changes minimal (
,
,
,
createError({ why, fix })
), then re-run with
. Omit
only when the user wants
written.
3. Lock it in CI: and
On GitHub Actions, suggest the
evlog action (
with
): it scans the pull request base itself, so no map file has to be committed, and puts regressions on the diff and in one comment. The commands below are the same gate from a
step.
After fixing, propose making the score durable. This is where the CLI earns its keep:
bash
# in CI, after pnpm add -D @evlog/cli (pinned by the lockfile; ask first)
pnpm exec evlog map --min-score 80 # absolute gate: exits 1 below the threshold
pnpm exec evlog map --baseline # ratchet: exits 1 if this PR made things worse
compares the fresh scan against the committed
,
per entry point and per requirement, so a refactor that instruments one route and breaks another fails even if the total score is unchanged. Disabling a passing check with a comment counts as a regression too. New uninstrumented routes are listed as
without failing. Workflow: commit
once, add the
run to CI, then re-run
without
to accept an intentional change. Docs:
https://www.evlog.dev/cli/ci
When the project has an
, the gate can live there instead of in the CI step:
map: { minScore: 80, baseline: true }
makes a bare
gate the same way, and a flag still wins. The same
block turns checks off for every entry point (
rules: { 'error-catalog': 'off' }
) and leaves entry points out by file glob (
). The CLI reads these values without running the file, so they must be literals. Docs:
https://www.evlog.dev/cli/config
Early days: adapters and rules are still evolving; expect scores to move between releases. Docs:
https://www.evlog.dev/cli/map · Rules:
https://www.evlog.dev/cli/rules
Framework Setup
Nuxt
typescript
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['evlog/nuxt'],
evlog: {
env: { service: 'my-app' },
include: ['/api/**'],
},
})
,
, and
are
auto-imported.
is not: a bare one resolves to h3's, which drops
,
, and
. Import it from
.
typescript
// server/api/checkout.post.ts — no imports needed
export default defineEventHandler(async (event) => {
const log = useLogger(event)
log.set({ user: { id: user.id, plan: user.plan } })
return { success: true }
})
Drain, enrich, sampling and redaction can live in
at the app root. The module loads it on the server, and the
key in
overrides it. The browser logger reads only the
key.
typescript
// evlog.config.ts
import { defineEvlog } from 'evlog'
import { createAxiomDrain } from 'evlog/axiom'
export default defineEvlog({
service: 'my-app',
drain: createAxiomDrain(),
})
Nitro hooks in server plugins keep working next to the file, and an event reaches both drains:
typescript
// server/plugins/evlog-drain.ts
import { createAxiomDrain } from 'evlog/axiom'
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('evlog:drain', createAxiomDrain())
})
Client transport (auto-configured Vue plugin):
typescript
// nuxt.config.ts
evlog: {
transport: { enabled: true }, // logs sent to /api/_evlog/ingest
}
Client-side:
,
,
are auto-imported in components.
Next.js
Step 1: Create central config. All exports come from here:
typescript
// lib/evlog.ts
import type { DrainContext } from 'evlog'
import { createEvlog } from 'evlog/next'
import { createUserAgentEnricher, createRequestSizeEnricher } from 'evlog/enrichers'
import { createDrainPipeline } from 'evlog/pipeline'
const enrichers = [createUserAgentEnricher(), createRequestSizeEnricher()]
const pipeline = createDrainPipeline<DrainContext>({ batch: { size: 50, intervalMs: 5000 } })
const drain = pipeline(createAxiomDrain({ dataset: 'logs', apiKey: process.env.AXIOM_API_KEY! }))
export const { withEvlog, useLogger, log, createError } = createEvlog({
service: 'my-app',
sampling: {
rates: { info: 10 },
keep: [{ status: 400 }, { duration: 1000 }],
},
routes: {
'/api/auth/**': { service: 'auth-service' },
'/api/checkout/**': { service: 'checkout-service' },
},
keep: (ctx) => {
const user = ctx.context.user as { premium?: boolean } | undefined
if (user?.premium) ctx.shouldKeep = true
},
enrich: (ctx) => {
for (const enricher of enrichers) enricher(ctx)
},
drain,
})
Step 2: Wrap route handlers with
:
typescript
// app/api/checkout/route.ts
import { withEvlog, useLogger } from '@/lib/evlog'
export const POST = withEvlog(async (request: Request) => {
const log = useLogger() // Zero arguments — uses AsyncLocalStorage
log.set({ user: { id: 'user_123', plan: 'enterprise' } })
log.set({ cart: { items: 3, total: 14999 } })
return Response.json({ success: true })
})
Step 3: Server Actions. Same
wrapper:
typescript
// app/actions.ts
'use server'
import { withEvlog, useLogger } from '@/lib/evlog'
export const checkout = withEvlog(async (formData: FormData) => {
const log = useLogger()
log.set({ action: 'checkout', source: 'server-action' })
return { success: true }
})
Step 4: Middleware (optional, sets
+ timing headers):
typescript
// proxy.ts
import { evlogMiddleware } from 'evlog/next'
export const proxy = evlogMiddleware()
export const config = { matcher: ['/api/:path*'] }
Step 5: Client Provider. Wrap the root layout:
tsx
// app/layout.tsx
import { EvlogProvider } from 'evlog/next/client'
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<EvlogProvider service="my-app" transport={{ enabled: true, endpoint: '/api/evlog/ingest' }}>
{children}
</EvlogProvider>
</body>
</html>
)
}
Step 6: Client logging. In any client component:
tsx
'use client'
import { log, setIdentity, clearIdentity } from 'evlog/next/client'
setIdentity({ userId: 'usr_123' })
log.info({ action: 'checkout_click' })
clearIdentity()
Step 7 (optional): Instrumentation. Startup plus global
(SSR/RSC errors outside
). Use
defineNodeInstrumentation(() => import('./lib/evlog'))
in root
to gate Node + cache the import,
or write
/
manually. Both are valid. For custom logic, wrap evlog’s
/
inside
(compose with your own init or metrics), then re-export.
Export
from
alongside
. See framework docs for coexistence with
.
Step 8: Client ingest endpoint. Receives client logs:
typescript
// app/api/evlog/ingest/route.ts
import { NextRequest } from 'next/server'
const VALID_LEVELS = ['info', 'error', 'warn', 'debug'] as const
export async function POST(request: NextRequest) {
const origin = request.headers.get('origin')
const host = request.headers.get('host')
if (origin && new URL(origin).host !== host) {
return Response.json({ error: 'Invalid origin' }, { status: 403 })
}
const body = await request.json()
if (!body?.timestamp || !body?.level || !VALID_LEVELS.includes(body.level)) {
return Response.json({ error: 'Invalid payload' }, { status: 400 })
}
const { service: _, ...sanitized } = body
console.log('[CLIENT LOG]', JSON.stringify({ ...sanitized, service: 'my-app', source: 'client' }))
return new Response(null, { status: 204 })
}
SvelteKit
typescript
// src/hooks.server.ts
import { initLogger } from 'evlog'
import { createEvlogHooks } from 'evlog/sveltekit'
initLogger({ env: { service: 'my-app' } })
export const { handle, handleError } = createEvlogHooks()
Access the logger via
in route handlers or
from anywhere in the call stack:
typescript
// src/routes/api/users/[id]/+server.ts
import { json } from '@sveltejs/kit'
export const GET = ({ locals, params }) => {
locals.log.set({ user: { id: params.id } })
return json({ id: params.id })
}
typescript
import { useLogger } from 'evlog/sveltekit'
async function findUsers() {
const log = useLogger()
log.set({ db: { query: 'SELECT * FROM users' } })
}
Full pipeline with drain, enrich, and tail sampling:
typescript
import { createAxiomDrain } from 'evlog/axiom'
export const { handle, handleError } = createEvlogHooks({
include: ['/api/**'],
drain: createAxiomDrain(),
enrich: (ctx) => { ctx.event.region = process.env.FLY_REGION },
keep: (ctx) => {
if (ctx.duration && ctx.duration > 2000) ctx.shouldKeep = true
},
})
Nitro v3
typescript
// nitro.config.ts
import { defineConfig } from 'nitro'
import evlog from 'evlog/nitro/v3'
export default defineConfig({
modules: [evlog({ env: { service: 'my-api' } })],
})
typescript
// routes/api/checkout.post.ts
import { defineHandler } from 'nitro/h3'
import { useLogger } from 'evlog/nitro/v3'
export default defineHandler(async (event) => {
const log = useLogger(event)
log.set({ action: 'checkout' })
return { ok: true }
})
TanStack Start
TanStack Start uses Nitro v3. Install evlog and add a
:
typescript
// nitro.config.ts
import { defineConfig } from 'nitro'
import evlog from 'evlog/nitro/v3'
export default defineConfig({
experimental: { asyncContext: true },
modules: [evlog({ env: { service: 'my-app' } })],
})
Add the error handling middleware to
:
typescript
// src/routes/__root.tsx
import { createMiddleware } from '@tanstack/react-start'
import { evlogErrorHandler } from 'evlog/nitro/v3'
export const Route = createRootRoute({
server: {
middleware: [createMiddleware().server(evlogErrorHandler)],
},
})
Use
from
to access the logger:
typescript
import { useRequest } from 'nitro/context'
import type { RequestLogger } from 'evlog'
const req = useRequest()
const log = req.context.log as RequestLogger
log.set({ user: { id: 'user_123' } })
Nitro v2
typescript
// nitro.config.ts
import { defineNitroConfig } from 'nitropack/config'
import evlog from 'evlog/nitro'
export default defineNitroConfig({
modules: [evlog({ env: { service: 'my-api' } })],
})
Import
from
in routes. On Nitro v2 and v3, an
at the app root is loaded by the module, and the options passed to
override it.
NestJS
typescript
// src/app.module.ts
import { Module } from '@nestjs/common'
import { EvlogModule } from 'evlog/nestjs'
@Module({
imports: [EvlogModule.forRoot()],
})
export class AppModule {}
registers a global middleware, on both
and
.
in controllers is the evlog logger on both platforms. Do not also register
on a Nest Fastify app, or every request emits two wide events. Use
to access the request-scoped logger from any controller or service:
typescript
import { useLogger } from 'evlog/nestjs'
async function findUsers() {
const log = useLogger()
log.set({ db: { query: 'SELECT * FROM users' } })
}
Full pipeline with drain, enrich, and tail sampling:
typescript
import { createAxiomDrain } from 'evlog/axiom'
EvlogModule.forRoot({
include: ['/api/**'],
drain: createAxiomDrain(),
enrich: (ctx) => { ctx.event.region = process.env.FLY_REGION },
keep: (ctx) => {
if (ctx.duration && ctx.duration > 2000) ctx.shouldKeep = true
},
})
For async configuration with NestJS DI, use
:
typescript
EvlogModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config) => ({
drain: createAxiomDrain({ apiKey: config.get('AXIOM_API_KEY') }),
}),
})
Express
typescript
import express from 'express'
import { initLogger } from 'evlog'
import { evlog, useLogger } from 'evlog/express'
initLogger({ env: { service: 'my-api' } })
const app = express()
app.use(evlog())
app.get('/api/users', (req, res) => {
req.log.set({ users: { count: 42 } })
res.json({ users: [] })
})
Use
to access the logger from anywhere in the call stack without passing
:
typescript
import { useLogger } from 'evlog/express'
async function findUsers() {
const log = useLogger()
log.set({ db: { query: 'SELECT * FROM users' } })
}
Full pipeline with drain, enrich, and tail sampling:
typescript
import { createAxiomDrain } from 'evlog/axiom'
app.use(evlog({
include: ['/api/**'],
drain: createAxiomDrain(),
enrich: (ctx) => { ctx.event.region = process.env.FLY_REGION },
keep: (ctx) => {
if (ctx.duration && ctx.duration > 2000) ctx.shouldKeep = true
},
}))
Hono
typescript
import { Hono } from 'hono'
import { initLogger } from 'evlog'
import { evlog, type EvlogVariables } from 'evlog/hono'
initLogger({ env: { service: 'my-api' } })
const app = new Hono<EvlogVariables>()
app.use(evlog())
app.get('/api/users', (c) => {
const log = c.get('log')
log.set({ users: { count: 42 } })
return c.json({ users: [] })
})
Access the logger via
in handlers. Use
from
in the layers underneath (services, repositories) where
is not in hand. Both return the same logger:
typescript
import { useLogger } from 'evlog/hono'
async function findUsers() {
const log = useLogger()
log.set({ db: { query: 'SELECT * FROM users' } })
}
On Cloudflare Workers,
needs the
(or
) compatibility flag;
works with or without it.
Structured errors: throw
, then in
use
and pass
parsed.status as ContentfulStatusCode
to
(Hono types the status argument as
, not
).
typescript
import { createError, parseError } from 'evlog'
import type { ContentfulStatusCode } from 'hono/utils/http-status'
app.onError((error, c) => {
c.get('log').error(error)
const parsed = parseError(error)
return c.json(
{ message: parsed.message, why: parsed.why, fix: parsed.fix, link: parsed.link },
parsed.status as ContentfulStatusCode,
)
})
Full pipeline with drain, enrich, and tail sampling:
typescript
import { createAxiomDrain } from 'evlog/axiom'
app.use(evlog({
include: ['/api/**'],
drain: createAxiomDrain(),
enrich: (ctx) => { ctx.event.region = process.env.FLY_REGION },
keep: (ctx) => {
if (ctx.duration && ctx.duration > 2000) ctx.shouldKeep = true
},
}))
Fastify
typescript
import Fastify from 'fastify'
import { initLogger } from 'evlog'
import { evlog, useLogger } from 'evlog/fastify'
initLogger({ env: { service: 'my-api' } })
const app = Fastify({ logger: false })
await app.register(evlog)
app.get('/api/users', async (request) => {
request.log.set({ users: { count: 42 } })
return { users: [] }
})
is the evlog wide-event logger (shadows Fastify's built-in pino logger on the request). Fastify's pino logger remains accessible via
.
Use
to access the logger from anywhere in the call stack without passing
:
typescript
import { useLogger } from 'evlog/fastify'
async function findUsers() {
const log = useLogger()
log.set({ db: { query: 'SELECT * FROM users' } })
}
Full pipeline with drain, enrich, and tail sampling:
typescript
import { createAxiomDrain } from 'evlog/axiom'
await app.register(evlog, {
include: ['/api/**'],
drain: createAxiomDrain(),
enrich: (ctx) => { ctx.event.region = process.env.FLY_REGION },
keep: (ctx) => {
if (ctx.duration && ctx.duration > 2000) ctx.shouldKeep = true
},
})
Elysia
typescript
import { Elysia } from 'elysia'
import { initLogger } from 'evlog'
import { evlog, useLogger } from 'evlog/elysia'
initLogger({ env: { service: 'my-api' } })
const app = new Elysia()
.use(evlog())
.get('/api/users', ({ log }) => {
log.set({ users: { count: 42 } })
return { users: [] }
})
.listen(3000)
Use
to access the logger from anywhere in the call stack:
typescript
import { useLogger } from 'evlog/elysia'
async function findUsers() {
const log = useLogger()
log.set({ db: { query: 'SELECT * FROM users' } })
}
Full pipeline with drain, enrich, and tail sampling:
typescript
import { createAxiomDrain } from 'evlog/axiom'
app.use(evlog({
include: ['/api/**'],
drain: createAxiomDrain(),
enrich: (ctx) => { ctx.event.region = process.env.FLY_REGION },
keep: (ctx) => {
if (ctx.duration && ctx.duration > 2000) ctx.shouldKeep = true
},
}))
React Router
typescript
// react-router.config.ts
import type { Config } from '@react-router/dev/config'
export default {
future: {
v8_middleware: true,
},
} satisfies Config
typescript
// app/root.tsx
import { initLogger } from 'evlog'
import { evlog } from 'evlog/react-router'
initLogger({ env: { service: 'my-api' } })
export const middleware: Route.MiddlewareFunction[] = [
evlog(),
]
Access the logger via
context.get(loggerContext)
in loaders and actions:
typescript
// app/routes/api.users.$id.tsx
import { loggerContext } from 'evlog/react-router'
export async function loader({ params, context }: Route.LoaderArgs) {
const log = context.get(loggerContext)
log.set({ user: { id: params.id } })
return { users: [] }
}
Use
to access the logger from anywhere in the call stack without passing context:
typescript
import { useLogger } from 'evlog/react-router'
async function findUsers() {
const log = useLogger()
log.set({ db: { query: 'SELECT * FROM users' } })
}
Full pipeline with drain, enrich, and tail sampling:
typescript
import { createAxiomDrain } from 'evlog/axiom'
export const middleware: Route.MiddlewareFunction[] = [
evlog({
include: ['/api/**'],
drain: createAxiomDrain(),
enrich: (ctx) => { ctx.event.region = process.env.FLY_REGION },
keep: (ctx) => {
if (ctx.duration && ctx.duration > 2000) ctx.shouldKeep = true
},
}),
]
oRPC
typescript
import { os } from '@orpc/server'
import { RPCHandler } from '@orpc/server/fetch'
import { initLogger } from 'evlog'
import { evlog, withEvlog, type EvlogOrpcContext } from 'evlog/orpc'
initLogger({ env: { service: 'my-rpc' } })
const base = os.$context<EvlogOrpcContext>().use(evlog())
const router = {
ping: base.handler(({ context }) => {
context.log.set({ pinged: true })
return { ok: true }
}),
}
const handler = withEvlog(new RPCHandler(router))
export default async function fetch(request: Request) {
const { matched, response } = await handler.handle(request, { prefix: '/rpc' })
return matched ? response : new Response('Not Found', { status: 404 })
}
wraps the handler so each matched request emits one wide event;
exposes
on every procedure that descends from
and tags the wide event with
(the procedure path joined with
).
Use
to access the logger from utility modules:
typescript
import { useLogger } from 'evlog/orpc'
async function chargeCard(amount: number) {
const log = useLogger()
log.set({ payment: { amount } })
}
Full pipeline with drain, enrich, and tail sampling:
typescript
import { createAxiomDrain } from 'evlog/axiom'
const handler = withEvlog(new RPCHandler(router), {
include: ['/rpc/**'],
drain: createAxiomDrain(),
enrich: (ctx) => { ctx.event.region = process.env.FLY_REGION },
keep: (ctx) => {
if (ctx.duration && ctx.duration > 2000) ctx.shouldKeep = true
},
})
Effect
Effect 4 (
,
).
installs the
middleware on every route and provides the
service.
calls
when the layer builds and flushes a
drain when its scope closes:
typescript
import { createServer } from 'node:http'
import { NodeHttpServer, NodeRuntime } from '@effect/platform-node'
import { Effect, Layer } from 'effect'
import { HttpRouter, HttpServerResponse } from 'effect/http'
import { EvlogLogger, initLoggerLayer, routerLayer } from 'evlog/effect'
const Routes = HttpRouter.add('GET', '/api/users', Effect.gen(function* () {
const log = yield* EvlogLogger
log.set({ users: { count: 42 } })
return HttpServerResponse.jsonUnsafe({ users: [] })
}))
const Server = HttpRouter.serve(Routes.pipe(Layer.provide(routerLayer())), { disableLogger: true }).pipe(
Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })),
Layer.provide(initLoggerLayer({ env: { service: 'my-api' } })),
)
NodeRuntime.runMain(Layer.launch(Server))
There is no
and no
. The event lives in the Effect context:
in handlers and services (the requirement shows up in the effect's type),
yield* useLogger<Fields>()
for typed fields, and
in library code that should not require
(a no-op outside a unit). It runs on Cloudflare Workers without
.
typescript
import { Effect } from 'effect'
import { useLogger } from 'evlog/effect'
const chargeCard = (amount: number) => Effect.gen(function* () {
const log = yield* useLogger<{ payment: { amount: number } }>()
log.set({ payment: { amount } })
})
Failures are read from the
(the primary failure as
, the rest under
, a pure interruption as
). Effect's router answers a failed
createError({ status: 402 })
with 500, so catch it in the handler with
, call
, and return
HttpServerResponse.jsonUnsafe(body, { status: parsed.status })
built from
.
encodes declared errors as responses before the middleware: the event gets the status but no
field.
Non-HTTP work (jobs, queue consumers, Durable Object alarms) uses
.pipe(withLogger('action-name', options))
. A Durable Object
uses
.pipe(withRequestLogger(request, options))
. On Workers, provide
per request (
handler(request, Context.make(WaitUntil, ctx.waitUntil.bind(ctx)))
with
) so drains outlive the response.
folds
calls into the current wide event. Each unit continues an incoming
, and
withTracePropagation(client)
and
forward it on outgoing calls.
Full pipeline with drain, enrich, and tail sampling:
typescript
import { createAxiomDrain } from 'evlog/axiom'
routerLayer({
include: ['/api/**'],
drain: createAxiomDrain(),
enrich: (ctx) => { ctx.event.region = process.env.FLY_REGION },
keep: (ctx) => {
if (ctx.duration && ctx.duration > 2000) ctx.shouldKeep = true
},
})
Cloudflare Workers
typescript
import { initWorkersLogger, withEvlog } from 'evlog/workers'
initWorkersLogger({ env: { service: 'edge-api' } })
export default withEvlog(async (request, _env, _ctx, log) => {
log.set({ action: 'handle_request' })
return Response.json({ ok: true })
})
emits one wide event per request when the handler returns, with no manual
. Async drains are registered with
so they survive the response; streaming responses defer the emit until the body completes.
comes from
(fallback
);
,
,
,
, and the safe subset of
are captured automatically. It accepts the same options (
,
,
,
,
,
) as every other integration. For manual control (scheduled handlers, queues),
createWorkersLogger(request)
+
remains available. No ALS-based
on Workers, so pass
explicitly.
AWS Lambda
Lambda has no HTTP middleware lifecycle, so evlog behaves like standalone TypeScript, with one critical rule: one logger per invocation, never a shared module-level logger (Lambda reuses execution environments, so a shared instance leaks fields between invocations).
typescript
import { initLogger, createLogger } from 'evlog'
initLogger({ env: { service: 'my-fn' } }) // once at module load (cold start)
export async function handler(event: SQSEvent) {
for (const record of event.Records) {
const log = createLogger({ messageId: record.messageId })
try {
log.set({ queue: { source: record.eventSourceARN } })
await processMessage(record)
} catch (error) {
log.error(error as Error)
throw error
} finally {
log.emit()
}
}
}
Astro
typescript
// src/middleware.ts
import { defineMiddleware } from 'astro:middleware'
import { initLogger, createRequestLogger } from 'evlog'
initLogger({ env: { service: 'my-astro-app' } })
export const onRequest = defineMiddleware(async ({ request, locals }, next) => {
const url = new URL(request.url)
const log = createRequestLogger({ method: request.method, path: url.pathname })
locals.log = log
try {
const response = await next()
log.emit()
return response
} catch (error) {
log.error(error instanceof Error ? error : new Error(String(error)))
log.emit()
throw error
}
})
Type
in
(
interface Locals { log: RequestLogger }
). Pair with the Vite plugin (below) for auto-imports and build-time DX.
Vite Plugin (any Vite-based framework)
For any Vite-based project (SvelteKit, Astro, SolidStart, React+Vite, etc.), use the Vite plugin for auto-init, auto-imports, and build-time features:
typescript
// vite.config.ts
import evlog from 'evlog/vite'
export default defineConfig({
plugins: [
evlog({
service: 'my-app',
autoImports: true, // auto-import log, createEvlogError, parseError
strip: ['debug'], // remove log.debug() in production
sourceLocation: true, // inject file:line in dev + prod
client: { // client-side logging
transport: { endpoint: '/api/logs' },
},
}),
],
})
Server-side middleware (drain, enrich, keep, routes) is still configured in the framework integration (e.g.,
middleware for Hono/Express/SvelteKit). The Vite plugin handles build-time DX only.
Standalone TypeScript
typescript
import { initLogger, createRequestLogger } from 'evlog'
initLogger({ env: { service: 'my-worker', environment: 'production' } })
const log = createRequestLogger({ jobId: job.id })
log.set({ source: job.source, recordsSynced: 150 })
log.emit() // Manual emit required in standalone
Configuration Options
All options work in Nuxt (
key), Nitro (passed to
), Next.js (
), and standalone (
).
They can also live in
, written with
. Nuxt and Nitro load the file on their own, with module options overriding it. Other frameworks import it and pass
to
and
toMiddlewareOptions(config)
to the middleware.
writes the file,
prints what it resolves to, and
shares a base config across apps (one level). Docs:
https://www.evlog.dev/cli/config
| Option | Type | Default | Description |
|---|
| / | | | Service name in logs |
| | | Global toggle (no-ops when false) |
| | in dev | Pretty tree format vs JSON |
| | | Suppress console output. Events still go to drains |
| | All routes | Route glob patterns to log |
| | None | Route patterns to exclude (takes precedence) |
| Record<string, { service }>
| -- | Route-specific service names |
| 'debug' | 'info' | 'warn' | 'error'
| | Hard threshold for the global API and client (not request wide events). Use for probabilistic volume on requests |
| | -- | Head sampling: (0-100%) |
| | -- | Tail sampling: [{ status: 400 }, { duration: 1000 }]
|
| | -- | Drain callback (Next.js, standalone) |
| | -- | Enrich callback (Next.js) |
| | -- | Custom tail sampling callback (Next.js) |
| | in production | Enabled by default in production. to disable. Object for fine-grained control |
Nitro Hooks (Nuxt, Nitro v2/v3)
| Hook | When | Use |
|---|
| After enrichment | Send events to external services |
| After emit, before console output and drain | Add derived context |
| During emit | Custom tail sampling logic |
| Server shutdown | Flush drain pipeline buffers |
Drain Adapters
| Adapter | Import | Env Vars |
|---|
| Axiom | | , |
| OTLP | | (or the standard OTEL_EXPORTER_OTLP_[LOGS_]ENDPOINT
, , , , ). protocol: 'http/protobuf'
sends binary protobuf. semanticConventions: true
adds OTel attribute names (, , ) |
| HyperDX | | (optional ; defaults to https://in-otel.hyperdx.io
) |
| Sazabi | | (the log stream's intake URL; its key is part of the URL), optional |
| Grafana Loki | | , optional , |
| ClickHouse | | (the table must exist), optional , , , |
| PostHog | | , |
| Sentry | | |
| Better Stack | | |
| Datadog | | or , optional / |
| Grafana Loki | | , optional + (Grafana Cloud) or (multi-tenant) |
| ClickHouse | | , optional / / / |
| File System | | None (local file system) |
| Memory | | None (in-process ring buffer; optional , ). Read back with , ideal for dev-only log endpoints agents can query |
| NuxtHub | (separate package, Nuxt module) | None: stores wide events in the NuxtHub database with retention-based cleanup (set in the module options; accepts //) |
| HTTP (browser ingest) | | None (configure in code). is deprecated; same API, removed next major |
Use canonical env var names (e.g.
,
), and the same names work in every framework.
Setup pattern per framework:
typescript
// Nuxt/Nitro: server/plugins/evlog-drain.ts
import { createAxiomDrain } from 'evlog/axiom'
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('evlog:drain', createAxiomDrain())
})
// Hono / Express / Elysia: pass drain in middleware options
import { createAxiomDrain } from 'evlog/axiom'
app.use(evlog({ drain: createAxiomDrain() }))
// Fastify: pass drain in plugin options
import { createAxiomDrain } from 'evlog/axiom'
await app.register(evlog, { drain: createAxiomDrain() })
// NestJS: pass drain in module options
import { createAxiomDrain } from 'evlog/axiom'
EvlogModule.forRoot({ drain: createAxiomDrain() })
// Next.js: pass drain to createEvlog()
import { createAxiomDrain } from 'evlog/axiom'
import { createDrainPipeline } from 'evlog/pipeline'
const pipeline = createDrainPipeline<DrainContext>({ batch: { size: 50 } })
const drain = pipeline(createAxiomDrain())
// then: createEvlog({ ..., drain })
// Standalone: pass drain to initLogger()
initLogger({ env: { service: 'my-app' }, drain: createAxiomDrain() })
See references/drain-pipeline.md for batching, retry, and buffer overflow config.
Enrichers
Built-in:
createUserAgentEnricher()
,
,
createRequestSizeEnricher()
,
createTraceContextEnricher()
, all from
. Each accepts
(default
). Use
to compose all four in one call:
typescript
import { createDefaultEnrichers } from 'evlog/enrichers'
app.use(evlog({ enrich: createDefaultEnrichers() }))
typescript
// Nuxt/Nitro: server/plugins/evlog-enrich.ts
import { createUserAgentEnricher, createGeoEnricher } from 'evlog/enrichers'
export default defineNitroPlugin((nitroApp) => {
const enrichers = [createUserAgentEnricher(), createGeoEnricher()]
nitroApp.hooks.hook('evlog:enrich', (ctx) => {
for (const enricher of enrichers) enricher(ctx)
})
})
// Next.js: in lib/evlog.ts
createEvlog({
enrich: (ctx) => {
for (const enricher of enrichers) enricher(ctx)
ctx.event.region = process.env.VERCEL_REGION
},
})
Auto-Redaction (PII Protection)
Built-in redaction scrubs sensitive data from wide events
before console output and
before any drain sees the data.
Enabled by default in production (
NODE_ENV === 'production'
), disabled in development. Uses
smart partial masking, preserving enough context for debugging.
typescript
// Disable in production (opt-out)
evlog: { redact: false }
// Add custom paths on top of built-ins
evlog: {
redact: {
paths: ['user.password', 'headers.authorization'],
}
}
// Only specific built-ins
evlog: {
redact: {
builtins: ['email', 'creditCard'],
}
}
// No built-ins, only custom (uses flat [REDACTED] replacement)
evlog: {
redact: {
builtins: false,
paths: ['user.ssn'],
patterns: [/SECRET_\w+/g],
}
}
Built-in patterns with smart masking output:
| Pattern | Example Input | Masked Output |
|---|
| | |
| | |
| | |
| | |
| | |
| | |
| | |
Opt-in credential patterns match the value under any key and inside longer strings. They are off by default, and
runs only the names it lists, so add them next to the defaults:
| Pattern | Example Input | Masked Output |
|---|
| followed by 36 characters | |
| -----BEGIN RSA PRIVATE KEY-----
block | -----BEGIN RSA PRIVATE KEY-----***
|
| postgres://app:hunter2@db
| |
typescript
evlog: {
redact: {
builtins: ['creditCard', 'email', 'ipv4', 'phone', 'jwt', 'bearer', 'iban', 'secrets', 'apiKeys', 'privateKey', 'urlPassword'],
}
}
covers GitHub, npm, Anthropic, OpenAI, Stripe, Slack and AWS key IDs by their fixed prefix. Suggest them when the code logs provider tokens, private keys or connection strings, even under keys that
does not cover (
,
,
).
Works in all frameworks: Nuxt (
config), Nitro (
module options), Next.js (
), standalone (
), and all middleware integrations (Hono, Express, Fastify, Elysia, NestJS).
AI SDK Integration
Capture token usage, tool calls, model info, streaming metrics, tool execution timing, cost estimation, and embedding metadata from the Vercel AI SDK into wide events. Import from
. Requires
as a peer dependency.
Basic setup (middleware)
typescript
import { createAILogger } from 'evlog/ai'
const log = useLogger(event) // or any RequestLogger
const ai = createAILogger(log)
const result = streamText({
model: ai.wrap('anthropic/claude-sonnet-4.6'), // accepts string or model object
messages,
})
uses model middleware to transparently capture all LLM calls. Works with
,
, and
.
Telemetry integration (deeper observability)
For tool execution timing, success/failure tracking, and total generation wall time, add
:
typescript
import { createAILogger, createEvlogIntegration } from 'evlog/ai'
const ai = createAILogger(log)
const agent = new ToolLoopAgent({
model: ai.wrap('anthropic/claude-sonnet-4.6'),
tools: { searchWeb, queryDatabase },
stopWhen: stepCountIs(5),
telemetry: {
integrations: [createEvlogIntegration(ai)],
},
})
This adds
(per-tool
{ name, durationMs, success, error? }
) and
to the wide event.
Embeddings
typescript
const { embedding, usage } = await embed({ model: embeddingModel, value: query })
ai.captureEmbed({ usage, model: 'text-embedding-3-small', dimensions: 1536 })
For
, pass the batch count:
typescript
ai.captureEmbed({ usage, model: 'text-embedding-3-small', count: documents.length })
Cost estimation
Pass a pricing map to get
in the wide event:
typescript
const ai = createAILogger(log, {
cost: {
'claude-sonnet-4.6': { input: 3, output: 15 },
'gpt-4o': { input: 2.5, output: 10 },
},
})
Wide event field
Includes:
,
,
,
,
,
,
,
,
,
,
,
,
,
,
,
(via telemetry integration),
(via telemetry integration),
(via
),
(via
option).
Anti-patterns to detect:
| Anti-Pattern | Fix |
|---|
| Manual token tracking in | : middleware captures automatically |
console.log('tokens:', result.usage)
| : structured fields in wide event |
| No AI observability | Add + |
| No tool execution timing | Add createEvlogIntegration(ai)
to |
| Manual cost calculation | Use option in |
Related Capabilities (pointers)
Recommend these when the review surfaces the matching need. Each has full docs on evlog.dev:
Structured Errors
typescript
import { createError } from 'evlog' // required in Nuxt too: a bare createError is h3's
// Minimal
throw createError({ message: 'Database connection failed', status: 500 })
// Standard
throw createError({ message: 'Payment failed', status: 402, why: 'Card declined by issuer' })
// Complete
throw createError({
message: 'Payment failed',
status: 402,
why: 'Card declined by issuer - insufficient funds',
fix: 'Please use a different payment method or contact your bank',
link: 'https://docs.example.com/payments/declined',
cause: originalError,
})
// Backend-only context (wide events / drains — never HTTP body or parseError())
throw createError({
message: 'Not allowed',
status: 403,
why: 'Insufficient permissions',
internal: { correlationId: 'req_abc', resourceId: 'proj_123' },
})
Frontend: extract user-facing fields with
(
is never returned to clients):
typescript
import { parseError } from 'evlog'
const error = parseError(err)
// error.message, error.status, error.why, error.fix, error.link
See references/structured-errors.md for common patterns and templates.
Anti-Patterns to Detect
| Anti-Pattern | Fix |
|---|
| Multiple in one function | Single wide event with |
| throw createError({ message, status, why, fix })
|
console.error(e); throw e
| log.error(e); throw createError(...)
|
| No logging in request handlers | Add / / |
| Flat log data | Grouped objects: { user: {...}, cart: {...} }
|
| Logging sensitive data | Explicit fields: { user: { id: body.id, plan: body.plan } }
+ enable |
| Putting support-only IDs in / | Use createError({ ..., internal: { ... } })
for non-user-facing diagnostics |
See references/code-review.md for the full checklist.
Loading Reference Files
Load based on what you're working on, and do not load all at once:
- Designing wide events → references/wide-events.md
- Improving errors → references/structured-errors.md
- Full code review → references/code-review.md
- Drain pipeline setup → references/drain-pipeline.md