Skip to content

Repository files navigation

evlog — Digging through logs is not observability. It's hope

evlog

npm versionnpm downloadsCITypeScriptDocumentationlicense

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.

Why evlog?

The Problem

// 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')

The Solution

// 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" }
}

Built for AI-Assisted Development

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 why and fix fields
  • Structured JSON that's easy to parse and reason about

Your AI copilot will thank you.


Installation

npm install evlog

Nuxt Integration

The 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 $production to 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
}

Nitro Integration

Works with any framework powered by Nitro: Nuxt, Analog, Vinxi, SolidStart, TanStack Start, and more.

Nitro v3

// nitro.config.tsimport{defineConfig}from'nitro'importevlogfrom'evlog/nitro/v3'exportdefaultdefineConfig({modules: [evlog({env: {service: 'my-api'}})],})

Nitro v2

// 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
}

Standalone TypeScript

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()}}

Cloudflare Workers

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 = false

Notes:

  • Prefer defineWorkerFetch so you do not have to pass executionCtx yourself when using a drain
  • requestId defaults to cf-ray when available
  • request.cf is included (colo, country, asn) unless disabled
  • Use headerAllowlist to avoid logging sensitive headers

Hono

// 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.

Express

// 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.

Fastify

// 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.

Elysia

// 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.

React Router

// 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.

NestJS

// 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.

oRPC

// 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.

eve

// 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.callerprincipalId, principalType and authenticator — so cost and volume group by who triggered the turn.

See the full eve example for a complete agent layout.

Browser

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.

Client Transport

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:

  1. Client logs are sent to /api/_evlog/ingest via POST
  2. Server enriches with environment context (service, version, etc.)
  3. evlog:drain hook is called with source: 'client'
  4. 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.

Structured Errors

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

Enrichment Hook

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})})

Built-in Enrichers

// 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:

EnricherEvent FieldShape
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-ipcountry is a real Cloudflare HTTP header. The cf-region, cf-city, cf-latitude, cf-longitude headers are NOT standard -- they are properties of request.cf. For full geo data on Cloudflare, write a custom enricher that reads request.cf, or use a Workers middleware to forward cf properties as custom headers.

Custom Enrichers

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

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),})})
SymbolKindPurpose
log.audit(fields) / log.audit.deny(reason, fields)methodSugar over log.set({ audit }) + force-keep
audit(fields)functionStandalone for jobs / scripts
withAudit({ action, target })(fn)wrapperAuto-emit success / failure / denied
defineAuditAction(name, opts?)factoryTyped action registry
auditDiff(before, after)helperRedact-aware JSON Patch for changes
mockAudit()test utilCapture and assert audits in tests
auditEnricher({ tenantId? })enricherAuto-fill req/trace/ip/ua/tenantId context
auditOnly(drain, { await? })wrapperRoutes only events with event.audit
signed(drain, { strategy: 'hmac' | 'hash-chain', ... })wrapperTamper-evident integrity
auditRedactPresetpresetStrict 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.

AI SDK Integration

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})})

Adapters

Send your logs to external observability platforms with built-in adapters.

Axiom

// 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-dataset

OTLP (OpenTelemetry)

Works 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

Datadog

// 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.eu

Wide 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.

PostHog

// 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

Sentry

// 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

Better Stack

// 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

HyperDX

// 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.io

File System

Write 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/logs

Memory

In-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=1000

Pair with readMemoryLogs() for dev-only agent access over HTTP. See the Memory adapter docs.

Multiple Destinations

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)])})})

Custom Adapters

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.

Drain Pipeline

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())})

How it works

  1. Events are buffered in memory as they arrive
  2. A batch is flushed when either the batch size is reached or the interval expires (whichever comes first)
  3. If the drain function fails, the batch is retried with the configured backoff strategy
  4. If all retries are exhausted, onDropped is called with the lost events
  5. If the buffer exceeds maxBufferSize, the oldest events are dropped to prevent memory leaks

Options

OptionDefaultDescription
batch.size50Maximum events per batch
batch.intervalMs5000Max time (ms) before flushing a partial batch
retry.maxAttempts3Total attempts (including first)
retry.backoff'exponential''exponential' | 'linear' | 'fixed'
retry.initialDelayMs1000Base delay for first retry
retry.maxDelayMs30000Upper bound for any retry delay
maxBufferSize1000Max buffered events before dropping oldest
onDropped--Callback when events are dropped

Returned drain function

The function returned by pipeline(drain) is hook-compatible and exposes:

  • drain(ctx) -- Push a single event into the buffer
  • drain.flush() -- Force-flush all buffered events (call on server shutdown)
  • drain.pending -- Number of events currently buffered

API Reference

initLogger(config)

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}>}})

Sampling

At scale, logging everything can become expensive. evlog supports two sampling strategies:

Head Sampling (rates)

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)},},})

Tail Sampling (keep)

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],},},})

Custom Tail Sampling Hook

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}})})

Pretty Output Format

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.

log

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')

createRequestLogger(options)

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 context

Wide event lifecycle and log.fork()

The 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.

Integrationlog.fork()
Express, Fastify, NestJS, SvelteKit, React Router, ElysiaYes
Next.js withEvlogYes
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 () => { ... }).

initWorkersLogger(options?)

Initialize evlog for Cloudflare Workers (object logs + correct severity).

import{initWorkersLogger}from'evlog/workers'initWorkersLogger({env: {service: 'edge-api'},})

defineWorkerFetch(handler)

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')})

createWorkersLogger(request, options?)

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})

createError(options)

Create a structured error with HTTP status support. Import from evlog directly to avoid conflicts with Nuxt/Nitro's createError.

Note: createEvlogError is 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.

parseError(error)

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 Support

FrameworkIntegration
Nuxtmodules: ['evlog/nuxt']
Next.jscreateEvlog() factory with import { createEvlog } from 'evlog/next' (example)
SvelteKitexport const { handle, handleError } = createEvlogHooks() with import { createEvlogHooks } from 'evlog/sveltekit' (example)
Nitro v3modules: [evlog()] with import evlog from 'evlog/nitro/v3'
Nitro v2modules: [evlog()] with import evlog from 'evlog/nitro'
TanStack StartNitro v3 module setup (example)
React Routerevlog() middleware with import { evlog } from 'evlog/react-router' (example)
NestJSEvlogModule.forRoot() with import { EvlogModule } from 'evlog/nestjs' (example)
Expressapp.use(evlog()) with import { evlog } from 'evlog/express' (example)
Honoapp.use(evlog()) with import { evlog } from 'evlog/hono' (example)
Fastifyapp.register(evlog) with import { evlog } from 'evlog/fastify' (example)
Elysia.use(evlog()) with import { evlog } from 'evlog/elysia' (example)
oRPCwithEvlog(handler) + os.use(evlog()) with import { evlog, withEvlog } from 'evlog/orpc' (example)
evedefineEvlogHook() in agent/hooks/evlog.ts with import { defineEvlogHook, useLogger } from 'evlog/eve' (example)
Cloudflare WorkersManual setup with import { initWorkersLogger, createWorkersLogger } from 'evlog/workers' (example)
CustomBuild your own with import { createMiddlewareLogger } from 'evlog/toolkit' (guide)
AnalogNitro v2 module setup
VinxiNitro v2 module setup
SolidStartNitro v2 module setup (example)

CLI

@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
CommandWhat it does
evlog mapScore 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 --allEvery entry point as a check matrix
evlog map --min-score <n>Exit 1 below the threshold — a CI gate
evlog doctorDiagnose 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

Agent Skills

evlog provides Agent Skills to help AI coding assistants understand and implement proper logging patterns in your codebase.

Installation

npx skills add https://www.evlog.dev

What it does

Once installed, your AI assistant will:

  • Review your logging code and suggest wide event patterns
  • Help refactor scattered console.log calls 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

Examples

Add logging to this endpoint
Review my logging code
Raise my evlog map score
Help me set up logging for this service

Philosophy

Inspired by Logging Sucks by Boris Tane.

  1. Wide Events: One log per request with all context
  2. Structured Errors: Errors that explain themselves
  3. Request Scoping: Accumulate context, emit once
  4. Pretty for Dev, JSON for Prod: Human-readable locally, machine-parseable in production

License

MIT

Made by @HugoRCD

About

Digging through logs is not observability. It's hope — wide events, structured errors, TypeScript-first, every runtime.

Topics

Resources

Security policy

Stars

1.8k stars

Watchers

3 watching

Forks

Releases

Used by

Contributors

Languages