
Review Logging Patterns
- 720 installs
- 1.7k repo stars
- Updated August 4, 2026
- hugorcd/evlog
This is a copy of review-logging-patterns by evlog.dev - installs and ranking accrue to the original listing.
review-logging-patterns is a code review skill that audits console.log and error patterns and migrates them to structured evlog wide events for developers who need production-ready observability across TypeScript and Jav
About
review-logging-patterns is an agent skill from hugorcd/evlog (metadata version 0.5, MIT license) that reviews TypeScript and JavaScript codebases for logging anti-patterns. It detects console.log spam, unstructured errors, and missing request context, then recommends evlog wide events, structured errors, sampling, enrichers, and drain adapters for Axiom, OTLP, HyperDX, PostHog, Sentry, and Better Stack. Setup guidance spans Nuxt, Next.js, SvelteKit, Nitro, TanStack Start, React Router, NestJS, Express, Hono, Fastify, Elysia, Cloudflare Workers, and standalone TypeScript, including AI SDK token usage and streaming metrics. Developers invoke it before shipping services that still rely on printf debugging or lack correlated error context in production logs.
- Detects console.log spam, unstructured errors, and missing context across TypeScript/JavaScript codebases
- Guides evlog setup on 13 frameworks including Nuxt, Next.js, SvelteKit, NestJS, Express, Hono, and Cloudflare Workers
- Recommends wide events patterns, structured errors, drain adapters (Axiom, OTLP, HyperDX, PostHog, Sentry, Better Stack)
- Covers sampling, enrichers, and AI SDK integration for token usage, tool calls, and streaming metrics
- Delivers concrete migration steps from scattered logs to self-documenting structured logging
Review Logging Patterns by the numbers
- 720 all-time installs (skills.sh)
- +4 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/hugorcd/evlog --skill review-logging-patternsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 720 |
|---|---|
| repo stars | ★ 1.7k |
| Security audit | 3 / 3 scanners passed |
| Last updated | August 4, 2026 |
| Repository | hugorcd/evlog ↗ |
How do you replace console.log with structured logging?
Audit existing console.log and error patterns then replace them with structured wide events and evlog.
Who is it for?
TypeScript backend or full-stack teams preparing production observability across Next.js, NestJS, Hono, or Cloudflare Workers.
Skip if: Projects already standardized on a different structured logging stack with no plan to evaluate evlog.
When should I use this skill?
A codebase still uses console.log-heavy debugging or lacks structured error context before a production deploy.
What you get
Structured evlog events, configured drain adapters, sampling rules, and framework-specific logging setup files.
- Structured logging plan
- evlog configuration
- Drain adapter setup
By the numbers
- Skill metadata version: 0.5
- Covers 12+ JavaScript/TypeScript frameworks and runtimes
- Supports 6 named drain adapters: Axiom, OTLP, HyperDX, PostHog, Sentry, Better Stack
Files
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 |
|---|---|
| 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 audit field on wide events, not a parallel logger. See the `build-audit-logs` skill for end-to-end setup (log.audit, withAudit, denials, auditEnricher, auditOnly, signed, mockAudit).
log.audit({
action: 'invoice.refund',
actor: { type: 'user', id: user.id },
target: { type: 'invoice', id: invoice.id },
outcome: 'success',
})Docs: https://www.evlog.dev/use-cases/audit/overview
Installation
npm install evlog---
Framework Setup
Nuxt
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['evlog/nuxt'],
evlog: {
env: { service: 'my-app' },
include: ['/api/**'],
},
})All evlog functions (useLogger, createError, parseError, log) are auto-imported — no import statements needed.
// 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, and tail sampling use Nitro hooks in server plugins:
// 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):
// nuxt.config.ts
evlog: {
transport: { enabled: true }, // logs sent to /api/_evlog/ingest
}Client-side: log, setIdentity, clearIdentity are auto-imported in components.
Next.js
Step 1: Create central config — all exports come from here:
// 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 withEvlog():
// 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 withEvlog() wrapper:
// 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 x-request-id + timing headers):
// proxy.ts
import { evlogMiddleware } from 'evlog/next'
export const proxy = evlogMiddleware()
export const config = { matcher: ['/api/:path*'] }Step 5: Client Provider — wrap root layout:
// 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:
'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 + global onRequestError (SSR/RSC errors outside withEvlog). Use defineNodeInstrumentation(() => import('./lib/evlog')) in root instrumentation.ts to gate Node + cache the import, or write register/onRequestError manually — both are valid. For custom logic, wrap evlog’s register/onRequestError inside lib/evlog.ts (compose with your own init or metrics), then re-export.
Export createInstrumentation() from lib/evlog.ts alongside createEvlog(). See framework docs for coexistence with lockLogger.
Step 8: Client ingest endpoint — receives client logs:
// 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
// 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 event.locals.log in route handlers or useLogger() from anywhere in the call stack:
// 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 })
}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:
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
// nitro.config.ts
import { defineConfig } from 'nitro'
import evlog from 'evlog/nitro/v3'
export default defineConfig({
modules: [evlog({ env: { service: 'my-api' } })],
})// 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 nitro.config.ts:
// 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 __root.tsx:
// 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 useRequest() from nitro/context to access the logger:
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
// nitro.config.ts
import { defineNitroConfig } from 'nitropack/config'
import evlog from 'evlog/nitro'
export default defineNitroConfig({
modules: [evlog({ env: { service: 'my-api' } })],
})Import useLogger from evlog/nitro in routes.
NestJS
// src/app.module.ts
import { Module } from '@nestjs/common'
import { EvlogModule } from 'evlog/nestjs'
@Module({
imports: [EvlogModule.forRoot()],
})
export class AppModule {}EvlogModule.forRoot() registers a global middleware. Use useLogger() to access the request-scoped logger from any controller or service:
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:
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 forRootAsync():
EvlogModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config) => ({
drain: createAxiomDrain({ apiKey: config.get('AXIOM_API_KEY') }),
}),
})Express
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 useLogger() to access the logger from anywhere in the call stack without passing req:
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:
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
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 c.get('log') in handlers. No useLogger() — use c.get('log') and pass it down explicitly, or use Express/Fastify/Elysia if you need useLogger() across async boundaries.
Structured errors: throw createError(), then in app.onError use parseError() and pass parsed.status as ContentfulStatusCode to c.json() (Hono types the status argument as ContentfulStatusCode, not number).
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:
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
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: [] }
})request.log is the evlog wide-event logger (shadows Fastify's built-in pino logger on the request). Fastify's pino logger remains accessible via fastify.log.
Use useLogger() to access the logger from anywhere in the call stack without passing request:
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:
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
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 useLogger() to access the logger from anywhere in the call stack:
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:
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
// react-router.config.ts
import type { Config } from '@react-router/dev/config'
export default {
future: {
v8_middleware: true,
},
} satisfies Config// 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:
// 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 useLogger() to access the logger from anywhere in the call stack without passing context:
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:
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
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 })
}withEvlog() wraps the handler so each matched request emits one wide event; os.use(evlog()) exposes context.log on every procedure that descends from base and tags the wide event with operation (the procedure path joined with .).
Use useLogger() to access the logger from utility modules:
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:
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
},
})Cloudflare Workers
import { initWorkersLogger, createWorkersLogger } from 'evlog/workers'
initWorkersLogger({ env: { service: 'edge-api' } })
export default {
async fetch(request: Request) {
const log = createWorkersLogger(request)
try {
log.set({ route: 'health' })
const response = new Response('ok', { status: 200 })
log.emit({ status: response.status })
return response
} catch (error) {
log.error(error as Error)
log.emit({ status: 500 })
throw error
}
},
}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:
// 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., evlog() middleware for Hono/Express/SvelteKit). The Vite plugin handles build-time DX only.
Standalone 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 (evlog key), Nitro (passed to evlog()), Next.js (createEvlog()), and standalone (initLogger()).
| Option | Type | Default | Description |
|---|---|---|---|
env.service / service | string | 'app' | Service name in logs |
enabled | boolean | true | Global toggle (no-ops when false) |
pretty | boolean | true in dev | Pretty tree format vs JSON |
silent | boolean | false | Suppress console output. Events still go to drains |
include | string[] | All routes | Route glob patterns to log |
exclude | string[] | None | Route patterns to exclude (takes precedence) |
routes | Record<string, { service }> | -- | Route-specific service names |
minLevel | `'debug' \ | 'info' \ | 'warn' \ |
sampling.rates | object | -- | Head sampling: { info: 10, warn: 50 } (0-100%) |
sampling.keep | array | -- | Tail sampling: [{ status: 400 }, { duration: 1000 }] |
drain | (ctx) => void | -- | Drain callback (Next.js, standalone) |
enrich | (ctx) => void | -- | Enrich callback (Next.js) |
keep | (ctx) => void | -- | Custom tail sampling callback (Next.js) |
redact | `boolean \ | RedactConfig` | true in production |
Nitro Hooks (Nuxt, Nitro v2/v3)
| Hook | When | Use |
|---|---|---|
evlog:drain | After enrichment | Send events to external services |
evlog:enrich | After emit, before drain | Add derived context |
evlog:emit:keep | During emit | Custom tail sampling logic |
close | Server shutdown | Flush drain pipeline buffers |
---
Drain Adapters
| Adapter | Import | Env Vars |
|---|---|---|
| Axiom | evlog/axiom | AXIOM_API_KEY, AXIOM_DATASET |
| OTLP | evlog/otlp | OTLP_ENDPOINT (or OTEL_EXPORTER_OTLP_ENDPOINT) |
| HyperDX | evlog/hyperdx | HYPERDX_API_KEY (optional HYPERDX_OTLP_ENDPOINT; defaults to https://in-otel.hyperdx.io) |
| PostHog | evlog/posthog | POSTHOG_API_KEY, POSTHOG_HOST |
| Sentry | evlog/sentry | SENTRY_DSN |
| Better Stack | evlog/better-stack | BETTER_STACK_API_KEY |
| Datadog | evlog/datadog | DD_API_KEY or DATADOG_API_KEY, optional DD_SITE / DATADOG_LOGS_URL |
| File System | evlog/fs | None (local file system) |
| HTTP (browser ingest) | evlog/http | None (configure endpoint in code). evlog/browser is deprecated; same API, removed next major |
Use canonical env var names (e.g. AXIOM_API_KEY, BETTER_STACK_API_KEY) — the same names work in every framework.
Setup pattern per framework:
// 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(), createGeoEnricher(), createRequestSizeEnricher(), createTraceContextEnricher() — all from evlog/enrichers.
// 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.
// 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 |
|---|---|---|
creditCard | 4111111111111111 | ****1111 |
email | alice@example.com | a***@***.com |
ipv4 | 192.168.1.100 | ***.***.***.100 |
phone | +33 6 12 34 56 78 | +33 ****5678 |
jwt | eyJhbGciOi... | eyJ***.*** |
bearer | Bearer sk_live_abc... | Bearer *** |
iban | FR76 3000 6000 ...189 | FR76****189 |
Works in all frameworks: Nuxt (evlog config), Nitro (evlog() module options), Next.js (createEvlog()), standalone (initLogger()), 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 evlog/ai. Requires ai >= 6.0.0 as a peer dependency.
Basic setup (middleware)
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,
})ai.wrap() uses model middleware to transparently capture all LLM calls. Works with generateText, streamText, and ToolLoopAgent.
Telemetry integration (deeper observability)
For tool execution timing, success/failure tracking, and total generation wall time, add createEvlogIntegration():
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),
experimental_telemetry: {
isEnabled: true,
integrations: [createEvlogIntegration(ai)],
},
})This adds ai.tools (per-tool { name, durationMs, success, error? }) and ai.totalDurationMs to the wide event.
Embeddings
const { embedding, usage } = await embed({ model: embeddingModel, value: query })
ai.captureEmbed({ usage, model: 'text-embedding-3-small', dimensions: 1536 })For embedMany, pass the batch count:
ai.captureEmbed({ usage, model: 'text-embedding-3-small', count: documents.length })Cost estimation
Pass a pricing map to get ai.estimatedCost in the wide event:
const ai = createAILogger(log, {
cost: {
'claude-sonnet-4.6': { input: 3, output: 15 },
'gpt-4o': { input: 2.5, output: 10 },
},
})Wide event ai field
Includes: calls, model, provider, inputTokens, outputTokens, totalTokens, cacheReadTokens, reasoningTokens, finishReason, toolCalls, steps, msToFirstChunk, msToFinish, tokensPerSecond, error, tools (via telemetry integration), totalDurationMs (via telemetry integration), embedding (via captureEmbed), estimatedCost (via cost option).
Anti-patterns to detect:
| Anti-Pattern | Fix |
|---|---|
Manual token tracking in onFinish | ai.wrap() — middleware captures automatically |
console.log('tokens:', result.usage) | ai.wrap() — structured ai.* fields in wide event |
| No AI observability | Add createAILogger(log) + ai.wrap() |
| No tool execution timing | Add createEvlogIntegration(ai) to experimental_telemetry.integrations |
| Manual cost calculation | Use cost option in createAILogger() |
---
Structured Errors
import { createError } from 'evlog' // or auto-imported in Nuxt
// 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 parseError() (internal is never returned to clients):
import { parseError } from 'evlog'
const error = parseError(err)
// error.message, error.status, error.why, error.fix, error.linkSee references/structured-errors.md for common patterns and templates.
---
Anti-Patterns to Detect
| Anti-Pattern | Fix |
|---|---|
Multiple console.log in one function | Single wide event with log.set() |
throw new Error('...') | throw createError({ message, status, why, fix }) |
console.error(e); throw e | log.error(e); throw createError(...) |
| No logging in request handlers | Add useLogger(event) / useLogger() / createRequestLogger() |
Flat log data { uid, n, t } | Grouped objects: { user: {...}, cart: {...} } |
Logging sensitive data log.set({ user: body }) | Explicit fields: { user: { id: body.id, plan: body.plan } } + enable redact: true |
Putting support-only IDs in why / message | 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 — 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
Code Review Checklist
Use this checklist when reviewing code for logging best practices and evlog adoption.
Quick Scan
Run through these checks first to identify improvement opportunities:
1. Console Statement Audit
Search for these patterns:
// ❌ Patterns to find and transform
console.log(...)
console.error(...)
console.warn(...)
console.info(...)
console.debug(...)Questions to ask:
- Are there multiple console statements in one function?
- Are they logging request/response data?
- Could they be consolidated into a wide event?
2. Error Pattern Audit
Search for these patterns:
// ❌ Generic errors
throw new Error('...')
throw Error('...')
// ❌ Re-throwing without context
catch (error) {
throw error
}
// ❌ Logging and throwing
catch (error) {
console.error(error)
throw error
}Questions to ask:
- Does the error message explain what happened?
- Is there a
whyexplaining the root cause? - Is there a
fixsuggesting a solution? - Is the original error preserved as
cause?
3. Request Handler Audit
For each API route/handler, check:
// ❌ Missing request context
export default defineEventHandler(async (event) => {
// No logging at all, or scattered console.logs
})Questions to ask:
- Is there a request-scoped logger?
- Is context accumulated throughout the request?
- Is there a single emit at the end?
Detailed Review
Console.log Transformations
Single Debug Log
// ❌ Before
console.log('Processing user:', userId)
// ✅ After - if part of a larger operation
log.set({ user: { id: userId } })
// ✅ After - if standalone debug
log.debug('user', `Processing user ${userId}`)Multiple Related Logs
// ❌ Before
console.log('Starting checkout')
console.log('User:', user.id)
console.log('Cart items:', cart.items.length)
console.log('Total:', cart.total)
// ✅ After
log.info({
action: 'checkout',
user: { id: user.id },
cart: { items: cart.items.length, total: cart.total },
})Request Lifecycle Logs
// server/api/process.post.ts
// ❌ Before
export default defineEventHandler(async (event) => {
console.log('Request started')
const user = await getUser(event)
console.log('User loaded')
const result = await processData(user)
console.log('Processing complete')
return result
})
// ✅ After (Nuxt - auto-imported, no import needed)
// For Nitro v3: import { useLogger } from 'evlog/nitro/v3'
// For Nitro v2: import { useLogger } from 'evlog/nitro'
export default defineEventHandler(async (event) => {
const log = useLogger(event)
const user = await getUser(event)
log.set({ user: { id: user.id } })
const result = await processData(user)
log.set({ result: { id: result.id } })
return result
// emit() called automatically
})Error Transformations
Generic Error
// ❌ Before
throw new Error('Failed to create user')
// ✅ After
throw createError({
message: 'Failed to create user',
why: 'Email address already registered',
fix: 'Use a different email or log in to existing account',
link: 'https://your-app.com/docs/registration',
})Wrapped Error Without Context
// ❌ Before
try {
await externalApi.call()
} catch (error) {
throw new Error('API call failed')
}
// ✅ After
try {
await externalApi.call()
} catch (error) {
throw createError({
message: 'External API call failed',
why: `API returned: ${error.message}`,
fix: 'Check API credentials and try again',
link: 'https://api-docs.example.com/errors',
cause: error,
})
}Log-and-Throw Anti-pattern
// ❌ Before
try {
await riskyOperation()
} catch (error) {
console.error('Operation failed:', error)
throw error
}
// ✅ After
try {
await riskyOperation()
} catch (error) {
log.error(error, { step: 'riskyOperation' })
throw createError({
message: 'Operation failed',
why: error.message,
fix: 'Check input and retry',
cause: error,
})
}Request Handler Transformations
No Logging
// server/api/orders.post.ts
// ❌ Before
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const result = await processOrder(body)
return result
})
// ✅ After (Nuxt - auto-imported, no import needed)
// For Nitro v3: import { useLogger } from 'evlog/nitro/v3'
// For Nitro v2: import { useLogger } from 'evlog/nitro'
import { createError } from 'evlog'
export default defineEventHandler(async (event) => {
const log = useLogger(event)
const body = await readBody(event)
log.set({ order: { items: body.items?.length } })
try {
const result = await processOrder(body)
log.set({ result: { orderId: result.id, status: result.status } })
return result
} catch (error) {
log.error(error, { step: 'processOrder' })
throw createError({
message: 'Order processing failed',
why: error.message,
fix: 'Check the order data and try again',
})
}
// emit() called automatically
})Review Checklist Summary
Logging
- [ ] No raw
console.logstatements in production code - [ ] Request handlers use
useLogger(event)(Nuxt/Nitro) orcreateRequestLogger()(standalone) - [ ] Context is accumulated with
log.set()throughout the request - [ ]
emit()is automatic withuseLogger(), manual withcreateRequestLogger() - [ ] Wide events include: user, business context, outcome
Errors
- [ ] All errors use
createError()instead ofnew Error()(import fromevlog) - [ ] Every error has a clear
messageand appropriatestatuscode - [ ] Complex errors include
whyexplaining root cause - [ ] Fixable errors include
fixwith actionable steps - [ ] Documented errors include
linkto docs - [ ] Wrapped errors preserve
cause - [ ] Support-only or sensitive diagnostics use
internal, notmessage/why/fix
Frontend Error Handling
- [ ] API errors are caught and displayed with full context (message, why, fix)
- [ ] Toasts or error components use the structured data from
error.data.data - [ ] Links to documentation are actionable (buttons/links in toasts)
Context
- [ ] User context includes: id, plan/subscription, relevant business data
- [ ] Request context includes: method, path, requestId
- [ ] Business context is domain-specific and useful for debugging
- [ ] No sensitive data in logs (passwords, tokens, full card numbers)
Anti-Pattern Summary
| Anti-Pattern | Fix |
|---|---|
Multiple console.log in one function | Single wide event with useLogger(event).set() |
throw new Error('...') | throw createError({ message, status, why, fix }) |
console.error(e); throw e | log.error(e); throw createError(...) |
| No logging in request handlers | Add useLogger(event) (Nuxt/Nitro) or createRequestLogger() (standalone) |
| Flat log data | Grouped objects: { user: {...}, cart: {...} } |
| Abbreviated field names | Descriptive names: userId not uid |
Suggested Review Comments
Use these when leaving review feedback:
Console.log Found
Consider using evlog's wide event pattern here. Instead of multiple console.log statements, use useLogger(event) to accumulate context and emit a single comprehensive event.Generic Error
This error would benefit from evlog's structured error pattern. Consider usingimport { createError } from 'evlog'andcreateError({ message, status, why, fix })to provide more debugging context.
Missing Request Context
This handler would benefit from request-scoped logging. Add useLogger(event) at the start to capture context throughout the request lifecycle.Good Logging (Positive Feedback)
Nice use of wide events here! The context is well-structured and will be very useful for debugging.
Drain Pipeline Reference
The drain pipeline wraps any adapter to add batching, retry with backoff, and buffer overflow protection. Use it in production to reduce network overhead and handle transient failures.
When to Recommend
- The user has a drain adapter (Axiom, OTLP, custom) and is deploying to production
- High-throughput scenarios where one HTTP request per event is wasteful
- The user needs retry logic for unreliable backends
- The user is implementing batching manually with
setIntervaland arrays
Basic Setup
// server/plugins/evlog-drain.ts
import type { DrainContext } from 'evlog'
import { createDrainPipeline } from 'evlog/pipeline'
import { createAxiomDrain } from 'evlog/axiom'
export default defineNitroPlugin((nitroApp) => {
const pipeline = createDrainPipeline<DrainContext>()
const drain = pipeline(createAxiomDrain())
nitroApp.hooks.hook('evlog:drain', drain)
nitroApp.hooks.hook('close', () => drain.flush())
})Important: Always call drain.flush() on server close hook. Without it, buffered events are lost when the process exits.
Full Configuration
const pipeline = createDrainPipeline<DrainContext>({
batch: {
size: 50, // Max events per batch (default: 50)
intervalMs: 5000, // Max wait before flushing partial batch (default: 5000)
},
retry: {
maxAttempts: 3, // Total attempts including first (default: 3)
backoff: 'exponential', // 'exponential' | 'linear' | 'fixed' (default: 'exponential')
initialDelayMs: 1000, // Base delay for first retry (default: 1000)
maxDelayMs: 30000, // Upper bound for any retry delay (default: 30000)
},
maxBufferSize: 1000, // Max buffered events; oldest dropped on overflow (default: 1000)
onDropped: (events, error) => {
// Called when events are dropped (overflow or retry exhaustion)
console.error(`[evlog] Dropped ${events.length} events:`, error?.message)
},
})How It Works
1. drain(ctx) pushes a single event into the buffer 2. When buffer.length >= batch.size, the batch is flushed immediately 3. If the batch isn't full, a timer starts; after intervalMs, whatever is buffered gets flushed 4. On flush, the drain function receives T[] (always an array) 5. If the drain throws, the batch is retried with the configured backoff 6. After maxAttempts failures, onDropped is called and the batch is discarded 7. If the buffer exceeds maxBufferSize, the oldest event is dropped and onDropped is called
Backoff Strategies
| Strategy | Delay Pattern | Best For |
|---|---|---|
exponential | 1s, 2s, 4s, 8s... | Default. Transient failures needing recovery time |
linear | 1s, 2s, 3s, 4s... | Predictable delay growth |
fixed | 1s, 1s, 1s, 1s... | Rate-limited APIs with known cooldown |
Returned Drain Function API
const drain = pipeline(myDrainFn)
drain(ctx) // Push a single event (synchronous, non-blocking)
await drain.flush() // Force-flush all buffered events
drain.pending // Number of events currently buffered (readonly)Common Patterns
With multiple adapters
const axiom = createAxiomDrain()
const otlp = createOTLPDrain()
const pipeline = createDrainPipeline<DrainContext>()
const drain = pipeline(async (batch) => {
await Promise.allSettled([axiom(batch), otlp(batch)])
})Custom drain function
const pipeline = createDrainPipeline<DrainContext>({ batch: { size: 100 } })
const drain = pipeline(async (batch) => {
await fetch('https://your-service.com/logs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(batch.map(ctx => ctx.event)),
})
})Low-traffic with longer interval
const pipeline = createDrainPipeline<DrainContext>({
batch: { size: 10, intervalMs: 30000 }, // Flush every 30s or 10 events
})Anti-Patterns
Manual batching with setInterval
// ❌ No retry, no overflow protection, no flush on shutdown
const batch: WideEvent[] = []
setInterval(() => {
if (batch.length > 0) fetch(...)
}, 5000)Transform to:
// ✅ Use the pipeline
const pipeline = createDrainPipeline<DrainContext>()
const drain = pipeline(async (batch) => { await fetch(...) })
nitroApp.hooks.hook('close', () => drain.flush())Missing flush on shutdown
// ❌ Buffered events lost on process exit
nitroApp.hooks.hook('evlog:drain', drain)Fix:
// ✅ Always flush on close
nitroApp.hooks.hook('evlog:drain', drain)
nitroApp.hooks.hook('close', () => drain.flush())Review Checklist
- [ ] Pipeline wraps the adapter for production use
- [ ]
drain.flush()called on serverclosehook - [ ]
onDroppedcallback logs or reports dropped events - [ ] Batch size and interval are appropriate for the traffic volume
- [ ]
maxBufferSizeis set to prevent memory leaks under load
Structured Errors Guide
Structured errors provide context that helps developers understand what happened, why it happened, and how to fix it.
The Problem with Generic Errors
// ❌ Useless errors
throw new Error('Something went wrong')
throw new Error('Failed')
throw new Error('Invalid input')
// ❌ Missing context
throw new Error('Payment failed') // Why? How do I fix it?When these errors reach your logs or monitoring, you have no idea:
- What actually failed
- Why it failed
- How to fix it
- Where to find more information
Structured Error Anatomy
import { createError } from 'evlog'
throw createError({
message: 'Payment failed', // What happened
status: 402, // HTTP status code
why: 'Card declined by issuer', // Why it happened
fix: 'Try a different payment method', // How to fix it
link: 'https://docs.example.com/...', // More information
cause: originalError, // Original error
internal: { // Optional: backend / logs only
correlationId: 'pay_abc',
processorCode: 'card_declined',
},
})internal (backend-only)
- Use
internalfor IDs, gateway codes, or diagnostics that must not appear in HTTP error bodies or in client-sideparseError()results. - Access in server code via `error.internal`. Values are omitted from `toJSON()` and from framework serializers; they are included on wide events under `error.internal` when the error is captured with `log.error()` (or equivalent automatic capture).
- Stored with a non-enumerable symbol so
JSON.stringify(error)does not leakinternal; devtools may show it as[Symbol(evlog.error.internal)].
Console Output (Development)
Error: Payment failed
Why: Card declined by issuer
Fix: Try a different payment method
More info: https://docs.example.com/payments/declined
Caused by: StripeCardError: card_declinedJSON Output (Production)
{
"name": "EvlogError",
"message": "Payment failed",
"why": "Card declined by issuer",
"fix": "Try a different payment method",
"link": "https://docs.example.com/payments/declined",
"cause": {
"name": "StripeCardError",
"message": "card_declined"
},
"stack": "..."
}Field Guidelines
message - What Happened
User-facing description of what went wrong.
// ✅ Good - clear, actionable
message: 'Failed to sync repository'
message: 'Unable to process payment'
message: 'User not found'
// ❌ Bad - vague, unhelpful
message: 'Error'
message: 'Something went wrong'
message: 'Failed'why - Why It Happened
Technical explanation for debugging.
// ✅ Good - specific, technical
why: 'GitHub API rate limit exceeded (403)'
why: 'Card declined by issuer: insufficient_funds'
why: 'No user with ID "user_123" exists in database'
// ❌ Bad - just restating the message
why: 'It failed'
why: 'Error occurred'fix - How to Fix It
Actionable steps to resolve the issue.
// ✅ Good - specific actions
fix: 'Wait 1 hour or use a different API token'
fix: 'Use a different payment method or contact your bank'
fix: 'Check the user ID and try again'
// ❌ Bad - not actionable
fix: 'Fix the error'
fix: 'Try again'link - More Information
Documentation URL for detailed troubleshooting.
// ✅ Good - specific documentation
link: 'https://docs.github.com/en/rest/rate-limit'
link: 'https://docs.stripe.com/declines/codes'
link: 'https://your-app.com/docs/errors/user-not-found'cause - Original Error
The underlying error that triggered this one.
try {
await stripe.charges.create(...)
} catch (error) {
throw createError({
message: 'Payment failed',
why: `Stripe error: ${error.code}`,
fix: 'Contact support with error code',
cause: error, // Preserves original stack trace
})
}Common Error Patterns
API/External Service Errors
// Rate limiting
throw createError({
message: 'GitHub sync temporarily unavailable',
status: 429,
why: 'API rate limit exceeded (5000/hour)',
fix: 'Wait until rate limit resets or use authenticated requests',
link: 'https://docs.github.com/en/rest/rate-limit',
cause: error,
})
// Authentication
throw createError({
message: 'Unable to connect to Stripe',
status: 503,
why: 'Invalid API key provided',
fix: 'Check STRIPE_SECRET_KEY environment variable',
link: 'https://docs.stripe.com/keys',
cause: error,
})
// Network
throw createError({
message: 'Failed to fetch user data',
status: 504,
why: 'Connection timeout after 30s',
fix: 'Check network connectivity and try again',
cause: error,
})Validation Errors
// Missing required field
throw createError({
message: 'Invalid checkout request',
status: 400,
why: 'Required field "email" is missing',
fix: 'Include a valid email address in the request body',
link: 'https://your-api.com/docs/checkout#request-body',
})
// Invalid format
throw createError({
message: 'Invalid email format',
status: 422,
why: `"${email}" is not a valid email address`,
fix: 'Provide an email in the format user@example.com',
})
// Business rule violation
throw createError({
message: 'Cannot cancel subscription',
status: 409,
why: 'Subscription has already been cancelled',
fix: 'No action needed - subscription is already inactive',
})Database Errors
// Not found
throw createError({
message: 'User not found',
status: 404,
why: `No user with ID "${userId}" exists`,
fix: 'Verify the user ID is correct',
})
// Constraint violation
throw createError({
message: 'Cannot create duplicate account',
status: 409,
why: `User with email "${email}" already exists`,
fix: 'Use a different email or log in to existing account',
link: 'https://your-app.com/login',
})
// Connection
throw createError({
message: 'Database unavailable',
status: 503,
why: 'Connection pool exhausted',
fix: 'Reduce concurrent connections or increase pool size',
cause: error,
})Permission Errors
throw createError({
message: 'Access denied',
status: 403,
why: 'User lacks "admin" role required for this action',
fix: 'Contact an administrator to request access',
link: 'https://your-app.com/docs/permissions',
})Transformation Examples
Before: Generic Error
async function processPayment(cart, user) {
try {
return await stripe.charges.create({
amount: cart.total,
currency: 'usd',
source: user.paymentMethodId,
})
} catch (error) {
throw new Error('Payment failed') // ❌ No context
}
}After: Structured Error
async function processPayment(cart, user) {
try {
return await stripe.charges.create({
amount: cart.total,
currency: 'usd',
source: user.paymentMethodId,
})
} catch (error) {
throw createError({
message: 'Payment failed',
why: getStripeErrorReason(error),
fix: getStripeErrorFix(error),
link: 'https://docs.stripe.com/declines/codes',
cause: error,
})
}
}
function getStripeErrorReason(error) {
const reasons = {
card_declined: 'Card was declined by the issuer',
insufficient_funds: 'Card has insufficient funds',
expired_card: 'Card has expired',
// ...
}
return reasons[error.code] ?? `Stripe error: ${error.code}`
}
function getStripeErrorFix(error) {
const fixes = {
card_declined: 'Try a different payment method or contact your bank',
insufficient_funds: 'Use a different card or add funds',
expired_card: 'Update your card details with a valid expiration date',
// ...
}
return fixes[error.code] ?? 'Contact support with error code'
}Integration with Wide Events
Structured errors integrate seamlessly with wide events:
// server/api/checkout.post.ts
// Nuxt: useLogger and createError are auto-imported
// Nitro v3: import { useLogger } from 'evlog/nitro/v3'
// Nitro v2: import { useLogger } from 'evlog/nitro'
import { createError } from 'evlog'
export default defineEventHandler(async (event) => {
const log = useLogger(event)
try {
// ... business logic ...
} catch (error) {
// EvlogError fields are automatically captured
log.error(error, { step: 'payment' })
throw createError({
message: 'Payment failed',
why: error.message,
fix: 'Try a different payment method',
})
}
// emit() called automatically
})The wide event will include:
{
"error": {
"name": "EvlogError",
"message": "Payment failed",
"why": "Card declined by issuer",
"fix": "Try a different payment method",
"link": "https://docs.stripe.com/declines/codes",
"internal": {
"stripeRequestId": "req_123"
}
},
"step": "payment"
}If you use createError({ ..., internal: { ... } }) without calling log.error(error) yourself, framework integrations that attach thrown errors to the wide event still merge `internal` into `error.internal` on emit.
Best Practices
Do
- Always provide
messageandwhyat minimum - Include
fixwhen there's an actionable solution - Add
linkto documentation for complex errors - Preserve
causewhen wrapping errors - Be specific about what failed and why
- Put operator-only or sensitive diagnostics in
internal, not inwhy/fix/message
Don't
- Use generic messages like "Error" or "Failed"
- Leak sensitive data (passwords, tokens, PII)
- Expect
internalin HTTP JSON or inparseError()— it is for server logs and drains only - Make
whyandmessageidentical - Suggest fixes that aren't actually possible
- Create errors without any context
Nitro Compatibility
evlog errors work with any Nitro-powered framework. When thrown in an API route, the error is automatically converted to an HTTP response:
// Backend - just throw
throw createError({
message: 'Payment failed',
status: 402,
why: 'Card declined',
fix: 'Try another card',
link: 'https://docs.example.com/payments',
})
// HTTP Response:
// Status: 402
// Body: {
// statusCode: 402,
// message: "Payment failed",
// data: { why: "Card declined", fix: "Try another card", link: "..." }
// }Frontend Integration
Use parseError() to extract all fields at the top level:
import { parseError } from 'evlog'
try {
await $fetch('/api/checkout')
} catch (err) {
const error = parseError(err)
// Direct access: error.message, error.why, error.fix, error.link
toast.add({
title: error.message,
description: error.why,
color: 'error',
actions: error.link
? [{ label: 'Learn more', onClick: () => window.open(error.link) }]
: undefined,
})
if (error.fix) console.info(`💡 Fix: ${error.fix}`)
}The difference: A generic error shows "An error occurred". A structured error shows the message, explains why, suggests a fix, and links to documentation.
Error Message Templates
Common patterns -- adapt fields to each specific case:
| Pattern | Status | Fields |
|---|---|---|
| Resource not found | 404 | why: what's missing, fix: verify identifier |
| External service failure | 503 | why: service error, fix: actionable step, link: service docs, cause: original error |
| Validation failure | 400 | why: what's invalid, fix: expected format |
| Permission denied | 403 | why: what's required, fix: how to get access |
Wide Events Guide
Wide events are comprehensive log entries that capture all context for a single logical operation (usually a request) in one place.
Why Wide Events?
Traditional logging scatters information across many log lines:
10:23:45.001 Request received POST /checkout
10:23:45.012 User authenticated: user_123
10:23:45.045 Cart loaded: 3 items, $99.99
10:23:45.089 Payment initiated: Stripe
10:23:45.234 Payment failed: card_declined
10:23:45.235 Request completed: 500During an incident, you're grep-ing through thousands of these trying to reconstruct what happened.
Wide events emit once with everything:
Development (pretty format):
10:23:45.235 ERROR [api] POST /checkout 500 in 234ms
├─ user: id=user_123 plan=premium accountAge=847
├─ cart: items=3 total=9999
├─ payment: provider=stripe method=card
└─ error: code=card_declined retriable=falseProduction (JSON format):
{
"timestamp": "2025-01-24T10:23:45.235Z",
"level": "error",
"service": "api",
"method": "POST",
"path": "/checkout",
"duration": "234ms",
"user": { "id": "user_123", "plan": "premium", "accountAge": 847 },
"cart": { "items": 3, "total": 9999 },
"payment": { "provider": "stripe", "method": "card" },
"error": { "code": "card_declined", "retriable": false }
}When to Use Wide Events
| Scenario | Use Wide Event? |
|---|---|
| HTTP request handling | Yes - one event per request |
| Background job execution | Yes - one event per job |
| Database query | No - use simple log |
| Cache hit/miss | No - include in parent wide event |
| User action (login, checkout) | Yes - one event per action |
| Debug statements | No - remove in production |
Required Fields
Every wide event should include:
Request Context
log.set({
method: 'POST',
path: '/api/checkout',
requestId: 'req_abc123', // For tracing
traceId: 'trace_xyz', // Distributed tracing
})User Context
log.set({
user: {
id: 'user_123',
plan: 'premium', // Business-relevant
accountAge: 847, // Days since signup
subscription: 'annual',
}
})Business Context
Add domain-specific data relevant to the operation:
// E-commerce checkout
log.set({
cart: { id: 'cart_xyz', items: 3, total: 9999 },
payment: { method: 'card', provider: 'stripe' },
order: { id: 'order_123', status: 'created' },
})
// API rate limiting
log.set({
rateLimit: {
limit: 1000,
remaining: 42,
resetAt: '2025-01-24T11:00:00Z',
}
})
// File upload
log.set({
upload: {
filename: 'document.pdf',
size: 1024000,
mimeType: 'application/pdf',
}
})Outcome
// Success
log.set({
status: 200,
// duration is added automatically by emit()
})
// Error
log.error(error, {
step: 'payment',
retriable: false,
})Pattern: Request Logger in API Routes
Nuxt/Nitro (Recommended)
With the evlog module, use useLogger(event) - it's auto-created and auto-emitted:
// server/api/checkout.post.ts
// Nuxt: useLogger and createError are auto-imported
// Nitro v3: import { useLogger } from 'evlog/nitro/v3'
// Nitro v2: import { useLogger } from 'evlog/nitro'
import { createError } from 'evlog'
export default defineEventHandler(async (event) => {
const log = useLogger(event) // Auto-created by evlog
const user = await requireAuth(event)
log.set({ user: { id: user.id, plan: user.plan } })
const cart = await getCart(user.id)
log.set({ cart: { items: cart.items.length, total: cart.total } })
try {
const payment = await processPayment(cart, user)
log.set({ payment: { id: payment.id, method: payment.method } })
} catch (error) {
log.error(error, { step: 'payment' })
throw createError({
message: 'Payment failed',
why: error.message,
fix: 'Try a different payment method',
})
}
const order = await createOrder(cart, user)
log.set({ order: { id: order.id, status: order.status } })
return order
// log.emit() is called automatically at request end
})Standalone TypeScript (Scripts, Workers)
Without Nuxt/Nitro, use createRequestLogger() and call emit() manually:
// scripts/sync-job.ts
import { initLogger, createRequestLogger } from 'evlog'
initLogger({ env: { service: 'sync-worker', environment: 'production' } })
async function processJob(job: Job) {
const log = createRequestLogger({ jobId: job.id, type: 'sync' })
try {
log.set({ source: job.source, target: job.target })
const result = await performSync(job)
log.set({ recordsSynced: result.count })
return result
} catch (error) {
log.error(error, { step: 'sync' })
throw error
} finally {
log.emit() // Manual emit required
}
}Transformation Examples
Before: Console.log Spam
// server/api/checkout.post.ts
export default defineEventHandler(async (event) => {
console.log('Checkout started')
const user = await getUser(event)
console.log('User loaded:', user.id)
const cart = await getCart(user.id)
console.log('Cart loaded:', cart.items.length, 'items')
try {
const payment = await processPayment(cart)
console.log('Payment successful:', payment.id)
return { orderId: payment.orderId }
} catch (error) {
console.error('Payment failed:', error.message)
throw error
}
})After: Single Wide Event
// server/api/checkout.post.ts
// Nuxt: useLogger and createError are auto-imported
// Nitro v3: import { useLogger } from 'evlog/nitro/v3'
// Nitro v2: import { useLogger } from 'evlog/nitro'
import { createError } from 'evlog'
export default defineEventHandler(async (event) => {
const log = useLogger(event)
const user = await getUser(event)
log.set({ user: { id: user.id, plan: user.plan } })
const cart = await getCart(user.id)
log.set({ cart: { items: cart.items.length, total: cart.total } })
try {
const payment = await processPayment(cart)
log.set({ payment: { id: payment.id }, order: { id: payment.orderId } })
return { orderId: payment.orderId }
} catch (error) {
log.error(error, { step: 'payment' })
throw createError({
message: 'Payment failed',
why: error.message,
fix: 'Try a different payment method',
})
}
// emit() called automatically
})Best Practices
Do
- Include business-relevant context (user plan, cart value, etc.)
- Add enough context to debug without looking elsewhere
- Use consistent field names across your codebase
- Let
emit()calculate duration automatically
Don't
- Log sensitive data (passwords, tokens, full credit card numbers)
- Create multiple wide events for one logical operation
- Forget to call
emit()(or use the Nuxt module for auto-emit) - Include debugging logs inside wide events (remove them)
Security: Preventing Sensitive Data Leakage
Always explicitly select which fields to log:
// ❌ DANGEROUS - logs everything including password
log.set({ user: body })
// ✅ SAFE - explicitly select fields
log.set({
user: {
id: body.id,
email: maskEmail(body.email),
// password: body.password ← NEVER include
},
})Never log: passwords, API keys, tokens, secrets, full card numbers, CVV, SSN, PII, session tokens, JWTs.
Sanitization helpers:
// server/utils/sanitize.ts
export function maskEmail(email: string): string {
const [local, domain] = email.split('@')
if (!domain) return '***'
return `${local[0]}***@${domain[0]}***.${domain.split('.')[1]}`
}
export function maskCard(card: string): string {
return `****${card.slice(-4)}`
}See code-review.md for the full security review checklist.
Field Naming Conventions
Use consistent, descriptive field names:
// ✅ Good - grouped, descriptive
log.set({
user: { id, plan, accountAge },
cart: { items, total },
payment: { method, provider },
})
// ❌ Bad - flat, abbreviated
log.set({
uid: '123',
n: 3,
t: 9999,
pm: 'card',
})Related skills
How it compares
Pick review-logging-patterns when adopting evlog across many JS frameworks; use vendor-specific logging skills when committed to one proprietary SDK only.
FAQ
Which frameworks does review-logging-patterns support?
review-logging-patterns guides evlog setup on Nuxt, Next.js, SvelteKit, Nitro, TanStack Start, React Router, NestJS, Express, Hono, Fastify, Elysia, Cloudflare Workers, and standalone TypeScript, covering wide events and drain adapters.
What logging problems does review-logging-patterns fix?
review-logging-patterns finds console.log spam, unstructured errors, and missing context, then replaces them with evlog wide events, enrichers, sampling, and drains to vendors like Axiom, Sentry, PostHog, and Better Stack.
Is Review Logging Patterns safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.