Digging through logs is not observability. It's hope.
A single request generates 10+ log lines. When production breaks at 3am, you're sifting scattered lines for a needle of signal. Your errors say "Something went wrong" — thanks, very helpful.
evlog is different. One wide event per operation. All the context. Errors that explain why and what to do next.
// server/api/checkout.post.ts// Scattered logs - impossible to debugconsole.log('Request received')console.log('User:',user.id)console.log('Cart loaded')console.log('Payment failed')// Good luck finding this at 3amthrownewError('Something went wrong')// server/api/checkout.post.tsimport{useLogger}from'evlog'// One comprehensive event per requestexportdefaultdefineEventHandler(async(event)=>{constlog=useLogger(event)// Auto-injected by evloglog.set({user: {id: user.id,plan: 'premium'}})log.set({cart: {items: 3,total: 9999}})log.error(error,{step: 'payment'})// Emits ONE event with ALL context + duration (automatic)})Output:
{
"timestamp": "2025-01-24T10:23:45.612Z",
"level": "error",
"service": "my-app",
"method": "POST",
"path": "/api/checkout",
"duration": "1.2s",
"durationMs": 1204,
"user": { "id": "123", "plan": "premium" },
"cart": { "items": 3, "total": 9999 },
"error": { "message": "Card declined", "step": "payment" }
}We're in the age of AI agents writing and debugging code. When an agent encounters an error, it needs clear, structured context to understand what happened and how to fix it.
Traditional logs force agents to grep through noise. evlog gives them:
- One event per request with all context in one place
- Self-documenting errors with
whyandfixfields - Structured JSON that's easy to parse and reason about
Your AI copilot will thank you.
npm install evlogThe recommended way to use evlog. Zero config, everything just works.
// nuxt.config.tsexportdefaultdefineNuxtConfig({modules: ['evlog/nuxt'],evlog: {env: {service: 'my-app',},// Optional: only log specific routes (supports glob patterns)include: ['/api/**'],},})Tip: Use
$productionto enable sampling only in production:exportdefaultdefineNuxtConfig({modules: ['evlog/nuxt'],evlog: {env: {service: 'my-app'}},$production: {evlog: {sampling: {rates: {info: 10,warn: 50,debug: 0}}},},})
That's it. Now use useLogger(event) in any API route:
// server/api/checkout.post.tsimport{useLogger,createError}from'evlog'exportdefaultdefineEventHandler(async(event)=>{constlog=useLogger(event)// Authenticate user and add to wide eventconstuser=awaitrequireAuth(event)log.set({user: {id: user.id,plan: user.plan}})// Load cart and add to wide eventconstcart=awaitgetCart(user.id)log.set({cart: {items: cart.items.length,total: cart.total}})// Process paymenttry{constpayment=awaitprocessPayment(cart,user)log.set({payment: {id: payment.id,method: payment.method}})}catch(error){log.error(error,{step: 'payment'})throwcreateError({message: 'Payment failed',status: 402,why: error.message,fix: 'Try a different payment method or contact your bank',})}// Create orderconstorder=awaitcreateOrder(cart,user)log.set({order: {id: order.id,status: order.status}})returnorder// log.emit() called automatically at request end})The wide event emitted at the end contains everything:
{
"timestamp": "2026-01-24T10:23:45.612Z",
"level": "info",
"service": "my-app",
"method": "POST",
"path": "/api/checkout",
"duration": "1.2s",
"durationMs": 1204,
"user": { "id": "user_123", "plan": "premium" },
"cart": { "items": 3, "total": 9999 },
"payment": { "id": "pay_xyz", "method": "card" },
"order": { "id": "order_abc", "status": "created" },
"status": 200
}Works with any framework powered by Nitro: Nuxt, Analog, Vinxi, SolidStart, TanStack Start, and more.
// nitro.config.tsimport{defineConfig}from'nitro'importevlogfrom'evlog/nitro/v3'exportdefaultdefineConfig({modules: [evlog({env: {service: 'my-api'}})],})// nitro.config.tsimport{defineNitroConfig}from'nitropack/config'importevlogfrom'evlog/nitro'exportdefaultdefineNitroConfig({modules: [evlog({env: {service: 'my-api'}})],})Then use useLogger in any route. Import from evlog/nitro/v3 (v3) or evlog/nitro (v2):
// routes/api/documents/[id]/export.post.ts// Nitro v3: import { defineHandler } from 'nitro/h3' + import { useLogger } from 'evlog/nitro/v3'// Nitro v2: import { defineEventHandler } from 'h3' + import { useLogger } from 'evlog/nitro'import{defineEventHandler}from'h3'import{useLogger}from'evlog/nitro'import{createError}from'evlog'exportdefaultdefineEventHandler(async(event)=>{constlog=useLogger(event)// Get document ID from route paramsconstdocumentId=getRouterParam(event,'id')log.set({document: {id: documentId}})// Parse request body for export optionsconstbody=awaitreadBody(event)log.set({export: {format: body.format,includeComments: body.includeComments}})// Load document from databaseconstdocument=awaitdb.documents.findUnique({where: {id: documentId}})if(!document){throwcreateError({message: 'Document not found',status: 404,why: `No document with ID "${documentId}" exists`,fix: 'Check the document ID and try again',})}log.set({document: {id: documentId,title: document.title,pages: document.pages.length}})// Generate exporttry{constexportResult=awaitgenerateExport(document,body.format)log.set({export: {format: body.format,size: exportResult.size,pages: exportResult.pages}})return{url: exportResult.url,expiresAt: exportResult.expiresAt}}catch(error){log.error(error,{step: 'export-generation'})throwcreateError({message: 'Export failed',status: 500,why: `Failed to generate ${body.format} export: ${error.message}`,fix: 'Try a different format or contact support',})}// log.emit() called automatically - outputs one comprehensive wide event})Output when the export completes:
{
"timestamp": "2025-01-24T14:32:10.123Z",
"level": "info",
"service": "document-api",
"method": "POST",
"path": "/api/documents/doc_123/export",
"duration": "2.4s",
"durationMs": 2412,
"document": { "id": "doc_123", "title": "Q4 Report", "pages": 24 },
"export": { "format": "pdf", "size": 1240000, "pages": 24 },
"status": 200
}For scripts, workers, or any TypeScript project:
// scripts/migrate.tsimport{initLogger,log,createRequestLogger}from'evlog'// Initialize once at script startinitLogger({env: {service: 'migration-script',environment: 'production',},})// Simple logginglog.info('migration','Starting database migration')log.info({action: 'migration',tables: ['users','orders']})// Or use request logger for a logical operationconstmigrationLog=createRequestLogger({action: 'full-migration'})migrationLog.set({tables: ['users','orders','products']})migrationLog.set({rowsProcessed: 15000})migrationLog.emit()// workers/sync-job.tsimport{initLogger,createRequestLogger,createError}from'evlog'initLogger({env: {service: 'sync-worker',environment: process.env.NODE_ENV,},})asyncfunctionprocessSyncJob(job: Job){constlog=createRequestLogger({jobId: job.id,type: 'sync'})try{log.set({source: job.source,target: job.target})constresult=awaitperformSync(job)log.set({recordsSynced: result.count})returnresult}catch(error){log.error(error,{step: 'sync'})throwerror}finally{log.emit()}}Use the Workers adapter for structured logs and correct platform severity. With initWorkersLogger({ drain }), use defineWorkerFetch so async drains are registered with waitUntil automatically (Cloudflare only passes ExecutionContext as the third fetch argument — there is no global).
// src/index.tsimport{defineWorkerFetch,initWorkersLogger}from'evlog/workers'initWorkersLogger({env: {service: 'edge-api'},})exportdefaultdefineWorkerFetch(async(request,_env,_ctx,log)=>{try{log.set({route: 'health'})constresponse=newResponse('ok',{status: 200})log.emit({status: response.status})returnresponse}catch(error){log.error(errorasError)log.emit({status: 500})throwerror}})If you keep a raw export default { fetch }, pass { executionCtx: ctx } to createWorkersLogger or waitUntil on createRequestLogger.
// Lower-level (equivalent)import{createWorkersLogger}from'evlog/workers'exportdefault{asyncfetch(request: Request,_env: unknown,ctx: ExecutionContext){constlog=createWorkersLogger(request,{executionCtx: ctx})// ...},}Disable invocation logs to avoid duplicate request logs:
# wrangler.toml
[observability.logs]
invocation_logs = falseNotes:
- Prefer
defineWorkerFetchso you do not have to passexecutionCtxyourself when using a drain requestIddefaults tocf-raywhen availablerequest.cfis included (colo, country, asn) unless disabled- Use
headerAllowlistto avoid logging sensitive headers
// src/index.tsimport{Hono}from'hono'import{initLogger}from'evlog'import{evlog,typeEvlogVariables}from'evlog/hono'initLogger({env: {service: 'hono-api'}})constapp=newHono<EvlogVariables>()app.use(evlog())app.get('/api/users',(c)=>{constlog=c.get('log')log.set({users: {count: 42}})returnc.json({users: []})})See the full hono example for a complete working project.
// src/index.tsimportexpressfrom'express'import{initLogger}from'evlog'import{evlog,useLogger}from'evlog/express'initLogger({env: {service: 'express-api'}})constapp=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.
See the full express example for a complete working project.
// src/index.tsimportFastifyfrom'fastify'import{initLogger}from'evlog'import{evlog,useLogger}from'evlog/fastify'initLogger({env: {service: 'fastify-api'}})constapp=Fastify({logger: false})awaitapp.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). Use useLogger() to access the logger from anywhere in the call stack.
See the full fastify example for a complete working project.
// src/index.tsimport{Elysia}from'elysia'import{initLogger}from'evlog'import{evlog,useLogger}from'evlog/elysia'initLogger({env: {service: 'elysia-api'}})constapp=newElysia().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.
See the full elysia example for a complete working project.
// app/root.tsximport{initLogger}from'evlog'import{evlog,loggerContext}from'evlog/react-router'initLogger({env: {service: 'react-router-api'}})exportconstmiddleware: Route.MiddlewareFunction[]=[evlog(),]// app/routes/api.users.$id.tsximport{loggerContext}from'evlog/react-router'exportasyncfunctionloader({ params, context }: Route.LoaderArgs){constlog=context.get(loggerContext)log.set({users: {count: 42}})return{users: []}}Use context.get(loggerContext) in loaders/actions, or useLogger() from anywhere in the call stack. Requires v8_middleware: true in react-router.config.ts.
See the full react-router example for a complete working project.
// src/app.module.tsimport{Module}from'@nestjs/common'import{EvlogModule}from'evlog/nestjs'
@Module({imports: [EvlogModule.forRoot()],})exportclassAppModule{}// In any controller or service:import{useLogger}from'evlog/nestjs'constlog=useLogger()log.set({users: {count: 42}})EvlogModule.forRoot() registers a global middleware that creates a request-scoped logger for every request. Use useLogger() to access it anywhere in the call stack, or req.log directly. Supports forRootAsync() for async configuration.
See the full nestjs example for a complete working project.
// server/orpc.tsimport{os}from'@orpc/server'import{RPCHandler}from'@orpc/server/fetch'import{initLogger}from'evlog'import{evlog,withEvlog,typeEvlogOrpcContext}from'evlog/orpc'initLogger({env: {service: 'orpc-api'}})constbase=os.$context<EvlogOrpcContext>().use(evlog())constrouter={ping: base.handler(({ context })=>{context.log.set({pinged: true})return{ok: true}}),}consthandler=withEvlog(newRPCHandler(router))exportdefaultasyncfunctionfetch(request: Request){const{ matched, response }=awaithandler.handle(request,{prefix: '/rpc'})returnmatched ? response : newResponse('Not Found',{status: 404})}withEvlog() wraps the handler and emits one wide event per request; os.use(evlog()) exposes context.log to procedures and tags each event with the procedure path as operation. Use useLogger() from evlog/orpc to access the logger off-context.
See the full orpc example for a complete working project.
// agent/hooks/evlog.tsimport{defineEvlogHook}from'evlog/eve'import{createAxiomDrain}from'evlog/axiom'exportdefaultdefineEvlogHook({init: {env: {service: 'my-agent'}},drain: createAxiomDrain(),maxSessions: 256,})// agent/tools/my_tool.ts — inside execute()import{useLogger}from'evlog/eve'constlog=useLogger()log.set({order: {id: input.orderId}})// agent/instrumentation.ts — joins eve's OTel spans to the wide eventsimport{defineEvlogInstrumentation}from'evlog/eve'exportdefaultdefineEvlogInstrumentation()defineEvlogHook() maps eve turn lifecycle events to one wide event per turn. Call useLogger() in tools — the logger is bound via AsyncLocalStorage on turn.started. Pass ctx only when ALS is unavailable (useLogger(ctx)). Pretty-printing follows isDev() by default (tree locally, JSON in production); set init.pretty: false explicitly if you need to override.
defineEvlogInstrumentation() is optional: it stamps evlog.request_id onto eve's AI SDK spans so a trace joins back to its wide event, and back. It owns agent/instrumentation.ts, so when another observability backend needs that file, use eve's own defineInstrumentation and spread evlogRuntimeContext(input) into your runtime context instead. Requires eve 0.30 or later. Complements eve Agent Runs — see the eve use case.
Every turn event carries eve.caller — principalId, principalType and authenticator — so cost and volume group by who triggered the turn.
See the full eve example for a complete agent layout.
Use the log API on the client side for structured browser logging:
import{log}from'evlog/client'log.info('checkout','User initiated checkout')log.error({action: 'payment',error: 'validation_failed'})In Nuxt, log is auto-imported -- no import needed in Vue components:
<script setup>log.info('checkout', 'User initiated checkout')</script>Client logs output to the browser console with colored tags in development.
To send client logs to the server for centralized logging, enable the transport:
// nuxt.config.tsexportdefaultdefineNuxtConfig({modules: ['evlog/nuxt'],evlog: {transport: {enabled: true,// Send client logs to server},},})When enabled:
- Client logs are sent to
/api/_evlog/ingestvia POST - Server enriches with environment context (service, version, etc.)
evlog:drainhook is called withsource: 'client'- External services receive the log
For a framework-agnostic batched HTTP drain (e.g. vanilla JS or custom endpoints), use createHttpLogDrain from evlog/http. The legacy import path evlog/browser is deprecated and will be removed in the next major release.
Errors should tell you what happened, why, and how to fix it.
// server/api/repos/sync.post.tsimport{useLogger,createError}from'evlog'exportdefaultdefineEventHandler(async(event)=>{constlog=useLogger(event)log.set({repo: {owner: 'acme',name: 'my-project'}})try{constresult=awaitsyncWithGitHub()log.set({sync: {commits: result.commits,files: result.files}})returnresult}catch(error){log.error(error,{step: 'github-sync'})throwcreateError({message: 'Failed to sync repository',status: 503,why: 'GitHub API rate limit exceeded',fix: 'Wait 1 hour or use a different token',link: 'https://docs.github.com/en/rest/rate-limit',cause: error,})}})Console output (development):
Error: Failed to sync repository
Why: GitHub API rate limit exceeded
Fix: Wait 1 hour or use a different token
More info: https://docs.github.com/en/rest/rate-limit
Use the evlog:enrich hook to add derived context after emit, before drain.
// server/plugins/evlog-enrich.tsexportdefaultdefineNitroPlugin((nitroApp)=>{nitroApp.hooks.hook('evlog:enrich',(ctx)=>{ctx.event.deploymentId=process.env.DEPLOYMENT_ID})})// server/plugins/evlog-enrich.tsimport{createGeoEnricher,createRequestSizeEnricher,createTraceContextEnricher,createUserAgentEnricher,}from'evlog/enrichers'exportdefaultdefineNitroPlugin((nitroApp)=>{constenrich=[createUserAgentEnricher(),createGeoEnricher(),createRequestSizeEnricher(),createTraceContextEnricher(),]nitroApp.hooks.hook('evlog:enrich',(ctx)=>{for(constenricherofenrich)enricher(ctx)})})Each enricher adds a specific field to the event:
| Enricher | Event Field | Shape |
|---|---|---|
createUserAgentEnricher() | event.userAgent | { raw, browser?: { name, version? }, os?: { name, version? }, device?: { type } } |
createGeoEnricher() | event.geo | { country?, region?, regionCode?, city?, latitude?, longitude? } |
createRequestSizeEnricher() | event.requestSize | { requestBytes?, responseBytes? } |
createTraceContextEnricher() | event.traceContext + event.traceId + event.spanId | { traceparent?, tracestate?, traceId?, spanId? } |
All enrichers accept an optional { overwrite?: boolean } option. By default (overwrite: false), user-provided data on the event takes precedence over enricher-computed values. Set overwrite: true to always replace existing fields.
Cloudflare geo note: Only
cf-ipcountryis a real Cloudflare HTTP header. Thecf-region,cf-city,cf-latitude,cf-longitudeheaders are NOT standard -- they are properties ofrequest.cf. For full geo data on Cloudflare, write a custom enricher that readsrequest.cf, or use a Workers middleware to forwardcfproperties as custom headers.
The evlog:enrich hook receives an EnrichContext with these fields:
interfaceEnrichContext{event: WideEvent// The emitted wide event (mutable -- modify it directly)request?: {// Request metadatamethod?: stringpath?: stringrequestId?: string}headers?: Record<string,string>// Safe HTTP headers (sensitive headers filtered)response?: {// Response metadatastatus?: numberheaders?: Record<string,string>}}Example custom enricher:
// server/plugins/evlog-enrich.tsexportdefaultdefineNitroPlugin((nitroApp)=>{nitroApp.hooks.hook('evlog:enrich',(ctx)=>{// Add deployment metadatactx.event.deploymentId=process.env.DEPLOYMENT_IDctx.event.region=process.env.FLY_REGION// Extract data from headersconsttenantId=ctx.headers?.['x-tenant-id']if(tenantId){ctx.event.tenantId=tenantId}})})Audit logs are not a parallel system: they are a typed audit field on the wide event plus a few helpers. Add 1 enricher + 1 drain wrapper + log.audit() and you get tamper-evident, redact-aware, force-kept audit events through the same pipeline.
// server/plugins/evlog.tsimport{auditEnricher,auditOnly,signed}from'evlog'import{createAxiomDrain}from'evlog/axiom'import{createFsDrain}from'evlog/fs'exportdefaultdefineNitroPlugin((nitroApp)=>{constenrich=[auditEnricher({tenantId: ctx=>ctx.headers?.['x-tenant-id']})]constaudits=auditOnly(signed(createFsDrain({path: '.audit/'}),{strategy: 'hash-chain'}),{await: true})constmain=createAxiomDrain()nitroApp.hooks.hook('evlog:enrich',asyncctx=>{for(consteofenrich)awaite(ctx)})nitroApp.hooks.hook('evlog:drain',asyncctx=>{awaitPromise.all([main(ctx),audits(ctx)])})})// server/api/invoice/[id]/refund.post.tsimport{auditDiff}from'evlog'exportdefaultdefineEventHandler(async(event)=>{constlog=useLogger(event)constbefore=awaitdb.invoice.get(id)constafter=awaitdb.invoice.refund(id)log.audit?.({action: 'invoice.refund',actor: {type: 'user',id: user.id,email: user.email},target: {type: 'invoice',id: after.id},outcome: 'success',changes: auditDiff(before,after),})})| Symbol | Kind | Purpose |
|---|---|---|
log.audit(fields) / log.audit.deny(reason, fields) | method | Sugar over log.set({ audit }) + force-keep |
audit(fields) | function | Standalone for jobs / scripts |
withAudit({ action, target })(fn) | wrapper | Auto-emit success / failure / denied |
defineAuditAction(name, opts?) | factory | Typed action registry |
auditDiff(before, after) | helper | Redact-aware JSON Patch for changes |
mockAudit() | test util | Capture and assert audits in tests |
auditEnricher({ tenantId? }) | enricher | Auto-fill req/trace/ip/ua/tenantId context |
auditOnly(drain, { await? }) | wrapper | Routes only events with event.audit |
signed(drain, { strategy: 'hmac' | 'hash-chain', ... }) | wrapper | Tamper-evident integrity |
auditRedactPreset | preset | Strict PII for audit events |
AuditFields is exported and merges with BaseWideEvent — augment it with declare module if you need extra typed fields. Audit events are always force-kept by tail sampling and get a deterministic idempotencyKey so retries are safe across drains.
See the Audit Logs guide for compliance, GDPR, and recipe details.
Capture token usage, tool calls, model info, and streaming metrics from the Vercel AI SDK into wide events. Compatible with AI SDK v6 and v7 (ai >= 6.0.168). AI SDK v7 requires Node.js 22+.
For tool execution timing, abort tracking, and auto embed capture, pass createEvlogIntegration(ai) to telemetry.integrations (v7) or experimental_telemetry.integrations (v6).
import{streamText}from'ai'import{createAILogger}from'evlog/ai'exportdefaultdefineEventHandler(async(event)=>{constlog=useLogger(event)constai=createAILogger(log)constresult=streamText({model: ai.wrap('anthropic/claude-sonnet-4.6'),// string or model object
messages,onFinish: ({ text })=>saveConversation(text),// no conflict})returnresult.toTextStreamResponse()})The middleware captures: inputTokens, outputTokens, cacheReadTokens, reasoningTokens, model, provider, finishReason, toolCalls, steps, msToFirstChunk, msToFinish, tokensPerSecond.
For embeddings: ai.captureEmbed({ usage }).
The same metadata is also exposed as a public API for custom analytics, billing, or user-facing dashboards:
constai=createAILogger(log,{cost: {'claude-sonnet-4.6': {input: 3,output: 15}},})awaitgenerateText({model: ai.wrap('anthropic/claude-sonnet-4.6'), prompt })constmetadata=ai.getMetadata()// structured snapshot (AIMetadata)constcost=ai.getEstimatedCost()// dollars, or undefinedai.onUpdate((metadata)=>{// incremental updates per steppushToClient({tokens: metadata.totalTokens,cost: metadata.estimatedCost})})Send your logs to external observability platforms with built-in adapters.
// server/plugins/evlog-drain.tsimport{createAxiomDrain}from'evlog/axiom'exportdefaultdefineNitroPlugin((nitroApp)=>{nitroApp.hooks.hook('evlog:drain',createAxiomDrain())})Set environment variables:
AXIOM_API_KEY=xaat-your-token
AXIOM_DATASET=your-datasetWorks with Grafana, Datadog, Honeycomb, and any OTLP-compatible backend.
// server/plugins/evlog-drain.tsimport{createOTLPDrain}from'evlog/otlp'exportdefaultdefineNitroPlugin((nitroApp)=>{nitroApp.hooks.hook('evlog:drain',createOTLPDrain())})Set environment variables:
OTLP_ENDPOINT=http://localhost:4318// server/plugins/evlog-drain.tsimport{createDatadogDrain}from'evlog/datadog'exportdefaultdefineNitroPlugin((nitroApp)=>{nitroApp.hooks.hook('evlog:drain',createDatadogDrain())})Set environment variables:
DD_API_KEY=your-api-key
# Optional — defaults to datadoghq.com
DD_SITE=datadoghq.euWide events are sent with a short message line (method, path, level) and full context under the evlog attribute (facets like @evlog.path). See the Datadog adapter docs.
// server/plugins/evlog-drain.tsimport{createPostHogDrain}from'evlog/posthog'exportdefaultdefineNitroPlugin((nitroApp)=>{nitroApp.hooks.hook('evlog:drain',createPostHogDrain())})Set environment variables:
POSTHOG_API_KEY=phc_your-key
POSTHOG_HOST=https://us.i.posthog.com # Optional: for EU or self-hosted// server/plugins/evlog-drain.tsimport{createSentryDrain}from'evlog/sentry'exportdefaultdefineNitroPlugin((nitroApp)=>{nitroApp.hooks.hook('evlog:drain',createSentryDrain())})Set environment variables:
SENTRY_DSN=https://public@o0.ingest.sentry.io/123// server/plugins/evlog-drain.tsimport{createBetterStackDrain}from'evlog/better-stack'exportdefaultdefineNitroPlugin((nitroApp)=>{nitroApp.hooks.hook('evlog:drain',createBetterStackDrain())})Set environment variables:
BETTER_STACK_API_KEY=your-source-token// server/plugins/evlog-drain.tsimport{createHyperDXDrain}from'evlog/hyperdx'exportdefaultdefineNitroPlugin((nitroApp)=>{nitroApp.hooks.hook('evlog:drain',createHyperDXDrain())})Set environment variables:
HYPERDX_API_KEY=your-api-key
# Optional — defaults to https://in-otel.hyperdx.io
HYPERDX_OTLP_ENDPOINT=https://in-otel.hyperdx.ioWrite wide events to local NDJSON files (.evlog/logs/ by default):
// server/plugins/evlog-drain.tsimport{createFsDrain}from'evlog/fs'exportdefaultdefineNitroPlugin((nitroApp)=>{nitroApp.hooks.hook('evlog:drain',createFsDrain())})Set environment variables:
EVLOG_FS_DIR=.evlog/logsIn-memory ring buffer — works in any runtime, including Cloudflare Workers:
// server/plugins/evlog-drain.tsimport{createMemoryDrain}from'evlog/memory'exportdefaultdefineNitroPlugin((nitroApp)=>{nitroApp.hooks.hook('evlog:drain',createMemoryDrain())})Optional environment variables:
EVLOG_MEMORY_STORE=default
EVLOG_MEMORY_MAX_EVENTS=1000Pair with readMemoryLogs() for dev-only agent access over HTTP. See the Memory adapter docs.
Send logs to multiple services:
// server/plugins/evlog-drain.tsimport{createAxiomDrain}from'evlog/axiom'import{createOTLPDrain}from'evlog/otlp'exportdefaultdefineNitroPlugin((nitroApp)=>{constaxiom=createAxiomDrain()constotlp=createOTLPDrain()nitroApp.hooks.hook('evlog:drain',async(ctx)=>{awaitPromise.allSettled([axiom(ctx),otlp(ctx)])})})Build your own adapter for any destination:
// server/plugins/evlog-drain.tsexportdefaultdefineNitroPlugin((nitroApp)=>{nitroApp.hooks.hook('evlog:drain',async(ctx)=>{awaitfetch('https://your-service.com/logs',{method: 'POST',headers: {'Content-Type': 'application/json'},body: JSON.stringify(ctx.event),})})})See the full documentation for adapter configuration options, troubleshooting, and advanced patterns.
For production use, wrap your drain adapter with createDrainPipeline to get batching, retry with backoff, and buffer overflow protection.
Without a pipeline, each event triggers a separate network call. The pipeline buffers events and sends them in batches, reducing overhead and handling transient failures automatically.
// server/plugins/evlog-drain.tsimporttype{DrainContext}from'evlog'import{createDrainPipeline}from'evlog/pipeline'import{createAxiomDrain}from'evlog/axiom'exportdefaultdefineNitroPlugin((nitroApp)=>{constpipeline=createDrainPipeline<DrainContext>({batch: {size: 50,intervalMs: 5000},retry: {maxAttempts: 3,backoff: 'exponential',initialDelayMs: 1000},onDropped: (events,error)=>{console.error(`[evlog] Dropped ${events.length} events:`,error?.message)},})constdrain=pipeline(createAxiomDrain())nitroApp.hooks.hook('evlog:drain',drain)nitroApp.hooks.hook('close',()=>drain.flush())})- Events are buffered in memory as they arrive
- A batch is flushed when either the batch size is reached or the interval expires (whichever comes first)
- If the drain function fails, the batch is retried with the configured backoff strategy
- If all retries are exhausted,
onDroppedis called with the lost events - If the buffer exceeds
maxBufferSize, the oldest events are dropped to prevent memory leaks
| Option | Default | Description |
|---|---|---|
batch.size | 50 | Maximum events per batch |
batch.intervalMs | 5000 | Max time (ms) before flushing a partial batch |
retry.maxAttempts | 3 | Total attempts (including first) |
retry.backoff | 'exponential' | 'exponential' | 'linear' | 'fixed' |
retry.initialDelayMs | 1000 | Base delay for first retry |
retry.maxDelayMs | 30000 | Upper bound for any retry delay |
maxBufferSize | 1000 | Max buffered events before dropping oldest |
onDropped | -- | Callback when events are dropped |
The function returned by pipeline(drain) is hook-compatible and exposes:
drain(ctx)-- Push a single event into the bufferdrain.flush()-- Force-flush all buffered events (call on server shutdown)drain.pending-- Number of events currently buffered
Initialize the logger. Required for standalone usage, automatic with Nuxt/Nitro plugins.
initLogger({enabled: boolean// Optional. Enable/disable all logging (default: true)env: {service: string // Service nameenvironment: string // 'production' | 'development' | 'test'version?: string // App versioncommitHash?: string // Git commitregion?: string // Deployment region},pretty?: boolean // Pretty print (default: true in dev)silent?: boolean // Suppress console output (default: false). Events still go to drains.stringify?: boolean // JSON.stringify output (default: true, false for Workers)include?: string[]// Route patterns to log (glob), e.g. ['/api/**']sampling?: {rates?: {// Head sampling (random per level)info?: number // 0-100, default 100warn?: number // 0-100, default 100debug?: number // 0-100, default 100error?: number// 0-100, default 100 (always logged unless set to 0)}keep?: Array<{// Tail sampling (force keep based on outcome)status?: number// Keep if status >= valueduration?: number// Keep if duration >= value (ms)path?: string// Keep if path matches glob pattern}>}})At scale, logging everything can become expensive. evlog supports two sampling strategies:
Random sampling based on log level, decided before the request completes:
initLogger({sampling: {rates: {info: 10,// Keep 10% of info logswarn: 50,// Keep 50% of warning logsdebug: 0,// Disable debug logs// error defaults to 100% (always logged)},},})Force-keep logs based on request outcome, evaluated after the request completes. Useful to always capture slow requests or critical paths:
// nuxt.config.tsexportdefaultdefineNuxtConfig({modules: ['evlog/nuxt'],evlog: {sampling: {rates: {info: 10},// Only 10% of info logskeep: [{duration: 1000},// Always keep if duration >= 1000ms{status: 400},// Always keep if status >= 400{path: '/api/critical/**'},// Always keep critical paths],},},})For business-specific conditions (premium users, feature flags), use the evlog:emit:keep Nitro hook:
// server/plugins/evlog-custom.tsexportdefaultdefineNitroPlugin((nitroApp)=>{nitroApp.hooks.hook('evlog:emit:keep',(ctx)=>{// Always keep logs for premium usersif(ctx.context.user?.premium){ctx.shouldKeep=true}})})In development, evlog uses a compact tree format:
16:45:31.060 INFO [my-app] GET /api/checkout 200 in 234ms
|- user: id=123 plan=premium
|- cart: items=3 total=9999
+- payment: id=pay_xyz method=card
In production (pretty: false), logs are emitted as JSON for machine parsing.
Simple logging API.
log.info('tag','message')// Tagged loglog.info({key: 'value'})// Wide eventlog.error('tag','message')log.warn('tag','message')log.debug('tag','message')Create a request-scoped logger for wide events.
constlog=createRequestLogger({method: 'POST',path: '/checkout',requestId: 'req_123',})log.set({user: {id: '123'}})// Add contextlog.error(error,{step: 'x'})// Log error with contextlog.emit()// Emit final eventlog.getContext()// Get current contextThe framework emits one wide event per HTTP request when the response finishes (or on error). After emit() runs — including when head sampling drops the event (emit() returns null) — that logger instance is sealed: further set, error, info, and warn calls are ignored and emit a [evlog] console warning listing dropped keys. A second emit() is ignored with a warning. This avoids silent data loss when async work (unawaited promises, setTimeout, etc.) still resolves useLogger() to the same logger via AsyncLocalStorage after the response has already been logged.
log.fork(label, fn) runs work under a child request logger: inside fn, useLogger() returns the child. When fn settles, the child emits its own wide event with operation set to label and _parentRequestId set to the parent’s requestId (query and dashboard correlation). The parent event may be emitted before the child event; they are two separate events ordered by time.
fork is attached by integrations that use AsyncLocalStorage for useLogger(). Standalone createLogger() instances do not have fork.
| Integration | log.fork() |
|---|---|
| Express, Fastify, NestJS, SvelteKit, React Router, Elysia | Yes |
Next.js withEvlog | Yes |
Hono (c.get('log') only) | Not yet |
Nitro / Nuxt useLogger(event) | Not yet — use post-emit warnings; see Wide events |
import{evlog,useLogger}from'evlog/express'app.post('/checkout',(req,res)=>{constlog=req.loglog.set({order_dispatched: true})log.fork!('process_order',async()=>{constchildLog=useLogger()childLog.set({inventory_checked: true})// child emits automatically when this async function completes})res.json({ok: true})})Use optional chaining if fork might be absent: log.fork?.('task', async () => { ... }).
Initialize evlog for Cloudflare Workers (object logs + correct severity).
import{initWorkersLogger}from'evlog/workers'initWorkersLogger({env: {service: 'edge-api'},})Recommended for Workers when using initWorkersLogger({ drain }). Wraps your handler so createWorkersLogger always receives executionCtx — you do not pass ctx into the factory yourself. Cloudflare does not expose ExecutionContext globally (only as fetch’s third argument), so this is the “automatic” option for plain Workers scripts.
import{defineWorkerFetch,initWorkersLogger}from'evlog/workers'initWorkersLogger({env: {service: 'edge-api'}, drain })exportdefaultdefineWorkerFetch(async(request,env,ctx,log)=>{log.emit({status: 200})returnnewResponse('ok')})Create a request-scoped logger for Workers. Auto-extracts cf-ray, request.cf, method, and path.
import{createWorkersLogger}from'evlog/workers'// ctx is the third argument to fetch(request, env, ctx)constlog=createWorkersLogger(request,{requestId: 'custom-id',// Override cf-ray (default: cf-ray header)headers: ['x-request-id'],// Headers to include (default: none)executionCtx: ctx,// With initWorkersLogger({ drain }), registers async drain via waitUntil})// Or pass waitUntil directly: waitUntil: ctx.waitUntil.bind(ctx)log.set({user: {id: '123'}})log.emit({status: 200})Create a structured error with HTTP status support. Import from evlog directly to avoid conflicts with Nuxt/Nitro's createError.
Note:
createEvlogErroris also available as an auto-imported alias in Nuxt/Nitro to avoid conflicts.
import{createError}from'evlog'createError({message: string // What happenedstatus?: number // HTTP status code (default: 500)why?: string // Why it happenedfix?: string // How to fix itlink?: string // Documentation URLcause?: Error// Original errorinternal?: Record<string,unknown>// Backend-only; never in HTTP body or toJSON()})internal — Optional context for support, auditing, or debugging (IDs, gateway codes, raw diagnostics). It is stored on EvlogError and exposed as error.internal in server code. It is not included in JSON error responses, toJSON(), or parseError() results. When the error is passed to log.error() (or thrown in integrations that record errors on the wide event), internal is copied into the emitted event under error.internal.
Parse a caught error into a flat structure with all evlog fields. Auto-imported in Nuxt.
import{parseError}from'evlog'try{await$fetch('/api/checkout')}catch(err){consterror=parseError(err)// Direct access to all fieldsconsole.log(error.message)// "Payment failed"console.log(error.status)// 402console.log(error.why)// "Card declined"console.log(error.fix)// "Try another card"console.log(error.link)// "https://docs.example.com/..."// Use with toasttoast.add({title: error.message,description: error.why,color: 'error',})}| Framework | Integration |
|---|---|
| Nuxt | modules: ['evlog/nuxt'] |
| Next.js | createEvlog() factory with import { createEvlog } from 'evlog/next' (example) |
| SvelteKit | export const { handle, handleError } = createEvlogHooks() with import { createEvlogHooks } from 'evlog/sveltekit' (example) |
| Nitro v3 | modules: [evlog()] with import evlog from 'evlog/nitro/v3' |
| Nitro v2 | modules: [evlog()] with import evlog from 'evlog/nitro' |
| TanStack Start | Nitro v3 module setup (example) |
| React Router | evlog() middleware with import { evlog } from 'evlog/react-router' (example) |
| NestJS | EvlogModule.forRoot() with import { EvlogModule } from 'evlog/nestjs' (example) |
| Express | app.use(evlog()) with import { evlog } from 'evlog/express' (example) |
| Hono | app.use(evlog()) with import { evlog } from 'evlog/hono' (example) |
| Fastify | app.register(evlog) with import { evlog } from 'evlog/fastify' (example) |
| Elysia | .use(evlog()) with import { evlog } from 'evlog/elysia' (example) |
| oRPC | withEvlog(handler) + os.use(evlog()) with import { evlog, withEvlog } from 'evlog/orpc' (example) |
| eve | defineEvlogHook() in agent/hooks/evlog.ts with import { defineEvlogHook, useLogger } from 'evlog/eve' (example) |
| Cloudflare Workers | Manual setup with import { initWorkersLogger, createWorkersLogger } from 'evlog/workers' (example) |
| Custom | Build your own with import { createMiddlewareLogger } from 'evlog/toolkit' (guide) |
| Analog | Nitro v2 module setup |
| Vinxi | Nitro v2 module setup |
| SolidStart | Nitro v2 module setup (example) |
@evlog/cli is a separate package — still early — that scores what your app can tell you when something goes wrong. It reads your project on disk — no traffic, no instrumentation — finds every entry point, and names the ones to fix first. Worth trying once you have anything wired; hand the report to an agent if you like.
npx @evlog/cli map
# or: pnpm dlx @evlog/cli map▀▀█ █▀▀ score /100 your-app · Nuxt
█ █▀█ ▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▱▱▱▱▱ 29 entry points scanned
▀ ▀▀▀ good ▂▂▂▃▃▃▃▃▄▆▆▆▆▆███████████████
FIX FIRST
1. ANY /api/auth/:all* A — touches auth and logs nothing
server/api/auth/[...all].ts:1 · evlog.dev/learn/wide-events
| Command | What it does |
|---|---|
evlog map | Score every entry point and list the three worth fixing first |
evlog map <route-or-file> | Explain one entry point in full, with the shape it could take |
evlog map --all | Every entry point as a check matrix |
evlog map --min-score <n> | Exit 1 below the threshold — a CI gate |
evlog doctor | Diagnose the install: Node, workspace, evlog version, local logs |
Same code in, same verdict out, with the file and line for every finding — which also makes it something you can hand to an agent: run it, fix the list, run it again.
Early days: the CLI is tested and safe to run on any project, but it is young — four framework adapters today, rules still being refined. Expect verdicts and scores to move between releases; pin it as a dev dependency when you gate CI on the number.
Docs: CLI · evlog map · Rules · Scoring · CI
evlog provides Agent Skills to help AI coding assistants understand and implement proper logging patterns in your codebase.
npx skills add https://www.evlog.devOnce installed, your AI assistant will:
- Review your logging code and suggest wide event patterns
- Help refactor scattered
console.logcalls into structured events - Guide you to use
createError()for self-documenting errors - Ensure proper use of
useLogger(event)in Nuxt/Nitro routes - Optionally run
evlog map(npx @evlog/cli map) to score dark entry points — separate early CLI package, worth trying
Add logging to this endpoint
Review my logging code
Raise my evlog map score
Help me set up logging for this service
Inspired by Logging Sucks by Boris Tane.
- Wide Events: One log per request with all context
- Structured Errors: Errors that explain themselves
- Request Scoping: Accumulate context, emit once
- Pretty for Dev, JSON for Prod: Human-readable locally, machine-parseable in production
Made by @HugoRCD
