# evlog — Digging through logs is not observability. It's hope. ::landing-hero #title Digging through logs :br is not observability. :br It's hope #description A modern TypeScript logger built for everything you ship. Simple logs, wide events, and structured errors — one API, every context. :: :landing-logos ::landing-features #body :::features-feature-simple-api --- link: /start/quick-start link-label: Quick start guide --- #headline Simple API #title Set context. :br Get answers #description Accumulate context with log.set, throw structured errors with why and fix, group recurring errors in typed catalogs. One wide event captures everything, whether the request succeeds or fails. ::: :::features-feature-agent-ready --- link: /reference/agent-skills link-label: Agent skills setup --- #headline Agent Ready #title Built for agents #description Structured fields, machine-readable context, and actionable metadata that give AI agents everything they need to diagnose and resolve issues on their own. Enable the file system drain to write NDJSON logs locally and let agents read them directly. ::: :::features-feature-cli-map --- link: /cli/map link-label: evlog map guide --- #headline Observability Score #title One command. :br Every blind spot #description The first time most teams learn a handler logs nothing is mid-incident. Think Lighthouse, but for observability: a score for the context your app will give you when it breaks, and the exact list of fixes to raise it — before it matters. ::: :::features-feature-adapters --- link: /integrate/adapters/overview link-label: Explore adapters --- #headline Drain Pipeline #title Send everywhere #description Batched writes, automatic retries with backoff, and fan-out to multiple destinations. Your logs flow through a pipeline that never blocks your response. ::: :::features-feature-client-drain --- link: /use-cases/client-logging link-label: Client logging guide --- #headline Client Logs #title See the full picture #description Capture browser events and drain them to your server. Automatic batching, retries, and page-aware flushing with the same pipeline from client to server. ::: :::features-feature-sampling --- link: /learn/sampling link-label: Sampling guide --- #headline Sampling #title Keep what matters #description Two-tier filtering: head sampling drops noise by level, tail sampling rescues critical events. Never miss errors, slow requests, or critical paths. ::: :::features-feature-audit --- link: /use-cases/audit/overview link-label: Audit logs guide --- #headline Audit Logs #title Compliance-ready :br by composition #description First-class who-did-what trails as a thin layer on top of wide events. One enricher, one drain wrapper, one helper. Tamper-evident hash chains, denied actions, redact-aware diffs, idempotency keys for safe retries, and typed action catalogs for refactor-safe alerting — all from the main entrypoint, no parallel pipeline. ::: :::features-feature-ai-sdk --- link: /use-cases/ai-sdk/overview link-label: AI SDK integration --- #headline AI Observability #title Make AI calls :br observable #description Your AI endpoints are black boxes. You don't know how many tokens each request burns, which tools the model called, or how fast the stream was. Wrap your model with one line and every call is captured into the wide event. Cost estimation, tool execution timing, streaming performance, cache hits, reasoning tokens, and multi-step agent breakdowns. ::: :::features-feature-performance --- link: /reference/performance link-label: Benchmark results --- #headline Performance #title Add logging, :br not overhead #description Zero dependencies, \~6 kB gzip, \~3µs per request. Benchmarked against pino, consola, and winston. 7.7x faster than pino in the wide event pattern (1 correlated event vs 4 separate log lines), competitive on every other path. ::: :::features-feature-frameworks --- link: /integrate/frameworks/overview link-label: Framework integrations --- #headline Frameworks #title Your stack. Covered #description Native integrations for every major framework. One import, zero config, same API everywhere. The Vite plugin adds auto-init, debug stripping, and source location to any Vite-based stack. #nuxt ```ts [server/api/checkout.post.ts] export default defineEventHandler(async (event) => { const log = useLogger(event) const { cartId } = await readBody(event) const cart = await db.findCart(cartId) log.set({ cart: { items: cart.items.length, total: cart.total } }) const charge = await stripe.charge(cart.total) log.set({ stripe: { chargeId: charge.id } }) if (!charge.success) { throw createError({ status: 402, message: 'Payment failed', why: charge.decline_reason, fix: 'Try a different payment method', }) } return { orderId: charge.id } }) ``` #nextjs ```ts [app/api/checkout/route.ts] import { withEvlog, useLogger } from '@/lib/evlog' import { createError } from 'evlog' export const POST = withEvlog(async (req) => { const log = useLogger() const { cartId } = await req.json() const cart = await db.findCart(cartId) log.set({ cart: { items: cart.items.length, total: cart.total } }) const charge = await stripe.charge(cart.total) log.set({ stripe: { chargeId: charge.id } }) if (!charge.success) { throw createError({ status: 402, message: 'Payment failed', why: charge.decline_reason, fix: 'Try a different payment method', }) } return Response.json({ orderId: charge.id }) }) ``` #sveltekit ```ts [src/routes/api/checkout/+server.ts] import { json } from '@sveltejs/kit' import { createError } from 'evlog' import { useLogger } from 'evlog/sveltekit' import type { RequestHandler } from './$types' export const POST: RequestHandler = async ({ request }) => { const log = useLogger() const { cartId } = await request.json() const cart = await db.findCart(cartId) log.set({ cart: { items: cart.items.length, total: cart.total } }) const charge = await stripe.charge(cart.total) log.set({ stripe: { chargeId: charge.id } }) if (!charge.success) { throw createError({ status: 402, message: 'Payment failed', why: charge.decline_reason, fix: 'Try a different payment method', }) } return json({ orderId: charge.id }) } ``` #nitro ```ts [routes/api/checkout.post.ts] import { defineHandler, readBody } from 'nitro/h3' import { useLogger, createError } from 'evlog/nitro/v3' export default defineHandler(async (event) => { const log = useLogger(event) const { cartId } = await readBody(event) const cart = await db.findCart(cartId) log.set({ cart: { items: cart.items.length, total: cart.total } }) const charge = await stripe.charge(cart.total) log.set({ stripe: { chargeId: charge.id } }) if (!charge.success) { throw createError({ status: 402, message: 'Payment failed', why: charge.decline_reason, fix: 'Try a different payment method', }) } return { orderId: charge.id } }) ``` #tanstack-start ```ts [src/routes/api/checkout.ts] import { createFileRoute } from '@tanstack/react-router' import { useRequest } from 'nitro/context' import { createError } from 'evlog' import type { RequestLogger } from 'evlog' export const Route = createFileRoute('/api/checkout')({ server: { handlers: { POST: async ({ request }) => { const req = useRequest() const log = req.context.log as RequestLogger const { cartId } = await request.json() const cart = await db.findCart(cartId) log.set({ cart: { items: cart.items.length, total: cart.total } }) const charge = await stripe.charge(cart.total) log.set({ stripe: { chargeId: charge.id } }) if (!charge.success) { throw createError({ status: 402, message: 'Payment failed', why: charge.decline_reason, fix: 'Try a different payment method', }) } return Response.json({ orderId: charge.id }) }, }, }, }) ``` #react-router ```ts [app/routes/api.checkout.tsx] import { loggerContext } from 'evlog/react-router' import { createError } from 'evlog' export async function action({ request, context }: Route.ActionArgs) { const log = context.get(loggerContext) const { cartId } = await request.json() const cart = await db.findCart(cartId) log.set({ cart: { items: cart.items.length, total: cart.total } }) const charge = await stripe.charge(cart.total) log.set({ stripe: { chargeId: charge.id } }) if (!charge.success) { throw createError({ status: 402, message: 'Payment failed', why: charge.decline_reason, fix: 'Try a different payment method', }) } return Response.json({ orderId: charge.id }) } ``` #nestjs ```ts [app.module.ts] import { Module } from '@nestjs/common' import { EvlogModule } from 'evlog/nestjs' import { createAxiomDrain } from 'evlog/axiom' @Module({ imports: [ EvlogModule.forRoot({ drain: createAxiomDrain(), }), ], }) export class AppModule {} ``` #express ```ts [src/index.ts] import { evlog, useLogger } from 'evlog/express' import { createAxiomDrain } from 'evlog/axiom' const app = express() app.use(evlog({ drain: createAxiomDrain() })) app.post('/checkout', async (req, res) => { const log = useLogger() const { cartId } = req.body const cart = await db.findCart(cartId) log.set({ cart: { items: cart.items.length, total: cart.total } }) const charge = await stripe.charge(cart.total) log.set({ stripe: { chargeId: charge.id } }) res.json({ orderId: charge.id }) }) ``` #hono ```ts [src/index.ts] import { evlog, type EvlogVariables } from 'evlog/hono' import { createAxiomDrain } from 'evlog/axiom' const app = new Hono() app.use(evlog({ drain: createAxiomDrain() })) app.post('/checkout', async (c) => { const log = c.get('log') const { cartId } = await c.req.json() const cart = await db.findCart(cartId) log.set({ cart: { items: cart.items.length, total: cart.total } }) const charge = await stripe.charge(cart.total) log.set({ stripe: { chargeId: charge.id } }) return c.json({ orderId: charge.id }) }) ``` #fastify ```ts [src/index.ts] import { evlog } from 'evlog/fastify' import { createAxiomDrain } from 'evlog/axiom' const app = Fastify({ logger: false }) await app.register(evlog, { drain: createAxiomDrain() }) app.post('/checkout', async (request) => { const { cartId } = request.body const cart = await db.findCart(cartId) request.log.set({ cart: { items: cart.items.length, total: cart.total } }) const charge = await stripe.charge(cart.total) request.log.set({ stripe: { chargeId: charge.id } }) return { orderId: charge.id } }) ``` #elysia ```ts [src/index.ts] import { evlog } from 'evlog/elysia' import { createAxiomDrain } from 'evlog/axiom' const app = new Elysia() .use(evlog({ drain: createAxiomDrain() })) .post('/checkout', async ({ log, body }) => { const { cartId } = body const cart = await db.findCart(cartId) log.set({ cart: { items: cart.items.length, total: cart.total } }) const charge = await stripe.charge(cart.total) log.set({ stripe: { chargeId: charge.id } }) return { orderId: charge.id } }) ``` #orpc ```ts [server/orpc.ts] import { os } from '@orpc/server' import { RPCHandler } from '@orpc/server/fetch' import { evlog, withEvlog, type EvlogOrpcContext } from 'evlog/orpc' import { createAxiomDrain } from 'evlog/axiom' const base = os.$context().use(evlog()) const router = { checkout: base .input(z.object({ cartId: z.string() })) .handler(async ({ input, context }) => { const cart = await db.findCart(input.cartId) context.log.set({ cart: { items: cart.items.length, total: cart.total } }) const charge = await stripe.charge(cart.total) context.log.set({ stripe: { chargeId: charge.id } }) return { orderId: charge.id } }), } const handler = withEvlog(new RPCHandler(router), { drain: createAxiomDrain() }) ``` #cloudflare ```ts [src/worker.ts] import { defineWorkerFetch, initWorkersLogger } from 'evlog/workers' initWorkersLogger({ env: { service: 'checkout-worker' } }) export default defineWorkerFetch(async (request, env, _ctx, log) => { const { cartId } = await request.json() const cart = await env.DB.findCart(cartId) log.set({ cart: { items: cart.items.length, total: cart.total } }) log.emit() return Response.json({ orderId: cart.id }) }) ``` #bun ```ts [scripts/migrate-users.ts] import { initLogger, createLogger } from 'evlog' initLogger({ env: { service: 'migrate' } }) const log = createLogger({ task: 'user-migration' }) const users = await db.query('SELECT * FROM legacy_users') log.set({ found: users.length }) for (const user of users) { await newDb.upsert({ id: user.id, email: user.email, plan: user.plan }) } log.set({ migrated: users.length, status: 'complete' }) log.emit() ``` ::: :: ::landing-cta #description One logger for every context. Set it up in 10 minutes. Your future self will thank you. :: # Introduction **evlog** is a modern TypeScript logger built for everything you ship. It gives you simple structured logs (a drop-in for `console.log`, pino, or consola), wide events that accumulate context across an operation, and structured errors that explain why they happened — all in one API, all behind the same drain pipeline. Use it in CLIs, libraries, background jobs, edge workers, and HTTP handlers without switching loggers. Inspired by [Logging Sucks](https://loggingsucks.com/){rel=""nofollow""} by [Boris Tane](https://x.com/boristane){rel=""nofollow""}. When you want a static score of which entry points can tell you what went wrong, try the separate [`@evlog/cli`](https://www.evlog.dev/cli/overview) (`evlog map`) — early, no config, one command. ## Philosophy Traditional logging is broken. Your logs are scattered across dozens of files. Each request generates 10+ log lines. When something goes wrong, you're left grep-ing through noise hoping to find signal. **evlog** takes a different approach: ::card-group :::card{icon="i-lucide-terminal" title="Structured Logging"} Replace `console.log` with typed, structured events that flow through a drain pipeline. Same level filtering, redaction, and pretty/JSON output as pino or consola. ::: :::card{icon="i-lucide-layers" title="Wide Events"} Accumulate context over any unit of work (a request, script, or job) and emit once. The two modes coexist — neither is an upgrade of the other. ::: :::card{icon="i-lucide-shield-alert" title="Structured Errors"} Errors that explain why they occurred and how to fix them. ::: :::card{icon="i-lucide-palette" title="Pretty for Dev"} Human-readable in development, machine-parseable JSON in production. ::: :: ::callout{color="info" icon="i-lucide-globe"} Not running an HTTP framework? See [Standalone TypeScript](https://www.evlog.dev/integrate/frameworks/standalone) for scripts, workers, and CLIs, and [Cloudflare Workers](https://www.evlog.dev/integrate/frameworks/cloudflare-workers) for the edge runtime. :: ## Why evlog over pino, winston, or consola evlog is a fully-featured general-purpose logger that happens to also do wide events. Concretely: - **Zero transitive dependencies** and \~6 kB gzip — nothing to audit, nothing that breaks on the next Node LTS. Benchmarked at [\~3 µs/request](https://www.evlog.dev/reference/performance), 7.7x faster than pino in the wide event pattern (1 event vs 4 log lines) and competitive on every other path. - **Same API in every context** — scripts, frameworks, edge runtimes, browser, library code. No `pino-http` vs `pino` split, no separate `consola` reporters per environment. - **Structured errors with `why` / `fix` / `link` built in** — your error toast finally tells users what went wrong and what to do, your on-call stops reverse-engineering stack traces. - **Wide events as a free upgrade path** — when you need to correlate context across an operation, the same logger gives you `log.set` + `log.emit` instead of stitching log lines together later. See the full [feature comparison](https://www.evlog.dev/reference/vs-other-loggers) (parity matrix, honest gaps, and migration snippets) for a side-by-side with pino, winston, and consola. ## Three Ways to Log evlog provides three APIs for different contexts. You can use all three in the same project. ### Simple Logging Fire-and-forget structured logs. Replace `console.log`, consola, or pino: ```typescript [src/index.ts] import { log } from 'evlog' log.info('auth', 'User logged in') log.error({ action: 'payment', error: 'card_declined', userId: 42 }) ``` ### Wide Events Accumulate context progressively over any operation, then emit a single comprehensive event: ::code-group ```typescript [scripts/sync-job.ts] import { createLogger } from 'evlog' const log = createLogger({ jobId: 'sync-001', queue: 'emails' }) log.set({ batch: { size: 50, processed: 50 } }) log.emit() ``` ```typescript [src/worker.ts] import { createRequestLogger } from 'evlog' const log = createRequestLogger({ method: 'POST', path: '/api/checkout' }) log.set({ user: { id: 1, plan: 'pro' } }) log.emit() ``` ```typescript [server/api/checkout.post.ts] import { useLogger } from 'evlog' export default defineEventHandler(async (event) => { const log = useLogger(event) log.set({ user: { id: 1, plan: 'pro' } }) return { success: true } // auto-emitted on response end }) ``` :: One log, all context. Everything you need to understand what happened. ### Structured Errors Errors with actionable context: `why` it happened, how to `fix` it, and a `link` to docs: ::code-group ```typescript [server/api/checkout.post.ts] import { createError } from 'evlog' throw createError({ message: 'Payment failed', status: 402, why: 'Card declined by issuer (insufficient funds)', fix: 'Try a different payment method or contact your bank', link: 'https://docs.example.com/payments/declined', }) ``` ```json [Response] { "statusCode": 402, "message": "Payment failed", "data": { "why": "Card declined by issuer (insufficient funds)", "fix": "Try a different payment method or contact your bank", "link": "https://docs.example.com/payments/declined" } } ``` :: ## Why Context Matters We're entering an era where AI agents build, debug, and maintain applications. These agents need **structured context** to work effectively: - **`why`**: The root cause, so the agent understands what went wrong - **`fix`**: An actionable solution the agent can suggest or apply - **`link`**: Documentation for complex issues Traditional `console.log` and generic `throw new Error()` provide no actionable context. evlog's structured output is designed for both humans and AI to parse and act on. ## Next Steps - [Why start with evlog](https://www.evlog.dev/start/why-evlog) - The case for adopting evlog on day zero - [Installation](https://www.evlog.dev/start/installation) - Install evlog in your project - [Quick Start](https://www.evlog.dev/start/quick-start) - Get up and running in minutes - [Logging Overview](https://www.evlog.dev/learn/overview) - Understand the three logging modes in depth - [evlog vs pino, winston, consola](https://www.evlog.dev/reference/vs-other-loggers) - Feature parity, honest gaps, and migration snippets - [Standalone TypeScript](https://www.evlog.dev/integrate/frameworks/standalone) — scripts, workers, libraries without a web framework # Why start with evlog The cheapest moment to add structured logging is **before the first request**. By the time you have 200 routes, 40 background jobs, and a `console.log` per file, you're paying interest on a decision you never made. evlog is designed for the day-zero choice — pick it once, and the rest of the system inherits structured logs, structured errors, typed catalogs, AI SDK telemetry, an audit trail, and a drain pipeline you don't have to build later. ::callout{color="neutral" icon="i-lucide-arrow-right"} Already shipping with `console.log` or pino? evlog still wins, but the case is different — see [evlog vs pino, winston, consola](https://www.evlog.dev/reference/vs-other-loggers) . :: ## What you get from day one evlog isn't a small primitive you wrap. It's a single dependency that comes with a structured surface, an ecosystem of integrations, and an opinionated drain pipeline — all on by default. ### Logging primitives ::card-group :::card{icon="i-lucide-shield" title="Auto-redaction"} PII (emails, cards, IPs, phone numbers, JWTs, Bearer tokens, IBANs) is masked before console output and before any drain. See [Auto-Redaction](https://www.evlog.dev/learn/redaction) . ::: :::card{icon="i-lucide-shield-alert" title="Structured errors"} Every error already carries `why` , `fix` , and `link` for your on-call (and your future AI agent). See [Structured Errors](https://www.evlog.dev/learn/structured-errors) . ::: :::card{icon="i-lucide-layers" title="Wide events"} Accumulate context across an operation and emit one typed event when it ends — the observability pattern that makes requests, jobs, and workflows queryable end-to-end. See [Wide Events](https://www.evlog.dev/learn/wide-events) . ::: :::card{icon="i-lucide-book-marked" title="Typed catalogs"} `defineErrorCatalog` and `defineAuditCatalog` give you enum-like, refactor-safe codes and actions. Start with two entries, grow to a published package. See [Catalogs](https://www.evlog.dev/learn/catalogs) . ::: :::card{icon="i-lucide-percent" title="Head + tail sampling"} Drop low-importance events at emit time, force-keep slow requests and errors. Configure once, scale forever. See [Sampling](https://www.evlog.dev/learn/sampling) . ::: :::card{icon="i-lucide-database" title="Drain pipeline"} Built-in batching, retry with exponential backoff, fan-out to multiple destinations. No transport glue to write yourself. ::: :: ### Beyond the logger The primitives are table stakes — every modern logger has some flavour of them. Where evlog earns its place on day 1 is everything wired around them, for problems you haven't had yet. **Imagine you add AI to your app.** Sooner or later you wire the Vercel AI SDK into a route. Token costs surprise you, a model hangs mid-stream, a tool returns garbage. With the [AI SDK integration](https://www.evlog.dev/use-cases/ai-sdk/overview), every model call becomes a wide event with prompt, tools, tokens, latency, and cost — automatically. And if you're using [Better Auth](https://www.evlog.dev/use-cases/better-auth/overview), evlog ties the actor identity to those events for you, so you can answer "which user just burned $14 in a single conversation?" without writing a line of plumbing. **Imagine your stack spans more than one framework.** A Nuxt frontend, a Hono internal service, an AWS Lambda webhook — most teams end up with this kind of mix. evlog has [13+ framework integrations](https://www.evlog.dev/integrate/frameworks/overview) and each one exposes the same logging primitives (`useLogger`, `log.set`, `createError`). The handlers themselves stay framework-shaped — that part is on you — but you don't relearn a logger every time you cross a runtime, and the drain pipeline behind them stays the same. **Imagine you want noisier logs in dev than in production.** During local development you sprinkle `log.debug` calls — full request bodies, every retry, every guard — to actually see what's happening. None of that should ship. The [Vite plugin](https://www.evlog.dev/reference/vite-plugin) strips selected log levels at build time, so dev has the verbose context you want and production stays clean. As a bonus, every surviving call gets its source location (`file.ts:42`) injected automatically, so when an event lands in your dashboard you know exactly which line emitted it. **Imagine a user reports a bug from their browser.** The error happened in their session, deep inside a fetch you can't reproduce. evlog's [browser logger](https://www.evlog.dev/use-cases/client-logging) ships client events to your server, where they merge into the same wide event as the rest of the request — one typed event, regardless of where the error originated. Combine it with the [built-in enrichers](https://www.evlog.dev/use-cases/enrichers) and you also get UA, GeoIP, and W3C trace context attached for free. **Imagine your stack changes vendor.** You started with stdout, signed Axiom for queries, then your team wants Sentry for errors and PostHog for product analytics. With [9+ drain adapters](https://www.evlog.dev/integrate/adapters/overview) and built-in fan-out, those events land everywhere in parallel — the application code never moves. Self-hosting is a swap-in too, via the [filesystem](https://www.evlog.dev/integrate/adapters/self-hosted/fs) or [NuxtHub](https://www.evlog.dev/integrate/adapters/self-hosted/nuxthub) adapters. None of this is a "v2 feature" — it's the same package, on the same `log` API, on day 1. ## Catalogs grow with you The smallest useful catalog is two entries: ```typescript [src/errors.ts] import { defineErrorCatalog } from 'evlog' export const errors = defineErrorCatalog('billing', { PAYMENT_DECLINED: { status: 402, message: 'Payment declined' }, INVOICE_NOT_FOUND: { status: 404, message: 'Invoice not found' }, }) ``` Six months later it has thirty entries, type augmentation gives you autocomplete on `createError({ code })` everywhere, and you ship it as a private npm package across your monorepo. **Same pattern. No rewrite.** The same applies to audit catalogs (`defineAuditCatalog`) — start with one action, grow into your compliance map. See [Catalogs](https://www.evlog.dev/learn/catalogs). ## Built for the AI-coding-agent era More and more applications are built with AI coding agents — Cursor, Codex, Claude Code, Copilot. They're good at writing handlers; they're worse at debugging them. **What they need is context.** - **Structured errors with `why` / `fix` / `link`.** A vague `Error: failed` is opaque; `createError({ message, why, fix, link })` is something an agent can read, summarise, or surface to the user without you wiring a translation layer. - **Wide events as a single source of truth per request.** One typed event the agent can reason about end-to-end — not log lines to grep across. - **Typed catalogs as an enum-like surface.** The agent doesn't invent error codes; it picks from `errors.PAYMENT_DECLINED`, `audit.INVOICE_REFUND`, etc., with autocomplete from the catalog. - **AI SDK telemetry on every LLM call.** When the agent's own model calls fail, hallucinate, or burn budget, the wide event tells you which prompt, which tools, how many tokens, how much it cost. - **Agent skills built in.** evlog ships [agent skills](https://www.evlog.dev/reference/agent-skills) so Cursor / Claude already know how to wire it up — no manual prompt-engineering. ::callout{color="info" icon="i-lucide-bot"} If your team's velocity comes from AI coding agents, the quality of your logs *is* the quality of their context window. Day 0 is when you set that ceiling. :: ## Audit and compliance: cheap now, expensive later Every product eventually meets one of: GDPR data-export requests, SOC 2 readiness, HIPAA in healthtech, PCI in payments, or simply an incident review where someone asks **"who deleted that?"**. There are two ways to get a trail: 1. **The day-1000 way.** Stand up a parallel system. Decide on a schema. Backfill what you can. Reverse-engineer actor identity from request headers. Ship under deadline pressure. Hope nothing was missed. 2. **The day-0 way.** Add `auditEnricher()` and call `log.audit({ action, actor, target })` from any handler that touches state. evlog ships hash-chain integrity, retention, and force-keep past sampling — on top of the wide events you were already emitting. evlog's audit layer is **not a parallel system** — it's the same `log` you already use, with a reserved `audit` field. See [Audit Logs](https://www.evlog.dev/use-cases/audit/overview). ::callout{color="success" icon="i-lucide-shield-check"} Teams in regulated verticals — fintech, healthtech, B2B SaaS — should treat day-0 audit logging as table stakes, not a v2 feature. :: ## Drain-agnostic from day one Your application code never depends on a vendor — it emits to the drain pipeline. On day 0 that's stdout in dev and a [filesystem drain](https://www.evlog.dev/integrate/adapters/self-hosted/fs) in CI. The day you decide to query logs: ```typescript [nuxt.config.ts] import { createAxiomDrain } from 'evlog/adapters/axiom' import { createSentryDrain } from 'evlog/adapters/sentry' export default defineNuxtConfig({ evlog: { drain: { adapters: [ createAxiomDrain({ token: '...', dataset: 'app' }), createSentryDrain({ dsn: '...' }), ], }, }, }) ``` Zero handler changes. The same events land in [Axiom](https://www.evlog.dev/integrate/adapters/cloud/axiom), [Datadog](https://www.evlog.dev/integrate/adapters/cloud/datadog), [PostHog](https://www.evlog.dev/integrate/adapters/cloud/posthog), [Sentry](https://www.evlog.dev/integrate/adapters/cloud/sentry), [Better Stack](https://www.evlog.dev/integrate/adapters/cloud/better-stack), [HyperDX](https://www.evlog.dev/integrate/adapters/hybrid/hyperdx), or [OTLP](https://www.evlog.dev/integrate/adapters/hybrid/otlp) — or all of them, with [fan-out](https://www.evlog.dev/integrate/adapters/overview). ## What "later" actually costs When teams ship with `console.log` and decide to add proper logging "when we need it", the bill comes due: - **Settling field-name conventions after the fact.** Is it `userId`, `user_id`, `uid`, or `actor.id`? Once thirty services log it differently, you're writing migration scripts inside your observability vendor. - **Bolting on redaction post-incident.** Auto-redaction is trivial when no log exists yet. It's a P1 audit when six months of logs already contain PII. - **Choosing a drain under pressure.** Picking Datadog vs Axiom vs Sentry while you're already on fire is the worst time to evaluate vendors. - **Adding actionable error context retroactively.** Every `throw new Error('failed')` you wrote is one less `why`, one less `fix`, one less link your on-call (or your AI agent) can use. evlog removes the "later" entirely — the structured surface, the wide event lifecycle, and the drain pipeline are all there from day 1. | Decision day | Cost to adopt evlog | | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Day 0 (greenfield) | Add the framework module. Done. | | Day 30 (small app) | Switch the logger surface — about a day of work. | | Day 365 (production app) | Walk the codebase to swap loggers, settle field-name conventions, fold in audit and redaction. The same cost as migrating between any two structured loggers. | The asymmetry is the point. **Start with evlog because the cost is zero. Stay with evlog because the cost of leaving is higher than building any of this yourself.** ## Day 0, in practice ::prompt --- actions: - copy - cursor - claude description: Start a new project with evlog wired in from the first commit icon: i-lucide-rocket --- I'm starting a new project. Wire in evlog from the first commit so I never have to retrofit logging. - Detect my framework and install the matching evlog integration (Nuxt, Next.js, SvelteKit, Hono, Express, etc.) - Set evlog.env.service to my app name and enable redact: true - Add a useLogger(event) call in one route handler with log.set({ user, action }) so I have a working wide event from the start - Throw one createError({ message, status, why, fix }) for an invalid input case - Create a tiny errors catalog with defineErrorCatalog('app', { ... }) — start with two entries, grow over time - If the project uses Vercel AI SDK, wire the AI SDK middleware so every LLM call records tokens, tools, and cost - If the project uses Better Auth, install the Better Auth plugin so auth events are typed from day 1 - Configure a filesystem drain for local dev (.evlog/logs) and leave the cloud drain commented out, ready to enable - If my project is in a regulated vertical (fintech / healthtech / B2B SaaS), also wire the audit enricher with defineAuditCatalog Docs: {rel=""nofollow""} Frameworks: {rel=""nofollow""} Catalogs: {rel=""nofollow""} AI SDK: {rel=""nofollow""} Audit: {rel=""nofollow""} :: ## Next steps - [Installation](https://www.evlog.dev/start/installation) — pick your framework - [Quick Start](https://www.evlog.dev/start/quick-start) — `useLogger`, `createLogger`, `createError` in 2 minutes - [Catalogs](https://www.evlog.dev/learn/catalogs) — typed errors and audit actions, day-0 to monorepo-scale - [Audit Logs](https://www.evlog.dev/use-cases/audit/overview) — day-0 compliance posture - [AI SDK](https://www.evlog.dev/use-cases/ai-sdk/overview) — token usage, tool calls, streaming metrics - [Better Auth](https://www.evlog.dev/use-cases/better-auth/overview) — auth events with one-line install - [Best Practices](https://www.evlog.dev/reference/best-practices) — what not to log, redaction, sampling - [evlog vs pino, winston, consola](https://www.evlog.dev/reference/vs-other-loggers) — feature parity matrix and migration snippets # Install evlog evlog supports Nuxt, Next.js, SvelteKit, Nitro, NestJS, and any TypeScript server framework. ::prompt --- actions: - copy - cursor - claude description: Install evlog in my project icon: i-lucide-download --- Install evlog in my TypeScript project. - Detect the framework I'm using (Nuxt, Next.js, SvelteKit, Nitro, NestJS, Express, Hono, Fastify, Elysia, TanStack Start, React Router, Cloudflare Workers, or standalone) - Install evlog with my package manager: pnpm add evlog (or npm/yarn/bun) - Wire up the framework-specific integration (module, plugin, or middleware) - Set evlog.env.service to my app name - Confirm useLogger, createError, and parseError are available Docs: {rel=""nofollow""} Frameworks: {rel=""nofollow""} :: ## Install ::code-group ```bash [pnpm] pnpm add evlog ``` ```bash [bun] bun add evlog ``` ```bash [yarn] yarn add evlog ``` ```bash [npm] npm install evlog ``` :: ### Using Agent Skills If you use an AI assistant (Claude Code, Cursor, etc.), install the evlog skill for guided setup and code review: ```bash [Terminal] npx skills add https://www.evlog.dev ``` Your AI assistant can then help you set up evlog, review your logging patterns, and migrate existing code to wide events. See [Agent Skills](https://www.evlog.dev/reference/agent-skills) for details. ::callout{color="neutral" icon="i-lucide-radar"} **Try `@evlog/cli`.** Separate package, early days — but one command scores which entry points are still dark. No install required: ```bash npx @evlog/cli map # or: pnpm dlx @evlog/cli map ``` See [CLI overview](https://www.evlog.dev/cli/overview) and [`evlog map`](https://www.evlog.dev/cli/map). Pin as a dev dependency only when you want it in CI. :: ## Choose Your Framework After installing the package, follow the setup guide for your framework: ::card-group :::card --- color: neutral icon: i-simple-icons-nuxtdotjs title: Nuxt to: https://www.evlog.dev/integrate/frameworks/nuxt --- Module with auto-imported `useLogger` , `createError` , and `parseError` . ::: :::card --- color: neutral icon: i-simple-icons-nextdotjs title: Next.js to: https://www.evlog.dev/integrate/frameworks/nextjs --- `createEvlog()` factory with `withEvlog()` handler wrapper. ::: :::card --- color: neutral icon: i-simple-icons-svelte title: SvelteKit to: https://www.evlog.dev/integrate/frameworks/sveltekit --- Handle and handleError hooks with `event.locals.log` . ::: :::card --- color: neutral icon: i-custom-nitro title: Nitro to: https://www.evlog.dev/integrate/frameworks/nitro --- Module for Nitro v2 and v3 with plugin-based hooks. ::: :::card --- color: neutral icon: i-custom-tanstack title: TanStack Start to: https://www.evlog.dev/integrate/frameworks/tanstack-start --- Uses Nitro v3 module with async context. ::: :::card --- color: neutral icon: i-custom-reactrouter title: React Router to: https://www.evlog.dev/integrate/frameworks/react-router --- Middleware with `context.get(loggerContext)` . ::: :::card --- color: neutral icon: i-simple-icons-nestjs title: NestJS to: https://www.evlog.dev/integrate/frameworks/nestjs --- `EvlogModule.forRoot()` with global middleware. ::: :::card --- color: neutral icon: i-simple-icons-express title: Express to: https://www.evlog.dev/integrate/frameworks/express --- Middleware with `req.log` . ::: :::card --- color: neutral icon: i-simple-icons-hono title: Hono to: https://www.evlog.dev/integrate/frameworks/hono --- Middleware with `c.get('log')` . ::: :::card --- color: neutral icon: i-simple-icons-fastify title: Fastify to: https://www.evlog.dev/integrate/frameworks/fastify --- Plugin with `request.log` . ::: :::card --- color: neutral icon: i-custom-elysia title: Elysia to: https://www.evlog.dev/integrate/frameworks/elysia --- Plugin with `log` in route context. ::: :::card --- color: neutral icon: i-lucide-network title: oRPC to: https://www.evlog.dev/integrate/frameworks/orpc --- `withEvlog()` handler wrapper + `evlog()` procedure middleware. ::: :::card --- color: neutral icon: i-simple-icons-cloudflare title: Cloudflare Workers to: https://www.evlog.dev/integrate/frameworks/cloudflare-workers --- Factory for request-scoped loggers. ::: :: ::callout{color="neutral" icon="i-lucide-arrow-right"} See the full [Framework Integrations](https://www.evlog.dev/integrate/frameworks/overview) page for a comparison table and all available integrations including [Standalone TypeScript](https://www.evlog.dev/integrate/frameworks/standalone) , [Astro](https://www.evlog.dev/integrate/frameworks/astro) , and [Custom Integration](https://www.evlog.dev/extend/custom-framework) . :: ## TypeScript Configuration evlog ships with full TypeScript type definitions. No additional configuration is required. ::callout{color="success" icon="i-lucide-check"} evlog requires TypeScript 5.0 or higher for optimal type inference. :: ## Next Steps - [Quick Start](https://www.evlog.dev/start/quick-start) - Learn the core concepts and start using evlog - [Wide Events](https://www.evlog.dev/learn/wide-events) - Understand the wide event pattern - [Adapters](https://www.evlog.dev/integrate/adapters/overview) - Send logs to Axiom, PostHog, Sentry, and more # Quick Start This guide covers the core APIs you'll use most often with evlog. ::callout{color="info" icon="i-lucide-sparkles"} In Nuxt, evlog **auto-imports** all functions ( `useLogger` , `log` , `createError` , `parseError` ). No import statements needed. :: ::prompt --- actions: - copy - cursor - claude description: Get evlog running in 2 minutes icon: i-lucide-zap --- Get evlog running in my project in under 2 minutes. - Install evlog: pnpm add evlog - Detect my framework and wire up the matching integration - Set evlog.env.service to my app name - Add a single useLogger(event) call in a route handler with log.set({ ... }) - Throw one createError({ message, status, why, fix }) for an invalid input case - Trigger the route locally and confirm a single wide event prints to the terminal Docs: {rel=""nofollow""} Frameworks: {rel=""nofollow""} :: ## log (Simple Logging) The simplest way to use evlog. Fire-and-forget structured logs, anywhere in your code: ::code-group ```typescript [Server] import { log } from 'evlog' log.info('auth', 'User logged in') log.error({ action: 'payment', error: 'card_declined' }) log.warn('cache', 'Cache miss') ``` ```bash [Output] 10:23:45.612 [auth] User logged in 10:23:45.613 ERROR [my-app] action=payment error=card_declined 10:23:45.614 [cache] Cache miss ``` :: Two call styles: - **Tagged**: `log.info('tag', 'message')` for quick, readable console output - **Structured**: `log.info({ key: value })` for rich events that flow through the drain pipeline ::callout{color="neutral" icon="i-lucide-arrow-right"} See the full [Simple Logging](https://www.evlog.dev/learn/simple-logging) guide for all patterns and drain integration. :: ## createLogger (Wide Events) When you need to **accumulate context** across multiple steps of an operation, whether a script, background job, queue worker, or workflow, use `createLogger`: ::code-group ```typescript [scripts/sync-job.ts] import { initLogger, createLogger } from 'evlog' initLogger({ env: { service: 'sync-worker' } }) const log = createLogger({ jobId: job.id, queue: 'emails' }) log.set({ batch: { size: 50 } }) log.set({ batch: { processed: 50 } }) log.emit() ``` ```bash [Output (Pretty)] 10:23:45.612 INFO [sync-worker] in 1204ms ├─ jobId: job_abc123 ├─ queue: emails └─ batch: size=50 processed=50 ``` :: `createLogger()` accepts any initial context as a plain object. It returns a logger with `set`, `error`, `info`, `warn`, `emit`, and `getContext`. For HTTP request contexts specifically, use `createRequestLogger()` which pre-populates `method`, `path`, and `requestId`: ```typescript [src/worker.ts] import { createRequestLogger } from 'evlog' const log = createRequestLogger({ method: 'POST', path: '/api/checkout' }) ``` ::callout{color="info" icon="i-lucide-info"} With `createLogger` and `createRequestLogger` , you must call `log.emit()` manually. In framework integrations, this happens automatically. :: ## useLogger (Retrieve the Request Logger) When using a framework integration (Nuxt, Hono, Express, etc.), the middleware automatically creates a wide event logger on request start and emits it on response end. `useLogger(event)` retrieves that logger from the request context: ::code-group ```typescript [server/api/checkout.post.ts] import { useLogger } from 'evlog' export default defineEventHandler(async (event) => { const log = useLogger(event) log.set({ user: { id: 1, plan: 'pro' } }) log.set({ cart: { items: 3, total: 9999 } }) const order = await processCheckout() log.set({ orderId: order.id }) return { success: true, orderId: order.id } }) ``` ```bash [Output (Pretty)] 10:23:45.612 INFO [my-app] POST /api/checkout 200 in 234ms ├─ user: id=1 plan=pro ├─ cart: items=3 total=9999 └─ orderId: ord_abc123 ``` :: ::callout{color="success" icon="i-lucide-check"} `useLogger` doesn't create a logger, the framework middleware already did that. It just retrieves it from the event context so you can add data with `set()` . :: ### When to use what | Use `log` | Use `createLogger()` / `createRequestLogger()` | Use `useLogger(event)` | | ------------------------------ | -------------------------------------------------------- | --------------------------------------- | | Quick one-off events | Scripts, jobs, workers, queues, HTTP without a framework | API routes with a framework integration | | No context accumulation needed | Accumulate context over an operation | Retrieve the request-scoped logger | | Client-side logging | Wide events (one log per operation) | Access the auto-managed wide event | ### Service Identification In multi-service architectures, differentiate which service a log belongs to using either route-based configuration or explicit service names. #### Route-Based Configuration Configure service names per route pattern in your `nuxt.config.ts`: ```typescript [nuxt.config.ts] export default defineNuxtConfig({ modules: ['evlog/nuxt'], evlog: { env: { service: 'default-service', }, routes: { '/api/auth/**': { service: 'auth-service' }, '/api/payment/**': { service: 'payment-service' }, '/api/booking/**': { service: 'booking-service' }, }, }, }) ``` Logs from routes matching these patterns will automatically include the configured service name: ```bash [Output] 21:57:10.442 INFO [auth-service] POST /api/auth/login 200 in 1ms ├─ requestId: 88ced16a-bef2-4483-86cb-2b4fb677ea52 ├─ user: id=user_123 email=demo@example.com └─ action: login ``` #### Explicit Service Parameter Override the service name for specific routes using the second parameter of `useLogger`: ```typescript [server/api/legacy/process.post.ts] import { useLogger } from 'evlog' export default defineEventHandler((event) => { const log = useLogger(event, 'legacy-service') log.set({ action: 'process_legacy_request' }) return { success: true } }) ``` ::callout{color="info" icon="i-lucide-info"} **Priority order:** Explicit `useLogger` parameter > Route configuration > `env.service` \> Auto-detected from environment :: ## createError (Structured Errors) Use `createError()` to throw errors with actionable context: ::code-group ```typescript [server/api/checkout.post.ts] import { createError } from 'evlog' throw createError({ message: 'Payment failed', status: 402, why: 'Card declined by issuer', fix: 'Try a different payment method', link: 'https://docs.example.com/payments/declined', }) ``` ```json [Response] { "statusCode": 402, "message": "Payment failed", "data": { "why": "Card declined by issuer", "fix": "Try a different payment method", "link": "https://docs.example.com/payments/declined" } } ``` :: ### Error Fields | Field | Required | Description | | ---------- | -------- | -------------------------------------------------------------------------------------------- | | `message` | Yes | What happened (user-facing) | | `status` | No | HTTP status code (default: 500) | | `why` | No | Technical reason (for debugging) | | `fix` | No | Actionable solution | | `link` | No | Documentation URL for more info | | `cause` | No | Original error (if wrapping) | | `internal` | No | Backend-only fields for logs and wide events — never included in HTTP JSON or `parseError()` | ### Frontend Integration Use `parseError()` to extract all error fields on the client: ```typescript [composables/useCheckout.ts] import { parseError } from 'evlog' export async function checkout(cart: Cart) { try { await $fetch('/api/checkout', { method: 'POST', body: cart }) } catch (err) { const error = parseError(err) 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}`) } } } ``` ## log (Client-Side) The same `log` API works on the client side, outputting to the browser console: ::code-group ```vue [components/CheckoutButton.vue] ``` ```typescript [composables/useAnalytics.ts] export function useAnalytics() { function trackEvent(event: string, data?: Record) { log.info('analytics', `Event: ${event}`) if (data) { log.debug({ event, ...data }) } } return { trackEvent } } ``` :: ::callout{color="neutral" icon="i-lucide-arrow-right"} See [Client Logging](https://www.evlog.dev/use-cases/client-logging) for transport configuration, identity context, and browser drain setup. :: ## Next Steps - [`evlog map`](https://www.evlog.dev/cli/map): See what you just unlocked — score coverage and find entry points that are still dark (`npx @evlog/cli map`) - [Logging Overview](https://www.evlog.dev/learn/overview): Understand all three logging modes - [Wide Events](https://www.evlog.dev/learn/wide-events): Learn how to design effective wide events - [Typed Fields](https://www.evlog.dev/learn/typed-fields): Add compile-time type safety to your wide events - [Structured Errors](https://www.evlog.dev/learn/structured-errors): Master error handling with evlog - [Best Practices](https://www.evlog.dev/reference/best-practices): Security guidelines and production tips # Learn evlog This section is the **mental model** of evlog. By the end, you'll know exactly what evlog does, when each API fits, and how an event flows from your code to your drain. If you're new, read it in order. If you've already shipped with evlog, jump to the page that matches your question. ::callout{color="info" icon="i-lucide-info"} All three modes coexist in the same logger. Pick per call — there's no upgrade path, no advanced mode, no toggle to flip. Same drains, same redaction, same types underneath. :: ::callout{color="neutral" icon="i-lucide-globe"} Not running an HTTP framework? See [Standalone TypeScript](https://www.evlog.dev/integrate/frameworks/standalone) and [Cloudflare Workers](https://www.evlog.dev/integrate/frameworks/cloudflare-workers) . :: ## The three logging modes ::card-group :::card --- color: neutral icon: i-lucide-terminal title: Simple Logging to: https://www.evlog.dev/learn/simple-logging --- A fully-featured general-purpose logger. Replaces `console.log` , consola, pino, or winston with `log.info` , `log.error` , `log.warn` , `log.debug` — same level filtering, drain pipeline, redaction, and pretty/JSON output. ::: :::card --- color: neutral icon: i-lucide-layers title: Wide Events to: https://www.evlog.dev/learn/wide-events --- Accumulate context over a unit of work (a script, job, queue task, or request) then emit a single comprehensive event. ::: :::card --- color: neutral icon: i-lucide-git-branch title: Request Logging to: https://www.evlog.dev/integrate/frameworks/overview --- Auto-managed wide events scoped to HTTP requests. Framework middleware creates the logger and emits it for you. ::: :: ## Quick comparison ### Simple Logging (`log`) One event per call. No accumulation, no lifecycle management. ```typescript [src/index.ts] import { log } from 'evlog' log.info('auth', 'User logged in') log.error({ action: 'payment', error: 'card_declined', userId: 42 }) ``` ### Wide Events (`createLogger` / `createRequestLogger`) One event per unit of work. Accumulate context progressively, emit when done. ::code-group ```typescript [scripts/sync-job.ts] import { createLogger } from 'evlog' const log = createLogger({ jobId: 'sync-001', queue: 'emails' }) log.set({ batch: { size: 50, processed: 50 } }) log.emit() ``` ```typescript [src/worker.ts] import { createRequestLogger } from 'evlog' const log = createRequestLogger({ method: 'POST', path: '/api/checkout' }) log.set({ user: { id: 1, plan: 'pro' } }) log.emit() ``` :: `createRequestLogger` is a thin wrapper around `createLogger` that pre-populates `method`, `path`, and `requestId`. ### Request Logging (framework middleware) Framework integrations create a wide event logger automatically on each request. `useLogger(event)` retrieves the logger that's already attached to the request context: ```typescript [server/api/checkout.post.ts] import { useLogger } from 'evlog' export default defineEventHandler(async (event) => { const log = useLogger(event) log.set({ user: { id: 1, plan: 'pro' } }) return { success: true } // auto-emitted on response end }) ``` ::callout{color="info" icon="i-lucide-info"} `useLogger(event)` doesn't create a logger, it retrieves the one the framework middleware already attached to the event. Each framework has its own way to access it ( `useLogger` , `req.log` , `c.get('log')` , etc.). In Nuxt, `useLogger` is auto-imported. :: ## When to use what | | `log` | `createLogger` / `createRequestLogger` | Framework middleware | | ------------- | -------------------- | -------------------------------------------------------- | --------------------------------------- | | **Use case** | Quick one-off events | Scripts, jobs, workers, queues, HTTP without a framework | API routes with a framework integration | | **Context** | Single call | Accumulate with `set()` | Accumulate with `set()` | | **Emit** | Immediate | Manual `emit()` | Automatic on response end | | **Lifecycle** | None | You manage it | Framework manages it | | **Output** | Console + drain | Console + drain | Console + drain + enrich | ### By context | Context | Best fit | Why | | -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | | **HTTP route in Nuxt / Next / Hono / Express / …** | `useLogger(event)` via [framework integration](https://www.evlog.dev/integrate/frameworks/overview) | One wide event per request, auto-emitted on response end | | **HTTP handler without a framework** | `createRequestLogger({ method, path })` | Same shape as framework middleware, manual emit | | **CLI tool / one-shot script** | `log.*` for steps + `createLogger` for the run summary — see [Standalone](https://www.evlog.dev/integrate/frameworks/standalone) | Pretty in dev, structured in CI, one summary event for the whole run | | **Published library** | `createLogger` only — never `initLogger` — see [Standalone](https://www.evlog.dev/integrate/frameworks/standalone) | Don't pollute the host app's global config or force a drain on consumers | | **Background job / queue worker / cron** | `createLogger({ jobId, queue })` per invocation — see [Standalone](https://www.evlog.dev/integrate/frameworks/standalone) | One wide event per job run, perfect for retry analysis | | **Cloudflare Worker / edge function** | `createWorkersLogger(req)` or `createRequestLogger` — see [Cloudflare Workers](https://www.evlog.dev/integrate/frameworks/cloudflare-workers) | Per-request event, no `process` globals required | | **AWS Lambda** | `initLogger` once + `createLogger` per invocation — see [AWS Lambda](https://www.evlog.dev/integrate/frameworks/aws-lambda) | Cold-start init, per-event scope, drain flush in the handler | | **Batch / pipeline step** | `createLogger({ step })` per stage | One event per stage with inputs and outputs side by side | | **AI agent / LLM call** | `createLogger` + [`createAILogger`](https://www.evlog.dev/use-cases/ai-sdk/overview) | Token usage, tool calls, streaming metrics on the same wide event | | **Library function called inside a request** | `useLogger(event)` from caller, or accept a logger as argument | Inherit the parent's request context, contribute to the same wide event | | **Shared workspace package** | Treat it like a library — see [Standalone](https://www.evlog.dev/integrate/frameworks/standalone) | Host app owns `initLogger` / drain; packages use `createLogger` or accept a logger | ::callout{color="info" icon="i-lucide-lightbulb"} None of these is an "upgrade" of another. Use `log` and `createLogger` in the same file when it makes sense — they share the global drain, redaction, and types. :: ## Shared foundation All three modes share the same foundation: - **Pretty output** in development, **JSON** in production (default, no configuration needed) - **Drain pipeline** to send events to Axiom, Sentry, PostHog, and more — see [Integrate / Adapters](https://www.evlog.dev/integrate/adapters/overview) - **Structured errors** with `why`, `fix`, and `link`, plus optional backend-only **`internal`** for logs - **Sampling** (head + tail) to control log volume in production - **Redaction** that wipes secrets before they ever leave the process - **Zero dependencies**, \~6 kB gzip ## The rest of this section After the three modes, the rest of Learn covers the concepts that show up across every mode: - [Structured Errors](https://www.evlog.dev/learn/structured-errors) — `why`, `fix`, `link`, `internal`, and how `createError` differs from `throw new Error` - [Catalogs](https://www.evlog.dev/learn/catalogs) — typed error / audit catalogs that survive refactors - [Lifecycle](https://www.evlog.dev/learn/lifecycle) — exactly what happens between `emit()` and your drain - [Sampling](https://www.evlog.dev/learn/sampling) — keep all errors and slow requests; drop healthy noise - [Typed Fields](https://www.evlog.dev/learn/typed-fields) — augment `RequestLogger` so `log.set` is autocompleted - [Redaction](https://www.evlog.dev/learn/redaction) — the rules that strip `authorization`, `password`, `token`, etc. before drain When you're done with Learn, head to [Integrate](https://www.evlog.dev/integrate/frameworks/overview) to wire evlog into your stack — or run [`evlog map`](https://www.evlog.dev/cli/map) first to see which parts of your app currently have no way of telling you what went wrong. # Simple Logging The `log` API is evlog's general-purpose logger. Use it the way you'd use pino, consola, or `console.log` — every call emits a structured event through the same drain pipeline as wide events. The two modes coexist; neither is an upgrade of the other. ::callout{color="neutral" icon="i-lucide-globe"} Looking for the same API in CLIs, libraries, jobs, and edge? Start with [Standalone TypeScript](https://www.evlog.dev/integrate/frameworks/standalone) and [Cloudflare Workers](https://www.evlog.dev/integrate/frameworks/cloudflare-workers) . :: ::callout{color="info" icon="i-lucide-sparkles"} In Nuxt, `log` is **auto-imported** . No import statement needed. :: ## Setup For standalone projects (non-Nuxt), initialize once at startup: ```typescript [src/index.ts] import { initLogger, log } from 'evlog' initLogger({ env: { service: 'my-app' }, }) log.info('app', 'Server started') ``` ::callout{color="info" icon="i-lucide-info"} `env.service` defaults to `'app'` if not specified. Only set it if you want a custom service name. :: ## Two Call Styles ### Tagged Logs Pass a tag and a message for quick, readable output: ```typescript [src/index.ts] import { log } from 'evlog' log.info('auth', 'User logged in') log.warn('cache', 'Cache miss for key user:42') log.error('payment', 'Stripe webhook failed') log.debug('router', 'Matched route /api/checkout') ``` ```bash [Output (Pretty)] 10:23:45.612 [auth] User logged in 10:23:45.613 [cache] Cache miss for key user:42 10:23:45.614 ERROR [payment] Stripe webhook failed 10:23:45.615 [router] Matched route /api/checkout ``` ### Structured Events Pass an object for rich, queryable events that flow through the drain pipeline: ```typescript [src/index.ts] import { log } from 'evlog' log.info({ action: 'user_login', userId: 42, method: 'oauth', provider: 'github' }) log.error({ action: 'sync_failed', source: 'postgres', target: 's3', error: 'connection_timeout' }) ``` ```bash [Output (Pretty)] 10:23:45.612 INFO [my-app] ├─ action: user_login ├─ userId: 42 ├─ method: oauth └─ provider: github ``` ::callout{color="info" icon="i-lucide-info"} **Tagged logs** are optimized for console readability. **Structured events** (object form) produce full wide events that flow through the drain pipeline to external services. :: ## Log Levels | Level | Method | When to use | | ------- | ------------- | --------------------------------------------------------------------- | | `info` | `log.info()` | Normal operations: startup, shutdown, successful actions | | `warn` | `log.warn()` | Unexpected but recoverable situations: cache miss, retry, deprecation | | `error` | `log.error()` | Failures that need attention: API errors, timeouts, invalid state | | `debug` | `log.debug()` | Development-only details: SQL queries, intermediate state, routing | ::callout{color="warning" icon="i-lucide-lightbulb"} `log.debug()` calls can be stripped from production builds using the [Vite Plugin](https://www.evlog.dev/reference/vite-plugin) or the Nuxt module's `strip` option. :: ## Common Patterns ### Application Lifecycle ```typescript [src/index.ts] import { log } from 'evlog' log.info('app', 'Starting server on port 3000') log.info({ action: 'db_connected', host: 'localhost', database: 'mydb', pool: 10 }) log.info('app', 'Ready to accept connections') ``` ### Background Tasks ```typescript [src/jobs/cleanup.ts] import { log } from 'evlog' log.info({ action: 'cron_started', job: 'cleanup', schedule: '0 */6 * * *' }) log.info({ action: 'cron_completed', job: 'cleanup', deleted: 42, duration: 1200 }) ``` ### Utility Functions ```typescript [src/utils/webhook.ts] import { log } from 'evlog' function processWebhook(payload: WebhookPayload) { log.info({ action: 'webhook_received', type: payload.type, source: payload.source }) if (!isValid(payload)) { log.warn({ action: 'webhook_invalid', type: payload.type, reason: 'missing_signature' }) return } } ``` ## Drain Integration When using the object form, events are sent through the [drain pipeline](https://www.evlog.dev/integrate/adapters/overview) just like wide events: ```typescript [src/index.ts] import { initLogger, log } from 'evlog' import { createAxiomDrain } from 'evlog/axiom' initLogger({ env: { service: 'my-app' }, drain: createAxiomDrain(), }) log.info({ action: 'deploy', version: '1.2.3', region: 'us-east-1' }) ``` ## Migrating from console / pino / consola / winston Pick the tab matching your current logger to see the **before** call style. The **after (evlog)** snippet underneath is the same regardless of where you came from. ::code-group ```typescript [pino] import pino from 'pino' const log = pino({ name: 'checkout' }) log.info({ event: 'checkout_started' }) log.info({ event: 'cart_loaded', items: 3, total: 9999 }) log.warn({ event: 'inventory_low', sku: 'SKU-42' }) log.error({ event: 'payment_failed', reason: 'card_declined' }) ``` ```typescript [winston] import { createLogger, format, transports } from 'winston' const log = createLogger({ defaultMeta: { service: 'checkout' }, format: format.json(), transports: [new transports.Console()], }) log.info({ event: 'checkout_started' }) log.info({ event: 'cart_loaded', items: 3, total: 9999 }) log.warn({ event: 'inventory_low', sku: 'SKU-42' }) log.error({ event: 'payment_failed', reason: 'card_declined' }) ``` ```typescript [consola] import { consola } from 'consola' const log = consola.withTag('checkout') log.info('Starting checkout') log.info('cart loaded', { items: 3, total: 9999 }) log.warn('inventory low', { sku: 'SKU-42' }) log.error('payment failed', { reason: 'card_declined' }) ``` ```typescript [console.log] console.log('[checkout] Starting checkout') console.log('[checkout] cart loaded', { items: 3, total: 9999 }) console.warn('[checkout] inventory low', { sku: 'SKU-42' }) console.error('[checkout] payment failed', { reason: 'card_declined' }) ``` :: All four become this — no formatter, transport, or peer-dep wiring required: ```typescript [After (evlog)] import { initLogger, log } from 'evlog' initLogger({ env: { service: 'checkout' } }) log.info({ event: 'checkout_started' }) log.info({ event: 'cart_loaded', items: 3, total: 9999 }) log.warn({ event: 'inventory_low', sku: 'SKU-42' }) log.error({ event: 'payment_failed', reason: 'card_declined' }) ``` `initLogger` is one line at boot. The drain, redaction, sampling, pretty/JSON switching, and level filtering are all wired by default — no `pino-pretty` peer dep, no winston transport assembly, no consola reporter setup. ::callout{color="neutral" icon="i-lucide-arrow-right"} Want the full side-by-side (feature comparison tables, honest gaps, per-feature mapping)? See [evlog vs pino, winston, consola](https://www.evlog.dev/reference/vs-other-loggers) . :: ## Pairing with wide events `log` and `createLogger` live inside the same logger. Use `log.*` for events that stand alone (startup messages, ad-hoc warnings, debug traces) and reach for `createLogger` when you want one event that captures an entire operation. They share the global drain, redaction, and types — pick per call. ```typescript [scripts/sync-data.ts] import { initLogger, log, createLogger } from 'evlog' initLogger({ env: { service: 'sync-worker' } }) log.info('sync', 'Worker starting') const run = createLogger({ source: 'postgres', target: 's3' }) try { const records = await fetchRecords() run.set({ found: records.length }) for (const record of records) { await syncOne(record) log.debug({ event: 'record_synced', id: record.id }) } run.set({ status: 'complete', synced: records.length }) } catch (err) { log.error({ event: 'sync_failed' }) run.error(err as Error) throw err } finally { run.emit() } log.info('sync', 'Worker finished') ``` The `log.*` calls give you a real-time trail in development; the `createLogger` block gives your dashboard one queryable row per run. Both go through the same drain. ## Next Steps - [Wide Events](https://www.evlog.dev/learn/wide-events): Accumulate context and emit comprehensive events - [Structured Errors](https://www.evlog.dev/learn/structured-errors): Throw errors with `why`, `fix`, and `link` - [Configuration](https://www.evlog.dev/reference/configuration): All `initLogger` options - [Adapters](https://www.evlog.dev/integrate/adapters/overview): Send events to Axiom, Sentry, PostHog, and more - [Standalone TypeScript](https://www.evlog.dev/integrate/frameworks/standalone): Scripts, workers, and libraries without a web framework - [evlog vs other loggers](https://www.evlog.dev/reference/vs-other-loggers): Side-by-side with pino, winston, consola # Wide Events Wide events are the core concept behind evlog. Instead of scattering logs throughout your codebase, you accumulate context over any unit of work, whether a request, script, job, or workflow, and emit a single, comprehensive log event. ::callout{color="neutral" icon="i-lucide-globe"} Not running an HTTP framework? See [Standalone TypeScript](https://www.evlog.dev/integrate/frameworks/standalone) and [Cloudflare Workers](https://www.evlog.dev/integrate/frameworks/cloudflare-workers) — wide events apply just as cleanly outside of request lifecycles. :: ::prompt --- actions: - copy - cursor - claude description: Convert my request handlers to wide events icon: i-lucide-layers --- Convert my existing request handlers from scattered logs to evlog wide events. - Find handlers that call console.log/logger.info multiple times per request - Replace those with a single useLogger(event) (or framework equivalent) at the top - Use log.set({ user, cart, payment, ... }) to accumulate context as the request progresses - Group related fields into nested objects (user, cart, payment) instead of flat keys - Remove redundant info-level logs once the wide event captures the same information - Keep error logs that capture distinct failure cases via log.error() - Trust the framework integration to auto-emit one wide event per request Docs: {rel=""nofollow""} Best practices: {rel=""nofollow""} :: ## Why Wide Events? :wide-event-collapse Traditional logging creates noise: ```typescript [src/service.ts] logger.info('Job started') logger.info('User authenticated', { userId: user.id }) logger.info('Fetching data', { source: 'postgres' }) logger.info('Processing records') logger.info('Processing complete') logger.info('Job finished', { duration: 234 }) ``` This approach has problems: - **Scattered context**: Information is spread across multiple log lines - **Hard to correlate**: Matching logs to operations requires IDs everywhere - **Noise**: 10+ log lines per operation makes finding issues harder - **Incomplete**: Some logs might be missing if errors occur Wide events solve this: ::code-group ```typescript [server/api/checkout.post.ts] import { useLogger } from 'evlog' const log = useLogger(event) log.set({ user: { id: 1, plan: 'pro' } }) log.set({ cart: { id: 42, items: 3, total: 9999 } }) log.set({ payment: { method: 'card', status: 'success' } }) ``` ```typescript [scripts/sync-data.ts] import { createLogger } from 'evlog' const log = createLogger({ jobId: 'sync-001', queue: 'emails' }) log.set({ source: 'postgres', target: 's3' }) log.set({ records: { found: 1250, synced: 1250 } }) log.emit() ``` ```bash [Output] [INFO] POST /api/checkout (234ms) user: { id: 1, plan: 'pro' } cart: { id: 42, items: 3, total: 9999 } payment: { method: 'card', status: 'success' } status: 200 ``` :: One log, all context. Everything you need to understand what happened. ## Creating Wide Events ### `createLogger` (General Purpose) Use `createLogger()` for scripts, background jobs, queue workers, cron jobs, or any operation where you manage the lifecycle: ```typescript [scripts/migrate-users.ts] import { initLogger, createLogger } from 'evlog' initLogger({ env: { service: 'migrate' } }) const log = createLogger({ task: 'user-migration' }) const users = await db.query('SELECT * FROM legacy_users') log.set({ found: users.length }) let migrated = 0 for (const user of users) { await newDb.upsert({ id: user.id, email: user.email, plan: user.plan }) migrated++ } log.set({ migrated, status: 'complete' }) log.emit() ``` ### `createRequestLogger` (HTTP Contexts) Use `createRequestLogger()` when working with HTTP requests outside of a framework integration. It's a thin wrapper around `createLogger` that pre-populates `method`, `path`, and `requestId`: ```typescript [src/worker.ts] import { initLogger, createRequestLogger } from 'evlog' initLogger({ env: { service: 'my-worker' } }) const log = createRequestLogger({ method: 'POST', path: '/api/checkout' }) log.set({ user: { id: 1, plan: 'pro' } }) log.set({ cart: { items: 3, total: 9999 } }) log.emit() ``` ::callout{color="info" icon="i-lucide-info"} Both `createLogger` and `createRequestLogger` require a manual `log.emit()` call. The event won't be emitted until you call it. :: ### `useLogger` (Retrieving the Request Logger) When using a framework integration (Nuxt, Hono, Express, etc.), the middleware creates a wide event logger automatically on each request. `useLogger(event)` retrieves that logger from the request context: ```typescript [server/api/checkout.post.ts] import { useLogger } from 'evlog' export default defineEventHandler(async (event) => { const log = useLogger(event) log.set({ user: { id: 1, plan: 'pro' } }) log.set({ cart: { items: 3, total: 9999 } }) return { success: true } // auto-emitted on response end }) ``` ::callout{color="info" icon="i-lucide-info"} `useLogger` doesn't create a logger, it retrieves the one the framework middleware already attached to the event. The middleware handles creation and emission automatically. In Nuxt, `useLogger` is auto-imported. :: ## After emit: sealing and background work When the wide event is **emitted** (automatically at the end of the request, or when you call `log.emit()` yourself), that logger instance is **sealed**. Further `set`, `error`, `info`, and `warn` calls do **not** update the event that was already sent to your drains. They are ignored and evlog prints a **`[evlog]` warning** to the console with the keys that were dropped. This also applies when **head sampling** discards the event (`emit()` returned `null`): the logger is still sealed for that unit of work. This matters for **async work that outlives the handler** (fire-and-forget promises, `setTimeout`, tasks started but not awaited). On many runtimes, `AsyncLocalStorage` keeps returning the same request logger, so `useLogger()` still succeeds even though the HTTP response — and the wide event — are already finished. Without warnings, that looks like silent data loss. ### `log.fork(label, fn)` For intentional background work that should produce **its own** wide event, use **`log.fork(label, fn)`** when your integration provides it (Express, Fastify, NestJS, SvelteKit, React Router, Next.js `withEvlog`, Elysia). Inside `fn`, `useLogger()` resolves to a **child** logger. When `fn` completes (or throws), the child emits an event with: - **`operation`**: the `label` you passed - **`_parentRequestId`**: the parent request’s `requestId` (for correlation in queries and dashboards) The parent wide event may be emitted **before** the child event; they are two separate events ordered by time. **Not available yet:** Hono (no `useLogger` without `c.get('log')` + ALS) and Nitro/Nuxt `useLogger(event)` — use the post-emit warnings to catch mistakes; a different API may arrive later for event-scoped forks. For AI SDK streaming responses, supported framework integrations (Next.js, Nitro/Nuxt, SvelteKit, Hono, React Router, oRPC) defer wide-event emit until the response body finishes, so `createAILogger(log)` metadata lands on the same request event automatically. ```typescript [server/routes/checkout.post.ts] import { evlog, useLogger } from 'evlog/express' // Inside a route after evlog middleware: const log = req.log log.set({ order_dispatched: true }) log.fork?.('process_order', async () => { const child = useLogger() child.set({ inventory_checked: true }) }) ``` ## Anatomy of a Wide Event A well-designed wide event contains context from multiple layers. The examples below show what to add inside your handler or script. They assume `log` is already created via `createLogger`, `createRequestLogger`, or `useLogger`. ### Operation Context Basic information about the operation: ::code-group ```typescript [server/api/checkout.post.ts] import { useLogger } from 'evlog' const log = useLogger(event) log.set({ method: 'POST', path: '/api/checkout', requestId: 'abc-123-def', }) ``` ```typescript [scripts/sync-data.ts] import { createLogger } from 'evlog' const log = createLogger({ jobId: 'sync-001', queue: 'emails', source: 'postgres', }) ``` :: ::callout{color="info" icon="i-lucide-info"} In framework integrations, request context ( `method` , `path` , `requestId` ) is auto-populated by the middleware. You don't need to set these fields manually. :: ### User / Actor Context Who triggered the operation: ```typescript [server/api/checkout.post.ts] log.set({ userId: user.id, email: user.email, subscription: user.plan, accountAge: daysSince(user.createdAt), }) ``` ### Business Context Domain-specific data relevant to the operation: ```typescript [server/api/checkout.post.ts] log.set({ cart: { id: cart.id, items: cart.items.length, total: cart.total, currency: 'USD', }, shipping: { method: 'express', country: address.country, }, coupon: appliedCoupon?.code, }) ``` ### Outcome The result of the operation: ::code-group ```typescript [Success] log.set({ status: 200, duration: Date.now() - startTime, success: true, }) ``` ```typescript [Error] log.set({ status: 500, error: { message: err.message, code: err.code, type: err.constructor.name, }, }) ``` :: ## Best Practices ### Use Meaningful Keys ```typescript [server/api/orders.post.ts] // Avoid generic keys log.set({ data: { id: 123 } }) // Use specific, descriptive keys log.set({ order: { id: 123, status: 'pending' } }) ``` ### Group Related Data ```typescript [server/api/checkout.post.ts] // Flat structure is hard to read log.set({ userId: 1, userEmail: 'a@b.com', cartId: 2, cartTotal: 100, }) // Grouped structure is clearer log.set({ user: { id: 1, email: 'a@b.com' }, cart: { id: 2, total: 100 }, }) ``` ### Add Context Incrementally Call `log.set()` as you gather information: ::code-group ```typescript [server/api/checkout.post.ts] import { useLogger } 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 } }) const payment = await processPayment(cart) log.set({ payment: { method: payment.method, status: payment.status } }) return { success: true } }) ``` ```bash [Output] [INFO] POST /api/checkout (456ms) user: { id: 1, plan: 'pro' } cart: { items: 3, total: 9999 } payment: { method: 'card', status: 'success' } status: 200 ``` :: ### Handle Errors Gracefully When errors occur, the wide event still emits with error context: ::code-group ```typescript [server/api/checkout.post.ts] import { useLogger } from 'evlog' export default defineEventHandler(async (event) => { const log = useLogger(event) try { const result = await processPayment(cart) return result } catch (err) { log.set({ error: { message: err.message, code: err.code, type: err.constructor.name, }, }) throw err } }) ``` ```bash [Output] ERROR [checkout] POST /api/checkout 402 in 123ms ├─ error: Card declined │ at server/api/checkout.post.ts:42 │ ❯ 42 ┃ throw createError({ code: 'CARD_DECLINED', ... }) │ Why: Issuer declined the charge │ Fix: Ask the customer to use another card │ stack (3 frames hidden in node_modules) ├─ user: id=1 plan=pro └─ cart: items=3 total=9999 ``` :: ### Setting the Level Manually `log.error(err)` populates the `error` field with `{ name, message, stack }` and promotes the wide event to `level: 'error'`. When you want to control the `error` field yourself — typed error codes, no stack, or richer custom shapes — use `log.setLevel()` to promote the level without touching the context: ```typescript log.setLevel('error') log.set({ error: { code: 'PAYMENT_DECLINED', reason: 'insufficient_funds', }, }) ``` `setLevel()` accepts `'error' | 'warn' | 'info' | 'debug'` and wins over the level computed from `.error()` / `.warn()`. Combine it with `log.set()` to keep the wide event tidy while still routing through error-level sampling and drains. ## Output Formats evlog automatically switches between formats based on environment: pretty in development, JSON in production. This is the default behavior, no configuration needed. ::code-group ```bash [Development (Pretty)] [INFO] POST /api/checkout (234ms) user: { id: 1, plan: 'pro' } cart: { items: 3, total: 9999 } payment: { method: 'card', status: 'success' } ``` ```json [Production (JSON)] { "level": "info", "method": "POST", "path": "/api/checkout", "duration": 234, "user": { "id": 1, "plan": "pro" }, "cart": { "items": 3, "total": 9999 }, "payment": { "method": "card", "status": "success" } } ``` :: ## Next Steps - [Simple Logging](https://www.evlog.dev/learn/simple-logging) - Fire-and-forget logs when you don't need context accumulation - [Typed Fields](https://www.evlog.dev/learn/typed-fields) - Add compile-time type safety to your wide events - [Structured Errors](https://www.evlog.dev/learn/structured-errors) - Errors with actionable context - [Frameworks](https://www.evlog.dev/integrate/frameworks/overview) - Auto-managed request logging per framework # Structured Errors evlog provides a `createError()` function that creates errors with rich, actionable context. ::prompt --- actions: - copy - cursor - claude description: Use structured errors in my app icon: i-lucide-shield-alert --- Use structured errors with code / why / fix / link fields throughout my app. - Replace plain `throw new Error(...)` calls with createError({ code, message, status, why, fix, link }) - Use `code` as a stable, machine-readable identifier (e.g. `'PAYMENT_DECLINED'`, `'auth/invalid-token'`) so clients and dashboards can branch on it - Use `message` for what happened, `why` for the technical reason, `fix` for the actionable solution, and `link` for docs - Set the appropriate HTTP `status` for API routes (400 / 401 / 402 / 403 / 404 / 422 / 500) - For internal-only context, pass `internal: { ... }` (logged but never returned in HTTP responses) - On the client, use parseError(err) to extract { message, status, code, why, fix, link } from any thrown error - Branch on `parseError(err).code === 'PAYMENT_DECLINED'` rather than parsing user-facing messages - Render `why` and `fix` in toasts/UI so users get actionable feedback Docs: {rel=""nofollow""} :: ## Why Structured Errors? :structured-error-context Traditional errors are often unhelpful: ```typescript [server/api/checkout.post.ts] // Unhelpful error throw new Error('Payment failed') ``` This tells you *what* happened, but not *why* or *how to fix it*. Structured errors provide context: ::code-group ```typescript [server/api/checkout.post.ts] import { createError } from 'evlog' throw createError({ code: 'PAYMENT_DECLINED', message: 'Payment failed', status: 402, why: 'Card declined by issuer (insufficient funds)', fix: 'Try a different payment method or contact your bank', link: 'https://docs.example.com/payments/declined', }) ``` ```json [Response] { "statusCode": 402, "message": "Payment failed", "data": { "code": "PAYMENT_DECLINED", "why": "Card declined by issuer (insufficient funds)", "fix": "Try a different payment method or contact your bank", "link": "https://docs.example.com/payments/declined" } } ``` :: ## Error Fields | Field | Required | Description | | ---------- | -------- | ----------------------------------------------------------------------------------- | | `message` | Yes | What happened (shown to users) | | `code` | No | Stable machine-readable identifier for client branching (e.g. `'PAYMENT_DECLINED'`) | | `status` | No | HTTP status code (default: 500) | | `why` | No | Technical reason (for debugging) | | `fix` | No | Actionable solution | | `link` | No | Documentation URL | | `cause` | No | Original error (for error chaining) | | `internal` | No | Backend-only context (see below) | ## Backend-only context (`internal`) Use `internal` when you need extra fields for logs, drains, or support tools, but **must not** expose them in API responses or to `parseError()` on the client. ```typescript throw createError({ message: 'Payment could not be completed', status: 402, why: 'Your card was declined', fix: 'Try another payment method', internal: { correlationId: 'pay_8x2k', processorCode: 'insufficient_funds', rawIssuerResponse: '…', // never sent to the client }, }) ``` - **HTTP responses** (Nuxt/Nitro error handler, Next.js, SvelteKit, etc.) and **`toJSON()`** omit `internal`. - **`parseError()`** does not surface `internal` for UI; the thrown error may still carry it server-side on `raw` when debugging. - **Wide events**: when the framework records the error (e.g. `log.error(err)` or automatic capture on thrown `EvlogError`), the emitted payload includes `error.internal`. In debuggers, the payload may appear under a symbol key; in code, always use **`error.internal`**. ## Basic Usage ### Simple Error ::code-group ```typescript [server/api/users/[id\\].get.ts] import { createError } from 'evlog' throw createError({ message: 'User not found', status: 404, }) ``` ```json [Response] { "statusCode": 404, "message": "User not found" } ``` :: ### Error with Full Context ::code-group ```typescript [server/api/checkout.post.ts] import { createError } from 'evlog' throw createError({ code: 'PAYMENT_DECLINED', message: 'Payment failed', status: 402, why: 'Card declined by issuer', fix: 'Try a different payment method', link: 'https://docs.example.com/payments/declined', }) ``` ```json [Response] { "statusCode": 402, "message": "Payment failed", "data": { "code": "PAYMENT_DECLINED", "why": "Card declined by issuer", "fix": "Try a different payment method", "link": "https://docs.example.com/payments/declined" } } ``` :: ### Error Chaining Wrap underlying errors while preserving the original: ```typescript [server/api/checkout.post.ts] import { createError } from 'evlog' try { await stripe.charges.create(charge) } catch (err) { throw createError({ message: 'Payment processing failed', status: 500, why: 'Stripe API returned an error', cause: err, // Original error preserved }) } ``` ## Development terminal output In development with `pretty: true` (the default), evlog prints failed requests as a wide event in the terminal. The **`error` block comes first**, then request context (`user`, `cart`, …). Structured fields (`why`, `fix`, `link`) appear under the error message with a source location and optional code snippet. ::code-group ```typescript [server/api/checkout.post.ts] import { createError } from 'evlog' throw createError({ code: 'PAYMENT_DECLINED', message: 'Card declined', status: 402, why: 'Issuer declined the charge', fix: 'Ask the customer to use another card', link: 'https://docs.example.com/payments/declined', }) ``` ```bash [Terminal (pretty dev)] ERROR [checkout] POST /api/checkout 402 in 123ms ├─ error: Card declined │ at server/api/checkout.post.ts:42 │ ❯ 42 ┃ throw createError({ code: 'PAYMENT_DECLINED', ... }) │ Why: Issuer declined the charge │ Fix: Ask the customer to use another card │ More: https://docs.example.com/payments/declined │ stack (3 frames hidden in node_modules) ├─ user: id=1 plan=pro └─ cart: items=3 total=9999 ``` :: Colors and tree connectors render in the terminal; the example above omits ANSI for readability. ### Choosing evlog vs Nitro console output | Goal | Config | | --------------------------------------------------------------------------- | --------------------------------------------------- | | One clean signal — wide event only, no Nitro `[request error]` overlay | `dev: 'evlog'` (default in pretty dev) | | Wide event context + Nitro's native Youch stack (evlog prints Why/Fix only) | `dev: 'nitro'` | | Full evlog block **and** Nitro overlay (debug) | `dev: 'both'` | | No pretty tree (JSON logs) but still suppress Nitro overlay | `pretty: false`, `dev: { frameworkOverlay: false }` | Fine-grained control lives under `dev.prettyError` (`snippet`, `stackDepth`, `compact`, `detail: 'full' | 'guidance'`). See [Configuration](https://www.evlog.dev/reference/configuration) and [Nuxt integration](https://www.evlog.dev/integrate/frameworks/nuxt). ## Branching on `code` `code` is a stable, machine-readable identifier you control. Pair it with `parseError()` so the client can branch on logic without parsing user-facing messages or coupling to HTTP status codes. :structured-error-branching ```typescript [composables/useCheckout.ts] import { parseError } from 'evlog' try { await $fetch('/api/checkout', { method: 'POST', body: cart }) } catch (err) { const error = parseError(err) switch (error.code) { case 'PAYMENT_DECLINED': return showRetryWithDifferentCard() case 'CART_EXPIRED': return rebuildCart() default: return toast.add({ title: error.message, color: 'error' }) } } ``` `parseError()` also surfaces `code` from Node-style errors (e.g. `'ENOENT'`, `'ECONNRESET'`) and any `Error` instance with a string `.code` property, so existing system errors flow through the same branch. `code` is also copied onto wide events under `error.code`, so dashboards and drains can group, alert, and chart by code without parsing free-text messages. ## Frontend Error Handling Use `parseError()` to extract all fields from caught errors: ::code-group ```typescript [composables/useCheckout.ts] import { parseError } from 'evlog' try { await $fetch('/api/checkout', { method: 'POST', body: cart }) } catch (err) { const error = parseError(err) console.log(error.message) // "Payment failed" console.log(error.status) // 402 console.log(error.code) // "PAYMENT_DECLINED" console.log(error.why) // "Card declined" console.log(error.fix) // "Try another card" } ``` ```typescript [composables/useCheckout.ts (Nuxt UI)] import { parseError } from 'evlog' const toast = useToast() try { await $fetch('/api/checkout', { method: 'POST', body: cart }) } catch (err) { const error = parseError(err) toast.add({ title: error.message, description: error.why, color: 'error', actions: error.link ? [{ label: 'Learn more', onClick: () => window.open(error.link) }] : undefined, }) } ``` :: ### Error Display Component Create a reusable error display: ```vue [components/ErrorAlert.vue] ``` ## Best Practices ### Use Appropriate Status Codes ::code-group ```typescript [400 - Bad Request] // Client error - user can fix throw createError({ message: 'Invalid email format', status: 400, fix: 'Please enter a valid email address', }) ``` ```typescript [401 - Unauthorized] // Authentication required throw createError({ message: 'Please log in to continue', status: 401, fix: 'Sign in to your account', link: '/login', }) ``` ```typescript [404 - Not Found] // Resource not found throw createError({ message: 'Order not found', status: 404, }) ``` ```typescript [500 - Server Error] // Server error - not user's fault throw createError({ message: 'Something went wrong', status: 500, why: 'Database connection timeout', // No 'fix' - user can't fix server errors }) ``` :: ### Provide Actionable Fixes ::code-group ```typescript [Bad] // Unhelpful fix throw createError({ message: 'Upload failed', fix: 'Try again', }) ``` ```typescript [Good] // Actionable fix throw createError({ message: 'Upload failed', status: 413, why: 'File exceeds maximum size (10MB)', fix: 'Reduce the file size or compress the image before uploading', link: '/docs/upload-limits', }) ``` :: ## Error Catalogs For anything beyond a handful of one-off errors, group them in a typed **catalog**. evlog ships two primitives for this — `defineError` (single factory) and `defineErrorCatalog` (bundle prefixed). The wire `code` is auto-derived as `${prefix}.${KEY}` and the `EvlogError` instance is built with all defaults applied. ### `defineErrorCatalog` Define a bundle of errors that share a prefix. Convention: `UPPER_SNAKE_CASE` keys, `lower.dot.case` prefix. ::code-group ```typescript [errors/billing.ts] import { defineErrorCatalog } from 'evlog' export const billingErrors = defineErrorCatalog('billing', { CART_EMPTY: { status: 400, message: 'Cart is empty', }, PAYMENT_DECLINED: { status: 402, message: 'Card declined', why: 'Issuer declined the charge', fix: 'Try a different payment method', link: 'https://docs.example.com/errors/billing.payment_declined', }, INSUFFICIENT_FUNDS: { status: 402, message: ({ available, required }: { available: number, required: number }) => `Insufficient funds: $${available} available, $${required} required`, fix: 'Add funds and retry', }, }) ``` ```typescript [server/api/checkout.post.ts] import { billingErrors } from '~/errors/billing' export default defineEventHandler(async (event) => { const cart = await getCart(event) if (!cart.items.length) throw billingErrors.CART_EMPTY() try { await stripe.charge(cart.total) } catch (e) { if (e.code === 'card_declined') throw billingErrors.PAYMENT_DECLINED({ cause: e }) if (e.code === 'insufficient_funds') { throw billingErrors.INSUFFICIENT_FUNDS({ available: e.balance, required: cart.total, cause: e, }) } throw e } }) ``` :: Each entry becomes a typed factory. Catalog metadata is exposed on `_codes` and `_prefix` for introspection (non-enumerable so `Object.keys(billingErrors)` still returns just the entry names). ```typescript billingErrors.PAYMENT_DECLINED.code // 'billing.PAYMENT_DECLINED' billingErrors.PAYMENT_DECLINED.status // 402 billingErrors._codes // readonly [ // 'billing.CART_EMPTY', // 'billing.PAYMENT_DECLINED', // 'billing.INSUFFICIENT_FUNDS', // ] ``` ### Templated messages with typed params Set `message` to a function and the params become **required and typed** at the call site. ```typescript const InvoiceOverdue = defineError('billing.INVOICE_OVERDUE', { status: 402, message: ({ daysOverdue }: { daysOverdue: number }) => `Invoice overdue by ${daysOverdue} day(s)`, fix: 'Pay outstanding invoice to resume service', }) throw InvoiceOverdue({ daysOverdue: 7 }) // params required and type-checked ``` You can still override any field at the call site (`message`, `status`, `why`, `fix`, `link`, `internal`, `cause`). Catalog defaults for `internal` are shallow-merged with call-site values (call-site wins on conflict). ### `defineError` — standalone factories For one-off errors that don't fit a catalog (or for very large repos that prefer one file per error), use `defineError` directly. Same factory shape as a catalog entry, no prefix derivation. ```typescript // errors/FraudDetected.ts import { defineError } from 'evlog' export const FraudDetected = defineError('billing.FRAUD_DETECTED', { status: 403, message: 'Transaction flagged for review', why: 'ML fraud-score above threshold', fix: 'Contact support to verify your identity', }) throw FraudDetected() ``` ### Type-safe codes everywhere (opt-in) Augment the `RegisteredErrorCatalogs` interface to make every registered code surface as autocomplete on `createError({ code })`, `parseError(err).code`, and any other typed `code` field across the codebase. ::code-group ```typescript [errors/types.ts] import type { billingErrors } from './billing' import type { authErrors } from './auth' declare module 'evlog' { interface RegisteredErrorCatalogs { billing: typeof billingErrors auth: typeof authErrors } } ``` ```typescript [Anywhere in your codebase] // createError autocompletes registered codes (and still accepts ad-hoc strings) throw createError({ code: 'billing.PAYMENT_DECLINED', // ← autocomplete, TS error if typo message: 'Card declined', status: 402, }) // parseError().code is typed as the union of all registered codes const err = parseError(caught) if (err.code === 'billing.PAYMENT_DECLINED') retry() // ↑ autocomplete, refactor-safe ``` :: This is purely type-level — no runtime registration, no init step. Skip it entirely if you don't need it; the runtime API is identical either way. ::callout{color="neutral" icon="i-lucide-package"} **Packaging tip.** A catalog is regular TypeScript. Publish `@acme/errors-billing` exporting your `defineErrorCatalog(...)` plus the `declare module 'evlog'` augmentation in its `index.d.ts` , and the typing flows transitively to every consumer that depends on it. Each shared package owns its prefix, no conflicts possible. :: ::callout --- color: primary icon: i-lucide-arrow-right to: https://www.evlog.dev/learn/catalogs --- **Going further.** The dedicated [Catalogs page](https://www.evlog.dev/learn/catalogs) covers the scaling story (single file → folder → feature → npm package), the full npm packaging recipe, composition patterns, the type-augmentation deep dive, and common pitfalls. :: ::callout{color="neutral" icon="i-lucide-code"} See the [Next.js guide](https://www.evlog.dev/integrate/frameworks/nextjs) for a working implementation. :: ## Next Steps - [Wide Events](https://www.evlog.dev/learn/wide-events): Accumulate context and emit comprehensive events - [Adapters](https://www.evlog.dev/integrate/adapters/overview): Send errors and events to Axiom, Sentry, PostHog, and more - [Frameworks](https://www.evlog.dev/integrate/frameworks/overview): Auto-managed request logging per framework - [Quick Start](https://www.evlog.dev/start/quick-start): See all evlog APIs in action # Lifecycle evlog events follow a pipeline from creation to delivery. The pipeline differs slightly depending on which logging mode you use, but the core stages (emit, sample, enrich, drain) are shared. :lifecycle-flow ## Overview by Mode | Stage | `log` (simple) | `createLogger` / `createRequestLogger` | Framework middleware | | -------------- | ------------------ | ----------------------------------------------------- | ---------------------------------- | | **Create** | Implicit per call | `createLogger({...})` or `createRequestLogger({...})` | Auto on request start | | **Accumulate** | N/A (single call) | `log.set()` multiple times | `log.set()` via `useLogger(event)` | | **Emit** | Immediate | Manual `log.emit()` | Auto on response end | | **Sample** | Head sampling only | Head + tail sampling | Head + tail sampling | | **Enrich** | Via global drain | Via global drain | Via hooks or callbacks | | **Drain** | Via global drain | Via global drain | Via hooks or callbacks | After **`emit`** (including when sampling returns no output), the request logger is **sealed**: later `set` / `error` / `info` / `warn` calls are ignored with a console warning. For background work that needs its own event, use **`log.fork()`** where your integration supports it. See [Wide events — After emit](https://www.evlog.dev/learn/wide-events#after-emit-sealing-and-background-work). ## Request Logging — Step by Step For framework-managed request logging, every request walks the pipeline above. Each stage is detailed below. ### 1. Route Filtering When a request arrives, evlog checks whether the path matches the configured `include` / `exclude` patterns. If the route is excluded, no logger is created and the request proceeds without any logging overhead. By default, all routes are logged. Use `include` to restrict logging to specific patterns: ```typescript [nuxt.config.ts] export default defineNuxtConfig({ modules: ['evlog/nuxt'], evlog: { include: ['/api/**'], }, }) ``` ### 2. Logger Creation For matched routes, evlog creates a `RequestLogger` and attaches it to the request context. The logger is pre-populated with: | Field | Source | | ----------- | ----------------------------------------------- | | `method` | HTTP method (`GET`, `POST`, ...) | | `path` | Request path | | `requestId` | Auto-generated UUID (or `cf-ray` on Cloudflare) | | `startTime` | `Date.now()` for duration calculation | The logger is stored on the event context. `useLogger(event)` is a shortcut to retrieve it, it doesn't create a new logger. ### 3. Context Accumulation During the handler, you call `log.set()` to attach context. Each call deep-merges into the existing context, so you can call it as many times as needed: ```typescript [server/api/checkout.post.ts] import { useLogger } from 'evlog' 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 } }) ``` If an error is thrown, evlog's `error` hook captures it automatically and records it on the logger with the status code. ### 4. Request End When the response is sent (or an error is thrown), evlog computes: - **Status code** from the response (or from the error's `status` / `statusCode`) - **Duration** from `Date.now() - startTime` - **Level** - `error` if an error was recorded, `warn` if status >= 400, otherwise `info` If an error triggered the emit, the request is marked as already emitted to prevent double-emission in the response hook. ### 5. Tail Sampling (`evlog:emit:keep`) Before the event is sampled, evlog evaluates **tail sampling** rules. These run *after* the request completes, so they can inspect the outcome: ```typescript [nuxt.config.ts] evlog: { sampling: { keep: [ { duration: 1000 }, // slow requests { status: 400 }, // client/server errors { path: '/api/critical/**' }, // critical paths ], }, } ``` The `evlog:emit:keep` hook also fires, letting you force-keep based on custom business logic: ```typescript [server/plugins/evlog-custom.ts] export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:emit:keep', (ctx) => { if (ctx.context.user?.premium) { ctx.shouldKeep = true } }) }) ``` If any rule or hook sets `shouldKeep = true`, the event **bypasses head sampling entirely**. ### 6. Head Sampling If the event wasn't force-kept by tail sampling, head sampling applies. This is a random coin flip per log level. By default, all levels are kept at 100% (no sampling). Configure `sampling.rates` to reduce volume in production: ```typescript [nuxt.config.ts] evlog: { sampling: { rates: { info: 10, warn: 50, debug: 0 }, }, } ``` - `info: 10` - keep 10% of info-level events - `warn: 50` - keep 50% of warnings - `error` defaults to **100%** (never sampled out, even if you set a rate) If the event is sampled out, processing stops entirely: no console output, no enrichment, no drain. ### 7. Emit The `WideEvent` object is built from the accumulated context: ```json [WideEvent] { "timestamp": "2026-01-15T10:30:00.000Z", "level": "info", "service": "my-app", "method": "POST", "path": "/api/checkout", "requestId": "abc-123", "duration": 234, "status": 200, "user": { "id": 1, "plan": "pro" }, "cart": { "items": 3, "total": 9999 } } ``` The event is printed to the console, pretty-formatted in development and as JSON in production. This is the default behavior, no configuration needed. ### 8. Enrich (`evlog:enrich`) After emission, enrichers add derived context to the event. Built-in enrichers extract data from request headers: | Enricher | Adds | Source | | ------------- | -------------------------------------- | ------------------------------------- | | User Agent | `userAgent` (browser, OS, device) | `User-Agent` header | | Geo | `geo` (country, region, city) | Platform headers (Vercel, Cloudflare) | | Request Size | `requestSize` (request/response bytes) | `Content-Length` headers | | Trace Context | `traceContext` (traceId, spanId) | `traceparent` header | ```typescript [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) }) }) ``` Enrichers receive the full `EnrichContext` with the mutable event, request metadata, safe headers, and response info. ### 9. Drain (`evlog:drain`) The final step sends the enriched event to your observability platform. The `evlog:drain` hook receives a `DrainContext` with the complete event: ```typescript [server/plugins/evlog-drain.ts] import { createAxiomDrain } from 'evlog/axiom' export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createAxiomDrain()) }) ``` On platforms with `waitUntil` (Cloudflare Workers, Vercel Edge), the drain runs after the response is sent to avoid adding latency. On traditional servers, the drain is awaited to prevent losing events in serverless cold shutdowns. ## Hook Execution Order | Order | Hook | When | Purpose | | ----- | ----------------- | ----------------------------------- | ---------------------------------- | | 1 | `evlog:emit:keep` | After request ends, before sampling | Force-keep events based on outcome | | 2 | `evlog:enrich` | After emit, before drain | Add derived context to the event | | 3 | `evlog:drain` | After enrichment | Send event to external services | ## Error vs Success Path Both paths converge at the same emit/enrich/drain pipeline. The only difference is *when* the emit is triggered: | | Success | Error | | --------------------- | ----------------------------------- | ----------------------------------------------- | | **Trigger** | `afterResponse` / `response` hook | `error` hook | | **Level** | `info` (or `warn` if status >= 400) | `error` | | **Status** | From response | From error's `status` field (default 500) | | **Error context** | None | `error` field with message, stack, `why`, `fix` | | **Double-emit guard** | Checks `_evlogEmitted` flag | Sets `_evlogEmitted = true` | ## Simple Logging Pipeline When using the `log` singleton, the pipeline is shorter: 1. **Call**: `log.info({ action: 'deploy' })` or `log.info('tag', 'message')` 2. **Emit**: The event is built and printed immediately 3. **Drain**: If a global `drain` was configured via `initLogger()`, the event is sent to external services Tagged logs (`log.info('tag', 'message')`) are console-only in pretty mode. Object-form logs (`log.info({ ... })`) always flow through the drain pipeline. ## Standalone Wide Event Pipeline When using `createLogger()` outside a framework: 1. **Create**: `createLogger({ jobId: 'sync-001' })` 2. **Accumulate**: `log.set()`, `log.info()`, `log.warn()`, `log.error()` over the operation 3. **Emit**: Manual `log.emit()` call 4. **Sample**: Head sampling applies based on computed level. Tail sampling via `initLogger({ sampling: { keep: [...] } })` 5. **Drain**: If a global `drain` was configured, the event is sent ```typescript [scripts/migrate.ts] import { initLogger, createLogger } from 'evlog' import { createAxiomDrain } from 'evlog/axiom' initLogger({ env: { service: 'worker' }, drain: createAxiomDrain(), sampling: { rates: { info: 10 } }, }) const log = createLogger({ task: 'migrate' }) log.set({ records: 500, status: 'complete' }) log.emit() ``` ## Next Steps - [Wide Events](https://www.evlog.dev/learn/wide-events) - Design effective wide events - [Sampling](https://www.evlog.dev/learn/sampling) - Configure head and tail sampling - [Adapters](https://www.evlog.dev/integrate/adapters/overview) - Send events to external platforms - [Enrichers](https://www.evlog.dev/use-cases/enrichers) - Add derived context automatically # Sampling At scale, logging everything gets expensive fast. Sampling lets you keep costs under control without losing visibility into what matters. evlog uses a two-tier approach: head sampling drops noise upfront, tail sampling rescues critical events after the fact. ::prompt --- actions: - copy - cursor - claude description: Enable head and tail sampling icon: i-lucide-filter --- Enable head and tail sampling in my evlog production config. - Identify my framework and locate the evlog config (nuxt.config.ts, lib/evlog.ts, initLogger, etc.) - Configure sampling.rates per level: { info: 10, warn: 50, debug: 0, error: 100 } as a starting point - Add sampling.keep rules to force-keep critical events: [{ status: 400 }, { duration: 1000 }, { path: '/api/critical/\*\*' }] - For business-specific keep logic (e.g. premium users), add a custom keep callback or evlog\:emit\:keep hook - Wrap sampling in a $production override so dev keeps full logging - Confirm errors are always kept by default unless I explicitly set error: 0 Docs: {rel=""nofollow""} Best practices: {rel=""nofollow""} :: ## Head Sampling Head sampling randomly keeps a percentage of logs per level. It runs **before** the request completes, acting as a coin flip at emission time. :head-sampling-plinko ::code-group ```typescript [nuxt.config.ts] export default defineNuxtConfig({ modules: ['evlog/nuxt'], evlog: { sampling: { rates: { info: 10, // Keep 10% of info logs warn: 50, // Keep 50% of warnings debug: 0, // Drop all debug logs error: 100, // Always keep errors (default) }, }, }, }) ``` ```typescript [lib/evlog.ts (Next.js)] import { createEvlog } from 'evlog/next' export const { withEvlog, useLogger } = createEvlog({ service: 'my-app', sampling: { rates: { info: 10, warn: 50, debug: 0, error: 100, }, }, }) ``` ```typescript [index.ts (Hono / Express / Fastify)] import { initLogger } from 'evlog' initLogger({ env: { service: 'my-app' }, sampling: { rates: { info: 10, warn: 50, debug: 0, error: 100, }, }, }) ``` :: Each level is a percentage from 0 to 100. Levels you don't configure default to 100% (keep everything). Error defaults to 100% even when other levels are configured, so you have to explicitly set `error: 0` to drop errors. ::callout{color="info" icon="i-lucide-info"} Head sampling is random. A `10%` rate means roughly 1 in 10 info logs are kept, not exactly 1 in 10. :: ## Tail Sampling Head sampling is blind: it doesn't know if a request was slow, failed, or hit a critical path. Tail sampling fixes this by evaluating **after** the request completes and force-keeping logs that match specific conditions. ```typescript [nuxt.config.ts] // Sampling config, works the same across all frameworks evlog: { sampling: { rates: { info: 10 }, keep: [ { status: 400 }, // HTTP status >= 400 { duration: 1000 }, // Request took >= 1s { path: '/api/payments/**' }, // Critical path (glob) ], }, } ``` Conditions use **>=** comparison for `status` and `duration`, and glob matching for `path`. If **any** condition matches, the log is kept regardless of head sampling (OR logic). ### Available Conditions | Condition | Type | Description | | ---------- | -------- | ---------------------------------------------------------------------- | | `status` | `number` | Keep if HTTP status >= value (e.g., `400` catches all 4xx and 5xx) | | `duration` | `number` | Keep if request duration >= value in milliseconds | | `path` | `string` | Keep if request path matches glob pattern (e.g., `'/api/critical/**'`) | ## How They Work Together The two tiers complement each other: 1. **Request completes** - evlog knows the status, duration, and path 2. **Tail sampling evaluates** - if any `keep` condition matches, the log is force-kept 3. **Head sampling applies** - only if tail sampling didn't force-keep, the random percentage check runs 4. **Log emits or drops** - kept logs go through enrichment and draining as normal This means a request to `/api/payments/charge` that returns a 500 in 2 seconds will always be logged, even if `info` is set to 1%. The tail conditions rescue it. :tail-sample-decision ::code-group ```typescript [Configuration] sampling: { rates: { info: 10 }, keep: [ { status: 400 }, { duration: 1000 }, ], } ``` ```bash [What gets logged] POST /api/users 200 45ms → 10% chance (head sampling) POST /api/users 500 45ms → always kept (status >= 400) GET /api/products 200 2300ms → always kept (duration >= 1000) POST /api/checkout 200 120ms → 10% chance (head sampling) ``` :: ## Custom Tail Sampling For conditions beyond status, duration, and path, use the `evlog:emit:keep` hook in Nuxt/Nitro or the `keep` callback in other frameworks. ::code-group ```typescript [server/plugins/sampling.ts (Nuxt)] export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:emit:keep', (ctx) => { if (ctx.context.user?.plan === 'enterprise') { ctx.shouldKeep = true } }) }) ``` ```typescript [lib/evlog.ts (Next.js)] import { createEvlog } from 'evlog/next' export const { withEvlog, useLogger } = createEvlog({ service: 'my-app', sampling: { rates: { info: 10 }, keep: [{ status: 400 }], }, keep(ctx) { if (ctx.context.user?.plan === 'enterprise') { ctx.shouldKeep = true } }, }) ``` ```typescript [index.ts (Hono)] import { evlog } from 'evlog/hono' app.use(evlog({ keep(ctx) { if (ctx.context.user?.plan === 'enterprise') { ctx.shouldKeep = true } }, })) ``` :: The `ctx` object contains: | Field | Type | Description | | ------------ | ------------------------- | ------------------------------ | | `status` | `number | undefined` | HTTP response status | | `duration` | `number | undefined` | Request duration in ms | | `path` | `string | undefined` | Request path | | `method` | `string | undefined` | HTTP method | | `context` | `Record` | All fields set via `log.set()` | | `shouldKeep` | `boolean` | Set to `true` to force-keep | ## Production Example A typical production configuration that balances cost and visibility: ::code-group ```typescript [nuxt.config.ts] export default defineNuxtConfig({ modules: ['evlog/nuxt'], evlog: { env: { service: 'my-app' }, }, $production: { evlog: { sampling: { rates: { info: 10, warn: 50, debug: 0, error: 100, }, keep: [ { status: 400 }, { duration: 1000 }, { path: '/api/payments/**' }, { path: '/api/auth/**' }, ], }, }, }, }) ``` ```typescript [lib/evlog.ts (Next.js)] import { createEvlog } from 'evlog/next' export const { withEvlog, useLogger } = createEvlog({ service: 'my-app', sampling: { rates: { info: 10, warn: 50, debug: 0, error: 100, }, keep: [ { status: 400 }, { duration: 1000 }, { path: '/api/payments/**' }, { path: '/api/auth/**' }, ], }, }) ``` ```typescript [index.ts (Hono / Express / Fastify)] import { initLogger } from 'evlog' initLogger({ env: { service: 'my-app' }, sampling: { rates: { info: 10, warn: 50, debug: 0, error: 100, }, keep: [ { status: 400 }, { duration: 1000 }, { path: '/api/payments/**' }, { path: '/api/auth/**' }, ], }, }) ``` :: ::callout{color="warning" icon="i-lucide-lightbulb"} In Nuxt, use the `$production` override to keep full logging in development while sampling in production. In other frameworks, use your own environment check or config system. :: ## Next Steps - [Best Practices](https://www.evlog.dev/reference/best-practices) - Security and production checklist - [Wide Events](https://www.evlog.dev/learn/wide-events) - Design effective wide events # Auto-Redaction Wide events capture comprehensive context, which makes it easy to accidentally log sensitive data. Auto-redaction scrubs PII from events **before** console output and **before** any drain sees the data. **Redaction is enabled by default in production** (`NODE_ENV === 'production'`). In development, it is off so you see full values for debugging. No configuration needed — just deploy. ## Opting Out If you need to disable redaction in production: ::code-group ```typescript [nuxt.config.ts] export default defineNuxtConfig({ modules: ['evlog/nuxt'], evlog: { redact: false, }, }) ``` ```typescript [lib/evlog.ts (Next.js)] import { createEvlog } from 'evlog/next' export const { withEvlog, useLogger } = createEvlog({ service: 'my-app', redact: false, }) ``` ```typescript [index.ts (Hono / Express / Fastify)] import { initLogger } from 'evlog' initLogger({ env: { service: 'my-app' }, redact: false, }) ``` :: You can also enable redaction explicitly in development with `redact: true`. :redaction-stream ## Smart Masking Built-in patterns use **partial masking** instead of flat `[REDACTED]` — preserving enough context for debugging while protecting the actual data. | 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` | `eyJhbGciOiJIUzI1NiIs...` | `eyJ***.***` | | `bearer` | `Bearer sk_live_abc123...` | `Bearer ***` | | `iban` | `FR76 3000 6000 0112 ...189` | `FR76****189` | ::callout{color="info" icon="i-lucide-info"} `127.0.0.1` and `0.0.0.0` are excluded from IPv4 masking since they are not real client addresses. :: ## Configuration ### Path Patterns Use a single `paths` array with dot-notation and globs. A bare segment like `password` is shorthand for `**.password` — it redacts that key at **any nesting depth**: ```typescript evlog: { redact: { paths: [ 'password', // same as '**.password' '*_token', // key-name glob at any depth 'headers.x-forwarded-for', // exact path 'user.*', // everything directly under user ], } } ``` | Pattern | Matches | | --------------------------- | ---------------------------------------------- | | `user.email` | Exact path only | | `password` or `**.password` | `password` key at any depth | | `*_token` | Key names like `access_token`, `refresh_token` | | `user.*` | `user.email`, `user.password`, etc. | | `audit.changes.*.password` | Mixed exact + wildcard segments | Path redaction replaces the **entire value** (including nested objects) with `replacement`. Use `patterns` when you need regex on **string values** inside fields. This matches `auditDiff({ redactPaths: ['password'] })` — same glob syntax, applied globally at emit time. ### Selective Built-ins Pick only the patterns you need: ```typescript evlog: { redact: { builtins: ['email', 'creditCard'], } } ``` ### Custom Patterns Add your own regex patterns. These use the flat `replacement` string, not smart masking: ```typescript evlog: { redact: { patterns: [/SECRET_\w+/g, /sk_live_\w+/g], replacement: '***', } } ``` ### Computed Replacements When the replacement has to be **derived** from the value it replaces, pass a function instead of a string. It runs at the same point as the rest of redaction — before the console write, before any drain. The common case is keeping requests correlatable without exposing the credential that identifies them: ```typescript initLogger({ redact: { patterns: [/\/public\/claim\/([A-Za-z0-9._-]{12,})/g], replacement: (_match, ctx) => `/public/claim/[tok:${fingerprint(ctx.groups[0])}]`, }, }) // /public/claim/eyJhbGciOi... → /public/claim/[tok:9f3a1c] ``` The function receives the matched value and a context object: | Field | Type | Description | | -------- | ---------- | ------------------------------------------------------------------------ | | `path` | `string` | Dot-notation path from the event root (`user.email`, `items.0.token`) | | `key` | `string` | Leaf key of the field (`email`) | | `groups` | `string[]` | Capture groups of the matching `patterns` entry. Only set for `patterns` | For `paths`, the matched value is the **whole field value** — any type, since path redaction replaces entire subtrees. For `patterns`, it is the matched substring. If the function throws or returns a non-string, redaction falls back to `[REDACTED]` and logs the failure. A broken policy degrades to over-redaction, never to leaking the value it was meant to scrub. ### Conditional Policies Some policies cannot be expressed as a list of paths — redact a field only for certain tenants, only when a sibling field has a given value, or keep an allowlist rather than a denylist. Use `transform`: ```typescript initLogger({ redact: { transform: (event) => { if (event.tenant === 'regulated') delete event.query }, }, }) ``` `transform` runs **before** `paths`, `builtins`, and `patterns`, so it sees raw values and the declarative rules still apply to whatever it leaves behind — a hook that misses a field is not your last line of defence. Mutate the event in place; it is already a private clone, so the object you logged is never touched. It must be synchronous, since it runs on the emit path before the console write. Errors are caught and reported like drain failures: the declarative stages still run and the event is still logged. ::callout{color="warning" icon="i-lucide-triangle-alert"} Function-valued `replacement` and `transform` cannot be declared in `nuxt.config.ts` or a Nitro module's options — that config is serialized to JSON at build time, which drops functions. Declare them at runtime with `initLogger()` from a server plugin, or with `createEvlog()` . The modules emit a build-time warning if you do it anyway. :: ### Disable Built-ins If you only want custom redaction: ```typescript evlog: { redact: { builtins: false, paths: ['user.ssn'], patterns: [/INTERNAL_\w+/g], } } ``` ## Configuration Reference | Option | Type | Default | Description | | ------------- | ----------------------------------- | -------------------- | ----------------------------------------------------------------------------------------------------------------------------- | | `redact` | `boolean | RedactConfig` | `true` in production | Enabled by default in production. `false` to disable. Object for fine-grained control | | `paths` | `string[]` | `undefined` | Dot-notation paths with globs (`password`, `**.password`, `*_token`, `user.*`) | | `patterns` | `RegExp[]` | `undefined` | Custom regex on string values. Uses flat `replacement` string | | `builtins` | `false | string[]` | All enabled | `false` disables built-ins. Array selects specific ones | | `replacement` | `string | (matched, ctx) => string` | `'[REDACTED]'` | Replacement for paths and custom patterns. Built-ins use smart masking instead. A function computes it from the matched value | | `transform` | `(event) => void` | `undefined` | Escape hatch for policies that are conditional, tenant-scoped, or allowlist-shaped. Runs before the declarative stages | Available built-in names: `creditCard`, `email`, `ipv4`, `phone`, `jwt`, `bearer`, `iban`. ## How It Works Redaction runs inside the emit pipeline, after the wide event is fully built but before any output: 1. **Transform** — your `transform` hook, if any, sees the raw event first 2. **Path redaction** — exact paths and globs replaced with `[REDACTED]` 3. **Smart masking** — built-in patterns scan all string values recursively with partial masking 4. **Pattern redaction** — custom regex patterns scan all string values with flat replacement 5. **Console output** — masked event printed to stdout 6. **Drain** — masked event sent to external services Redaction is the only stage that runs before the console write. `enrich` and drains run after it, so they cannot scrub what has already reached stdout — anything that needs to happen before output belongs in `transform` or a function-valued `replacement`. ::callout{color="info" icon="i-lucide-zap"} Redaction runs **after** the HTTP response is sent, so it adds zero latency to your API responses. :: ## Production Example Redaction is already on by default in production. Combine with sampling for a typical setup: ::code-group ```typescript [nuxt.config.ts] export default defineNuxtConfig({ modules: ['evlog/nuxt'], evlog: { env: { service: 'my-app' }, }, $production: { evlog: { sampling: { rates: { info: 10, debug: 0 }, keep: [{ status: 400 }, { duration: 1000 }], }, }, }, }) ``` ```typescript [lib/evlog.ts (Next.js)] import { createEvlog } from 'evlog/next' export const { withEvlog, useLogger } = createEvlog({ service: 'my-app', sampling: { rates: { info: 10, debug: 0 }, keep: [{ status: 400 }, { duration: 1000 }], }, }) ``` ```typescript [index.ts (Hono / Express / Fastify)] import { initLogger } from 'evlog' initLogger({ env: { service: 'my-app' }, sampling: { rates: { info: 10, debug: 0 }, keep: [{ status: 400 }, { duration: 1000 }], }, }) ``` :: ## Before / After Without redaction, sensitive data lands in your logs and drains: ```json { "user": { "email": "alice@example.com", "ip": "192.168.1.42" }, "payment": { "card": "4111111111111111" }, "auth": "Bearer sk_live_abc123def456" } ``` With `redact: true`: ```json { "user": { "email": "a***@***.com", "ip": "***.***.***.42" }, "payment": { "card": "****1111" }, "auth": "Bearer ***" } ``` Same debugging context, no PII in your Axiom/Datadog/Sentry. ## Next Steps - [Best Practices](https://www.evlog.dev/reference/best-practices) - Security guidelines and production checklist - [Sampling](https://www.evlog.dev/learn/sampling) - Control log volume in production - [Configuration](https://www.evlog.dev/reference/configuration) - Full configuration reference # Typed Fields By default, `useLogger` accepts any fields, which is great for getting started. But as your codebase grows, inconsistencies creep in: one route logs `user`, another logs `account`, a third logs `userId`. Typed fields solve this with opt-in compile-time safety. :typed-fields-intellisense ## Basic Usage Define an interface for your fields and pass it as a generic to `useLogger`: ```typescript [server/api/checkout.post.ts] import { useLogger } from 'evlog' interface CheckoutFields { user: { id: string; plan: string } cart: { items: number; total: number } action: string } export default defineEventHandler(async (event) => { const log = useLogger(event) log.set({ user: { id: '123', plan: 'pro' } }) // OK log.set({ cart: { items: 3, total: 9999 } }) // OK log.set({ action: 'checkout' }) // OK log.set({ account: '...' }) // TS error log.set({ usr: { id: '123' } }) // TS error return { success: true } }) ``` TypeScript catches typos and unknown fields at compile time, before they reach production. ## Internal Fields evlog sets some fields internally (`status`, `service`). These are always accepted regardless of your type, through the `InternalFields` type: ```typescript [server/api/checkout.post.ts] log.set({ status: 200 }) // OK - internal field log.set({ service: 'api' }) // OK - internal field ``` You don't need to include `status` or `service` in your interface. ## Untyped Usage Without a generic, `useLogger` accepts any fields as usual: ```typescript [server/api/example.ts] const log = useLogger(event) log.set({ anything: true, nested: { deep: 'value' } }) // OK ``` Typed fields are fully opt-in. ## Nuxt Auto-Import ::callout{color="warning" icon="i-lucide-triangle-alert"} When using typed fields with `useLogger` , you **must** use an explicit import. The Nuxt auto-import does not support excess property checking for generics due to a TypeScript limitation. :: ```typescript [server/api/checkout.post.ts] // Works - explicit import preserves type checking import { useLogger } from 'evlog' const log = useLogger(event) log.set({ typo: 'oops' }) // TS error // Does NOT work - auto-import loses excess property checking const log = useLogger(event) log.set({ typo: 'oops' }) // No error (silently accepted) ``` The auto-import works perfectly for untyped usage. Only add the explicit import when you need typed fields. ## Outside Nuxt The same generic works with `createRequestLogger` and `createWorkersLogger`: ::code-group ```typescript [Standalone] import { createRequestLogger } from 'evlog' interface MyFields { action: string userId: string } const log = createRequestLogger({ method: 'POST', path: '/checkout', }) log.set({ action: 'checkout', userId: '123' }) // OK log.set({ unknown: true }) // TS error ``` ```typescript [Cloudflare Workers] import { createWorkersLogger } from 'evlog/workers' interface MyFields { action: string } const log = createWorkersLogger(request) log.set({ action: 'process' }) // OK ``` :: ## Design Tips ### One Interface Per Domain Define field interfaces per domain area, not per route: ```typescript [server/types/log-fields.ts] export interface AuthFields { user: { id: string; email: string; role: string } action: string mfaUsed: boolean } export interface PaymentFields { user: { id: string; plan: string } order: { id: string; total: number; currency: string } payment: { method: string; last4: string } } ``` ```typescript [server/api/auth/login.post.ts] import { useLogger } from 'evlog' import type { AuthFields } from '~/server/types/log-fields' export default defineEventHandler(async (event) => { const log = useLogger(event) // ... }) ``` ### Keep Interfaces Focused Include only the fields your routes actually set. The interface doesn't need to mirror your entire data model: ```typescript [server/types/evlog.ts] // Too broad - most routes won't set all these interface EverythingFields { user: FullUserProfile order: CompleteOrder payment: PaymentDetails shipping: ShippingInfo } // Focused - only what this route sets interface CheckoutFields { user: { id: string; plan: string } cart: { items: number; total: number } } ``` ## Next Steps - [Wide Events](https://www.evlog.dev/learn/wide-events): Design effective wide events with context layering - [Best Practices](https://www.evlog.dev/reference/best-practices): Security guidelines for preventing sensitive data leakage - [Configuration](https://www.evlog.dev/reference/configuration): All `initLogger` and middleware options # Catalogs The catalog primitives (`defineError`, `defineErrorCatalog`, `defineAuditAction`, `defineAuditCatalog`) are the same regardless of project size. What changes is how you organise them. This page is the deep-dive: conventions, scaling recipes from one file to a published npm package, composition patterns, and the opt-in type augmentation. ::prompt --- actions: - copy - cursor - claude description: Set up typed error and audit catalogs in my app icon: i-lucide-book-open --- Group errors and audit actions in typed catalogs to eliminate magic strings, get autocomplete on `code` everywhere, and ship them as npm packages in a monorepo. - Use `defineErrorCatalog(prefix, map)` for error bundles, `defineAuditCatalog(prefix, map)` for audit bundles - Use `defineError(code, options)` and `defineAuditAction(action, opts?)` for one-off factories that don't fit a catalog - Convention: UPPER\_SNAKE\_CASE keys, lower.dot.case prefix, wire format is `${prefix}.${KEY}` (e.g. `billing.PAYMENT_DECLINED`, `billing.INVOICE_REFUND`) - One catalog = one bounded context = one prefix = one file (e.g. `errors/billing.ts`, `audit/billing.ts`) - Throw with `billingErrors.PAYMENT_DECLINED({ cause, internal })`, audit with `log.audit(billingAudit.INVOICE_REFUND({ actor, target }))` - Use templated messages (`message: ({ id }) => \`User ${id} not found\`\`) when params are dynamic and required - Catalog defaults for `internal` are shallow-merged with call-site values (call-site wins) - Add the opt-in `declare module 'evlog' { interface RegisteredErrorCatalogs { billing: typeof billingErrors } }` augmentation to surface autocomplete on `createError({ code })`, `parseError(err).code`, and `throwError(code)` everywhere - Scale by sharding: single file → folder per domain → sub-prefixes (`billing.payment`) → one npm package per bounded context (each owns its prefix, no conflicts possible) - Each shared package ships its own `declare module 'evlog'` block in `src/index.ts` so the type augmentation propagates to consumers via the published `.d.ts` - Compare on `factory.code` in tests instead of string literals so renames are TS errors, not silent breaks: `expect(err.code).toBe(billingErrors.PAYMENT_DECLINED.code)` - Never override `code` at the call site (the catalog defines the code identity); never put `declare module` blocks in test files (they leak into the main type-checker) Docs: {rel=""nofollow""} :: ::tip If you haven't yet, start with [Structured Errors → Error Catalogs](https://www.evlog.dev/learn/structured-errors#error-catalogs) and [Audit → defineAuditCatalog](https://www.evlog.dev/use-cases/audit/recording#defineauditcatalog) for the basics. This page assumes you've used the primitives at least once. :: ## Conventions A single set of conventions covers both error and audit catalogs. | | Convention | Example | | ----------------- | -------------------------------------------------------------- | -------------------------------------------------- | | **Catalog key** | `UPPER_SNAKE_CASE` (enum-style, scales to hundreds of entries) | `PAYMENT_DECLINED`, `INVOICE_REFUND` | | **Prefix** | `lower.dot.case`, can be hierarchical | `'billing'`, `'billing.payment'`, `'auth.session'` | | **Wire format** | `${prefix}.${KEY}` (preserved casing) | `billing.PAYMENT_DECLINED`, `auth.INVALID_TOKEN` | | **One catalog =** | One bounded context, one prefix, one file | `errors/billing.ts`, `audit/billing.ts` | The wire format ends up in HTTP responses, wide events, drains, and dashboards. Stick to it across services so a `code` from one service is recognisable in another. ## Scaling story The same primitives cover four scales without API change. ### 1 file — small repo One `errors.ts`, one `audit.ts`. Done. ::code-group ```typescript [src/errors.ts] import { defineErrorCatalog } from 'evlog' export const errors = defineErrorCatalog('app', { USER_NOT_FOUND: { status: 404, message: 'User not found' }, FORBIDDEN: { status: 403, message: 'Forbidden' }, VALIDATION_FAILED: { status: 400, message: ({ field }: { field: string }) => `Invalid ${field}`, }, }) ``` ```typescript [src/audit.ts] import { defineAuditCatalog } from 'evlog' export const audit = defineAuditCatalog('app', { USER_LOGIN: { target: 'user', severity: 'medium' }, USER_DELETE: { target: 'user', severity: 'high', requiresChanges: true, requiresReason: true }, }) ``` :: ### 1 folder, 1 file per domain — medium repo Group by bounded context. One file per domain in `src/errors/` and `src/audit/`. An `index.ts` re-exports for ergonomic imports and centralises the type augmentation. ```text src/ ├── errors/ │ ├── billing.ts → billingErrors (prefix: 'billing') │ ├── auth.ts → authErrors (prefix: 'auth') │ ├── user.ts → userErrors (prefix: 'user') │ └── index.ts → re-export + declare module ├── audit/ │ ├── billing.ts → billingAudit │ ├── auth.ts → authAudit │ └── index.ts ``` ```typescript [src/errors/index.ts] import type { authErrors } from './auth' import type { billingErrors } from './billing' import type { userErrors } from './user' export { authErrors } from './auth' export { billingErrors } from './billing' export { userErrors } from './user' declare module 'evlog' { interface RegisteredErrorCatalogs { auth: typeof authErrors billing: typeof billingErrors user: typeof userErrors } } ``` The augmentation is purely type-level: there is no `init` step, no runtime registration. Importing `~/errors` once anywhere in your app is enough for TypeScript to pick up the merged type. ### Sub-prefixes — very large repo Hierarchical prefixes (`billing.payment`, `billing.subscription`, `auth.session`) keep keys short while preserving namespace clarity. One catalog per sub-domain. ```text src/features/ ├── billing/ │ └── errors/ │ ├── payment.ts → billingPaymentErrors (prefix: 'billing.payment') │ ├── subscription.ts → billingSubscriptionErrors │ └── invoice.ts → billingInvoiceErrors ├── auth/ │ └── errors/ │ ├── session.ts → authSessionErrors (prefix: 'auth.session') │ ├── oauth.ts → authOAuthErrors │ └── mfa.ts → authMfaErrors ``` ```typescript [src/features/billing/errors/payment.ts] import { defineErrorCatalog } from 'evlog' export const billingPaymentErrors = defineErrorCatalog('billing.payment', { DECLINED: { status: 402, message: 'Card declined' }, INSUFFICIENT_FUNDS: { status: 402, message: 'Insufficient funds' }, EXPIRED_CARD: { status: 402, message: 'Card expired' }, CVV_MISMATCH: { status: 402, message: 'CVV mismatch' }, }) ``` Wire codes become `billing.payment.DECLINED`, `billing.payment.INSUFFICIENT_FUNDS`, etc. The convention scales to hundreds of entries without collisions. ### npm packages — monorepo In a monorepo, each bounded context can ship as its own npm package. Type augmentation propagates through the published `.d.ts`, so consumers get autocomplete just by `pnpm add @acme/errors-billing`. ```text acme-monorepo/ ├── packages/ │ ├── errors-billing/ → @acme/errors-billing │ │ └── src/index.ts │ ├── errors-auth/ → @acme/errors-auth │ │ └── src/index.ts │ └── audit-billing/ → @acme/audit-billing │ └── src/index.ts └── apps/ ├── api/ → imports + re-exports the catalogs └── worker/ ``` ## Publishing a catalog as an npm package A catalog is just regular TypeScript that depends on `evlog` as a peer dep. Here is the minimal recipe. ### `package.json` ```json [packages/errors-billing/package.json] { "name": "@acme/errors-billing", "version": "1.0.0", "type": "module", "main": "./dist/index.mjs", "types": "./dist/index.d.ts", "exports": { ".": { "import": "./dist/index.mjs", "types": "./dist/index.d.ts" } }, "peerDependencies": { "evlog": "^3.0.0" }, "files": ["dist"] } ``` ### Source — catalog + augmentation in the same file ```typescript [packages/errors-billing/src/index.ts] import { defineErrorCatalog } from 'evlog' export const billingErrors = defineErrorCatalog('billing', { PAYMENT_DECLINED: { status: 402, message: 'Card declined', why: 'Issuer declined the charge', fix: 'Try a different payment method', link: 'https://docs.example.com/errors/billing.payment_declined', }, INSUFFICIENT_FUNDS: { status: 402, message: ({ available, required }: { available: number, required: number }) => `Insufficient funds: $${available}/$${required}`, }, // ... }) declare module 'evlog' { interface RegisteredErrorCatalogs { billing: typeof billingErrors } } ``` The `declare module` block lives inside the source file so the bundler emits it into the `dist/index.d.ts`. Any consumer that imports from `@acme/errors-billing` gets the augmentation transitively — no extra setup required on their side. ### Consumption ```typescript [apps/api/src/init.ts] // Importing the package activates both the runtime catalog and the type augmentation. import { billingErrors } from '@acme/errors-billing' import { authErrors } from '@acme/errors-auth' // Re-export from a central place so the rest of the app has one import path. export { billingErrors, authErrors } ``` ```typescript [apps/api/src/routes/checkout.post.ts] import { billingErrors } from '~/init' throw billingErrors.PAYMENT_DECLINED({ cause: stripeErr }) ``` ```typescript [Anywhere in the app — autocomplete works] import { createError, parseError } from 'evlog' throw createError({ code: 'billing.PAYMENT_DECLINED', // ← autocomplete from the registered catalog message: 'Card declined', status: 402, }) const err = parseError(caught) if (err.code === 'billing.PAYMENT_DECLINED') retry() // ↑ TypeScript knows the union of all registered codes ``` ::callout{color="neutral" icon="i-lucide-package"} **Each shared package owns its prefix.** `@acme/errors-billing` owns `billing.*` , `@acme/errors-auth` owns `auth.*` . Conflicts are impossible by construction. Bumping a catalog to a new minor (adding entries) propagates to consumers via the regular semver upgrade path — no codegen, no migration step. :: ## Composition patterns ### Mix catalogs and standalone factories `defineError` and `defineErrorCatalog` produce identical call-site shapes. Use catalogs for grouped errors, `defineError` for one-offs (e.g. cross-cutting concerns like rate-limiting that don't belong to a specific domain). ```typescript [src/errors/index.ts] import { defineError, defineErrorCatalog } from 'evlog' export const billingErrors = defineErrorCatalog('billing', { PAYMENT_DECLINED: { status: 402, message: 'Card declined' }, }) export const rateLimited = defineError('app.RATE_LIMITED', { status: 429, message: ({ retryAfter }: { retryAfter: number }) => `Rate limited: retry in ${retryAfter}s`, }) // Both look identical at the call site: throw billingErrors.PAYMENT_DECLINED() throw rateLimited({ retryAfter: 30 }) ``` ### Re-export from one entry per domain If a feature ships errors and audits together, give it a single re-export module so call sites only import once. ```typescript [src/features/billing/index.ts] export { billingErrors } from './errors/billing' export { billingAudit } from './audit/billing' ``` ```typescript [server/api/refund.post.ts] import { billingErrors, billingAudit } from '~/features/billing' if (!cart.items.length) throw billingErrors.CART_EMPTY() log.audit(billingAudit.INVOICE_REFUND({ actor, target: { id: 'inv_889' } })) ``` ### Override catalog defaults at the call site Every entry's defaults (`message`, `status`, `why`, `fix`, `link`, `internal`) are overridable per call. `internal` is shallow-merged (call-site wins on conflict). ```typescript // Catalog default: // message: 'Card declined' // internal: { category: 'gateway' } throw billingErrors.PAYMENT_DECLINED({ message: 'Custom message for this specific call', internal: { stripeRef: 'ch_x', category: 'gateway-overridden' }, cause: stripeErr, }) // Resulting EvlogError: // - message: 'Custom message for this specific call' (override) // - status: 402 (catalog default) // - why: 'Issuer declined the charge' (catalog default) // - internal: { category: 'gateway-overridden', stripeRef: 'ch_x' } ``` ## Type augmentation — deep dive The opt-in `declare module 'evlog'` block is what surfaces autocomplete on `createError({ code })`, `parseError(err).code`, and the typed `ErrorCode` / `AuditAction` exports. ### Where to put the augmentation | Repo shape | Recommended location | | ----------------------------- | --------------------------------------------------------------------------------------- | | Single file (`src/errors.ts`) | At the bottom of the same file | | Folder (`src/errors/*.ts`) | In `src/errors/index.ts` (centralised) or each catalog file (decentralised) | | npm package | At the bottom of the package's main `src/index.ts` so it ships in the published `.d.ts` | | Monorepo | One augmentation per package, no central registry needed | Both centralised and decentralised work — TypeScript merges multiple `declare module 'evlog'` blocks across files automatically. ### How to add custom domains Each augmentation key is the namespace name. Multiple catalogs sharing a prefix can either be merged into one key or split: ```typescript [Centralised — one key per package] declare module 'evlog' { interface RegisteredErrorCatalogs { billing: typeof billingErrors } } ``` ```typescript [Decentralised — one key per sub-domain] declare module 'evlog' { interface RegisteredErrorCatalogs { 'billing.payment': typeof billingPaymentErrors 'billing.subscription': typeof billingSubscriptionErrors 'billing.invoice': typeof billingInvoiceErrors } } ``` The `_codes` literal union is what produces the actual `ErrorCode` type — the keys themselves are arbitrary, choose what feels right for your structure. ### Verifying the augmentation ```typescript [Anywhere in the codebase] import type { ErrorCode, AuditAction } from 'evlog' // Hover the type in your IDE — should show the union of all registered codes. type AllErrorCodes = ErrorCode type AllAuditActions = AuditAction // Compile-time check: const validCode: ErrorCode = 'billing.PAYMENT_DECLINED' // OK const invalidCode: ErrorCode = 'billing.NOPE' // ← TS error if catalog is registered ``` If autocomplete is empty, either no catalog is registered yet, or the augmentation file is not in the TypeScript program (check `tsconfig.json` includes). ## Common pitfalls ::warning **Don't put `declare module` blocks in test files.** Augmentations from test files leak into the type-checker for the rest of the codebase if the test files are included in the main `tsconfig.json` . Keep augmentations next to the catalog source, never inside `*.test.ts` . :: ::warning **Avoid prefix collisions across packages.** If two packages augment the same `RegisteredErrorCatalogs` key (say both ship a `billing` catalog), TypeScript merges them silently and the runtime keeps the last-registered factory. Convention: one prefix per package, no overlap. :: ::warning **Never override the `code` at the call site.** The catalog defines the code identity — overriding it would break dashboards, alerts, and consumer code branching on `err.code` . The factory's call-site signature deliberately omits `code` from the overridable fields. :: ::tip **Prefer `factory.code` over string comparisons in tests.** Both forms below are valid; the first survives renames (refactor-safe), the second doesn't. ```typescript expect(err.code).toBe(billingErrors.PAYMENT_DECLINED.code) // ✓ refactor-safe expect(err.code).toBe('billing.PAYMENT_DECLINED') // ✗ string literal ``` :: ## API reference | Symbol | Kind | Purpose | | ---------------------------------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | | `defineError(code, options)` | factory | Standalone single-error factory. No prefix derivation. | | `defineErrorCatalog(prefix, map)` | factory | Bundle of typed errors sharing a prefix. | | `defineAuditAction(action, opts?)` | factory | Standalone single-action audit factory. | | `defineAuditCatalog(prefix, map)` | factory | Bundle of typed audit actions sharing a prefix. Each entry accepts `target`, `description`, `severity`, `requiresChanges`, `requiresReason`, `redactPaths`. | | `AuditCatalogEntry` | type | Metadata shape for a single catalog entry (alias of `AuditActionDefinition`). | | `AuditSeverity` | type | `'low' | 'medium' | 'high' | 'critical'`. | | `RegisteredErrorCatalogs` | interface | Augmentable registry of error catalogs. | | `RegisteredAuditCatalogs` | interface | Augmentable registry of audit catalogs. | | `ErrorCode` | type | Union of all registered error codes. | | `AuditAction` | type | Union of all registered audit actions. | Everything ships from the main `evlog` entrypoint. ## Next Steps - [Structured Errors](https://www.evlog.dev/learn/structured-errors): The full `createError` API and `parseError` reference. - [Audit → Recording](https://www.evlog.dev/use-cases/audit/recording): All audit-emission APIs (`log.audit`, `withAudit`, etc.). - [Frameworks](https://www.evlog.dev/integrate/frameworks/overview): Auto-managed per-request loggers and HTTP error serialization. # evlog CLI `@evlog/cli` is a separate package from `evlog` itself. It never runs in your app: it reads your source on disk, so there is nothing to configure and nothing to deploy. Its main job is [`evlog map`](https://www.evlog.dev/cli/map) — a static observability score for your app, in the spirit of Lighthouse. It finds every entry point in your project, checks each one for wide-event coverage, and tells you which three to fix first. Two commands write rather than read: [`evlog init`](https://www.evlog.dev/cli/init) wires evlog into the app so there is something to score, and [`evlog agents`](https://www.evlog.dev/cli/agents) teaches the AI agents working in the repository how to use it. ::warning{icon="i-lucide-flask-conical"} **Early days.** The CLI is safe to run on any project — it only reads files, and it is covered by tests — but it is young. `evlog map` understands four frameworks today, its rules are still being refined, and both will grow. Expect verdicts and scores to move between releases, and [pin the version](https://www.evlog.dev/cli/ci#pin-the-version) when you gate CI on the number. :: ::code-group ```bash [pnpm] pnpm dlx @evlog/cli map ``` ```bash [bun] bunx @evlog/cli map ``` ```bash [npm] npx @evlog/cli map ``` :: Add it as a dev dependency once you gate CI on the score, so every run uses the same version: ::code-group ```bash [pnpm] pnpm add -D @evlog/cli ``` ```bash [bun] bun add -d @evlog/cli ``` ```bash [npm] npm install --save-dev @evlog/cli ``` :: ::callout{color="neutral" icon="i-lucide-info"} Requires Node 20 or later. The package installs a single `evlog` binary. :: ## Commands ::card-group :::card --- color: neutral icon: i-lucide-wand-sparkles title: init to: https://www.evlog.dev/cli/init --- Install evlog, register the framework integration, and write a local sink. ::: :::card --- color: neutral icon: i-lucide-bot title: agents to: https://www.evlog.dev/cli/agents --- Write the evlog conventions into AGENTS.md and install the agent skills. ::: :::card --- color: neutral icon: i-lucide-radar title: map to: https://www.evlog.dev/cli/map --- Score wide-event coverage across every entry point, and name the ones to fix first. ::: :::card --- color: neutral icon: i-lucide-stethoscope title: doctor to: https://www.evlog.dev/cli/doctor --- Check that evlog is installed, resolvable, and writing logs where you expect. ::: :::card --- color: neutral icon: i-lucide-bar-chart-3 title: telemetry to: https://www.evlog.dev/cli/telemetry --- Show, enable, or disable the CLI's own anonymous usage telemetry. ::: :: ## Global flags Every command accepts these: | Flag | What it does | | ------------- | ------------------------------------------------------------- | | `--json` | Machine-readable JSON on stdout instead of the report | | `--debug` | Print a debug summary of the run, and emit it as a wide event | | `--noHeader` | Skip the branded header | | `--cwd ` | Run against another directory instead of the current one | | `--help` | Usage for the command | | `--version` | Print the CLI version and exit | ## Human output goes to stderr The report you read is written to **stderr**. Stdout is reserved for `--json`, so a run can be piped into `jq` without the report getting in the way: ```bash [Terminal] evlog map --json | jq '.map.score' ``` Without `--json`, nothing is written to stdout at all. Colour is dropped when stdout is not a TTY or when `NO_COLOR` is set, and the report lays itself out for the width it is given — set `COLUMNS` to render at a fixed width. ## Exit codes | Code | Meaning | | ---- | ------------------------------------------------- | | `0` | The command ran and nothing failed | | `1` | A check failed, or `--min-score` was not met | | `2` | Usage error — an unknown flag or an invalid value | ::warning A pipe replaces `$?` with the exit code of the *last* command in it, so `evlog map --min-score 90 \| head` always looks successful. Use `set -o pipefail` when you pipe a gated run. :: ## When a check is wrong Every verdict can be turned off from the code it is about, so a false positive never becomes a reason to stop running the tool: ```ts [server/api/health.get.ts] // evlog-map-disable-next-line wide-event, context -- liveness probe, deliberately silent export default defineEventHandler(() => ({ ok: true })) ``` The check becomes `n/a` with your reason attached — no score cost, no failed gate — and the report counts how many checks the project disabled, so the number stays honest. [Full syntax](https://www.evlog.dev/cli/rules#disabling-a-check). ## Monorepos `init`, `map` and `doctor` all resolve the nearest `package.json` above the working directory, then treat that package as the project. In a pnpm or npm workspace, running from `apps/web` scans `apps/web` — not the repo root. `evlog map` scans one app at a time. Running it from a bare workspace root, where there is no framework to detect, is an error rather than an empty report: ```bash [Terminal] cd apps/web && evlog map # or evlog map --cwd apps/web ``` ## Debugging a run `--debug` prints what the command did — the steps it went through, the directory it resolved, and any findings — and emits the same information as an evlog wide event: ```bash [Terminal] evlog map --debug ``` ```text [Output] ── debug ──────────────────────────────── command map env development cwd /Users/you/apps/web steps resolveProject → detectFramework → resolveEvlog → scan → writeMapFile → done ────────────────────────────────────────── full event → --json --debug (stderr) ``` Add `--json` to get the whole event instead of the summary. `EVLOG_CLI_DEBUG=1` does the same as the flag, which is useful in a CI job you cannot easily edit. ## Next - [`evlog init`](https://www.evlog.dev/cli/init) — wire evlog into an app that does not have it yet - [`evlog map`](https://www.evlog.dev/cli/map) — what it scans and how to read the report - [Rules](https://www.evlog.dev/cli/rules) — every check, what satisfies it, and how to fix it - [Scoring](https://www.evlog.dev/cli/scoring) — how the number is calculated - [CI](https://www.evlog.dev/cli/ci) — gate a pull request on the score # evlog init `evlog map` tells you which entry points are dark. `evlog init` is what you run before that — it does the setup the framework guide describes, in the project you are standing in. ```bash [Terminal] npx @evlog/cli init ``` On a terminal it reads your project, asks what it cannot infer, and shows you the plan before touching anything: ```text [Interactive] ┌ evlog init checkout │ ◇ Detected Nuxt │ ◇ Service name on every wide event │ checkout │ ◇ In development, where should events go? │ Local files │ ◇ And in production? │ Axiom, Sentry │ ◇ Anything else? │ Context │ ◻ Request enrichers │ Delivery │ ◻ Batching and retry │ ◻ Sampling │ Catalogs │ ◼ Error catalog · 3 repeated errors found │ ◻ Audit actions · 2 sensitive routes with no trail │ ◇ Plan ───────────────────────────────────╮ │ │ │ run pnpm add evlog │ │ update nuxt.config.ts │ │ create server/plugins/evlog-drain.ts │ │ create server/plugins/evlog-enrich.ts │ │ │ ├──────────────────────────────────────────╯ │ ◇ Apply? │ Yes │ ◇ 4 ok · 1 warn · 0 fail │ ◇ Set these before anything is received ─────────────╮ │ │ │ AXIOM_DATASET dataset to write to │ │ AXIOM_API_KEY API token with ingest permission │ │ │ ├─────────────────────────────────────────────────────╯ │ │ verify evlog doctor │ score evlog map │ └ Nuxt wired · evlog.dev/integrate/frameworks/nuxt ``` The production picker is a fuzzy search — type `ax` for Axiom — and takes more than one destination: the same event fans out to each. Extras are grouped and only list what your project can actually use, with the evidence that put them there. ## What it does 1. **Reads your project** with the same analysis [`evlog map`](https://www.evlog.dev/cli/map) runs — framework, installed packages, errors you repeat, entry points with no audit trail. That is what the offers are built from. 2. **Installs `evlog`** with the package manager your lockfile implies, unless it is already there. `--no-install` prints the command instead of running it. 3. **Registers the integration** — the Nuxt module, the Nitro module, or the Next.js instrumentation files. 4. **Wires your destinations**, dev and production branched in one place, with batching, enrichers and sampling when you asked for them. 5. **Teaches your AI agents**, if you let it — the evlog conventions as a block in `AGENTS.md`, a `CLAUDE.md` pointing at it, and the [agent skills](https://www.evlog.dev/reference/agent-skills) via `npx skills add`. Same thing [`evlog agents`](https://www.evlog.dev/cli/agents) does; the writes are planned alongside the wiring so you confirm once. Skills already installed are left for `npx skills update`. `--no-agents` skips it. 6. **Runs `evlog doctor`** before it finishes, so the run answers "did it work" instead of telling you to go and check. Everything it writes is reported line by line, including the parts that were already in place. ## It only offers what it can back up An option that does not apply is not shown, and one that is shown says why: | Offer | Appears when | | ------------------ | ------------------------------------------------------------------------- | | Error catalog | The scan found the same `createError` in more than one file | | Audit actions | `map` flagged sensitive entry points with no `log.audit()` | | AI SDK logging | `ai` is a dependency | | Auth identity | `better-auth` is a dependency | | Batching and retry | A production destination was picked — there is nothing to batch otherwise | | Vite plugin | The framework is Vite-based | Passing one of these as a flag against a project that cannot use it drops it and says so, rather than wiring something inert. ## Non-interactive Prompts are skipped — and every answer comes from flags and defaults — whenever anything suggests nobody is watching: | Condition | Why | | ----------------------------------------- | ------------------------------------------------------------------- | | `--yes` | You said so | | `--json` | The payload is the contract; half a TUI in front of it helps nobody | | stdin or stdout is not a TTY | Piped, redirected, or spawned by a tool | | `CI` is set to anything but `false` / `0` | A workflow runner has no keyboard | ```bash [Terminal] evlog init --yes --prod-drain axiom,sentry --extras pipeline,enrichers evlog init --yes --extras sampling --sampling high evlog init --json --drain none # plan and result as JSON, nothing to read ``` ::callout{color="neutral" icon="i-lucide-bot"} **Written for agents too.** The non-interactive path can express every choice the prompts can, so an agent reproduces exactly what a human just did — and it can never end up waiting on a keystroke that is not coming. An unknown `--drain` or `--extras` value stops the run rather than falling back to a default, because silently wiring the wrong destination is worse than a failed command. :: ## It will not overwrite your code `init` appends; it never rewrites. Concretely: - A config file is patched **at the exact offsets** of the node it is adding to. Your comments, quote style, and formatting survive — nothing is reprinted from an AST. - A file that already exists is left alone and reported as already present. There is no `--force`. - Anything it cannot do safely becomes a **manual step** with the snippet to paste and the reason it stopped. A `modules` key built from a variable is the common case: there is no correct place to splice a string into an array the CLI cannot see. Which makes it safe to run twice. The second run reports what is already wired and writes nothing: ```text [Output] · nuxt.config.ts already registers evlog/nuxt · nuxt.config.ts already has an evlog block · server/plugins/evlog-drain.ts already exists ``` Use `--dry-run` first if you would rather see the plan before anything is touched. ## Per framework ### Nuxt Adds `'evlog/nuxt'` to `modules` and an `evlog` block with your service name. `useLogger`, `createError`, and `parseError` are auto-imported from there. ```typescript [nuxt.config.ts] export default defineNuxtConfig({ modules: ['@nuxt/ui', 'evlog/nuxt'], evlog: { env: { service: 'checkout' }, }, }) ``` ### Nitro Adds the import and the module call, picking the subpath that matches your major — `evlog/nitro/v3` for Nitro 3, `evlog/nitro` for `nitropack`. Creates `nitro.config.ts` when there is none. ```typescript [nitro.config.ts] import { defineConfig } from 'nitro' import evlog from 'evlog/nitro/v3' export default defineConfig({ modules: [ evlog({ env: { service: 'api' }, }), ], }) ``` ### Next.js Creates `instrumentation.ts` and `lib/evlog.ts` — under `src/` when your app lives there, since Next only loads the one that matches. Wrapping handlers stays yours: Next has no ambient request logger, so each route opts in with `withEvlog()`. `init` prints the snippet. ```typescript [lib/evlog.ts] import { createEvlog } from 'evlog/next' export const { withEvlog, useLogger, log, createError } = createEvlog({ service: 'web', }) ``` ### TanStack Start Writes the Nitro v3 config with `experimental.asyncContext` enabled — without it `useRequest()` returns nothing and you get an install that looks complete and logs no business context. The `evlogErrorHandler` middleware on your root route is a manual step: `__root.tsx` is a component file, and splicing into it is guesswork. ## Destinations Two questions rather than one list: nobody sends local development traffic to Axiom, and nobody reads production logs off the box's filesystem. `--drain` sets the development sink (`fs` or `none`); `--prod-drain` takes one or more hosted destinations. | Id | Destination | Needs | | -------------- | --------------------------------------------- | --------------------------------- | | `fs` | Local files under `.evlog/logs` (dev default) | nothing | | `axiom` | Axiom | `AXIOM_DATASET`, `AXIOM_API_KEY` | | `otlp` | Any OpenTelemetry collector | `OTEL_EXPORTER_OTLP_ENDPOINT` | | `posthog` | PostHog | `POSTHOG_API_KEY` | | `sentry` | Sentry | `SENTRY_DSN` | | `better-stack` | Better Stack | `BETTER_STACK_API_KEY` | | `datadog` | Datadog | `DATADOG_API_KEY`, `DATADOG_SITE` | | `hyperdx` | HyperDX | `HYPERDX_API_KEY` | | `none` | Pretty console output only | nothing | Picking several production destinations fans the same event out to each. The generated plugin branches on the environment in one place, so "which drain runs where" is never something to reconstruct: ```typescript [server/plugins/evlog-drain.ts] const drains = import.meta.dev ? [createFsDrain()] : [pipeline(createAxiomDrain()), pipeline(createSentryDrain())] ``` Batching wraps the network sends only — buffering a local file write adds latency to the one loop where you want the event on screen immediately. ::callout{color="neutral" icon="i-lucide-key"} **`init` never asks for a secret.** It prints which environment variables the adapter reads and leaves them to you. A setup command that prompts for an API token is one that writes a credential into a file it chose, and the answer lands in your shell history on the way there. :: ## Extras | Id | What you get | | --------------- | ---------------------------------------------------------------------- | | `enrichers` | Pick from user agent, geo, request size, trace context (`--enrichers`) | | `pipeline` | Batching and retry instead of one HTTP call per request | | `sampling` | A rate profile, see below (`--sampling`) | | `error-catalog` | A typed catalog seeded with the errors you already repeat | | `audit-catalog` | Typed audit actions for the sensitive routes with no trail | | `ai` | Token usage, tool calls and cost on AI SDK generations | | `better-auth` | The signed-in user on every event | | `vite` | The Vite plugin that strips `log.debug()` from production builds | An extra that does not apply is dropped and reported, not refused — so `--extras vite,enrichers` does the right thing across a monorepo of mixed apps. ### Sampling presets Named by the traffic your app takes. Info is what moves — it is the bulk of the volume and the bulk of the bill — and warnings only give way at the top, where halving them still leaves the shape. | Id | Info | Warnings | For | | ----------- | ---------- | ---------- | ---------------------------------------------------- | | `all` | everything | everything | The right answer until volume or cost says otherwise | | `low` | 50% | 100% | A small app that has started to repeat itself | | `medium` | 25% | 100% | Steady traffic with a bill worth watching | | `high` | 10% | 100% | Info is most of what you are paying for | | `very-high` | 1% | 50% | Trends rather than individual requests | **Errors are kept at 100% in every tier** — a sampling config that drops errors hides the only events anybody reads at three in the morning. The generated config states it explicitly so the invariant is visible where somebody would otherwise wonder. **`debug` is never sampled, and never appears in the config.** An unspecified level is kept in full, and debug events only exist because somebody turned them on to chase something: a 5% sample of the logs you switched on to investigate a problem is a 5% chance of seeing the line you needed. Production builds strip `log.debug()` anyway, so there is rarely volume there to sample. ```typescript [nuxt.config.ts] evlog: { env: { service: 'shop' }, sampling: { rates: { info: 10, warn: 100, error: 100 }, }, } ``` ### Catalogs are seeded, not scaffolded `error-catalog` does not write a template with the names filled in. It writes the errors your code already repeats, with the prose you already wrote: ```typescript [server/utils/errors.ts] export const shopErrors = defineErrorCatalog('shop', { /** Currently written inline in server/api/checkout.post.ts, server/api/refund.post.ts */ CARD_DECLINED: { status: 402, message: 'Card declined', why: 'The issuer refused it', fix: 'TODO: what they should do about it', }, }) ``` `audit-catalog` works the same way, naming an action per entry point `map` flagged as sensitive and untracked. Both land as a to-do list with the types already written, so the migration is a find and replace rather than a design exercise. ## Monorepos Run from a workspace root and `init` sets up the apps rather than the root package, which serves no traffic. It lists the workspace packages that have a detectable framework — a shared `utils` package has no entry points to instrument — and asks which to wire: ```bash [Terminal] evlog init # pick from the list evlog init --yes --apps apps/web,apps/api ``` Each app keeps its own service name, taken from its `package.json`. ## The local sink On Nitro-based apps `init` writes a drain plugin; on Next.js it adds the drain to `lib/evlog.ts`. The filesystem drain — and only that one — is **gated to development**: ```typescript [server/plugins/evlog-drain.ts] import { createFsDrain } from 'evlog/fs' const drain = createFsDrain() export default defineNitroPlugin((nitroApp) => { // Local files are a development convenience — never a production sink. if (!import.meta.dev) return nitroApp.hooks.hook('evlog:drain', drain) }) ``` A drain that writes files writes them on whatever box serves the request. Turning one on in production without being asked is not a decision a setup command should make for you. Every hosted destination is generated without the guard, because that is what you picked it for. The directory it writes to is `.evlog/logs`, and evlog gitignores it on first write. It is also what [`evlog doctor`](https://www.evlog.dev/cli/doctor) looks for. ## Flags | Flag | What it does | | -------------------- | ----------------------------------------------------------------------- | | `--yes`, `-y` | Skip every question and take the defaults | | `--framework ` | Override detection (`nuxt`, `nitro`, `next`, `tanstack-start`) | | `--service ` | Service name on every wide event (default: your package name, unscoped) | | `--drain ` | Development sink: `fs` (default) or `none` | | `--prod-drain ` | Production destinations — see the table above | | `--extras ` | Comma-separated, see Extras | | `--enrichers ` | `user-agent`, `geo`, `request-size`, `trace-context` (default: all) | | `--sampling ` | `all`, `low`, `medium` (default), `high`, `very-high` | | `--apps ` | Workspace packages to set up (monorepo root only) | | `--dry-run` | Print the plan, write nothing | | `--no-install` | Do not run the package manager; print the command instead | | `--no-agents` | Skip all of it — no `AGENTS.md` block, no `CLAUDE.md`, no agent skills | | `--cwd ` | Set up another app in the workspace | | `--json` | The plan and result as JSON on stdout (implies non-interactive) | ## Next - [`evlog agents`](https://www.evlog.dev/cli/agents) — re-run the agent guidelines on their own, whenever the skills move on - [`evlog doctor`](https://www.evlog.dev/cli/doctor) — confirm the wiring resolves and logs are being written - [`evlog map`](https://www.evlog.dev/cli/map) — score what is still dark now that evlog is in place - [Quick Start](https://www.evlog.dev/start/quick-start) — the concepts behind the wiring # evlog map `evlog map` reads your project on disk and answers one question: **if something goes wrong in production tonight, which parts of this app will be able to tell you why?** It finds every entry point — API handlers, pages that fetch, middleware, scheduled jobs, server actions — checks each one for wide-event coverage, scores it, and names the three worth fixing first. Nothing runs, nothing is instrumented, no traffic is needed: it is static analysis over your source. :map-scan-flow ```bash [Terminal] evlog map ``` ::warning{icon="i-lucide-flask-conical"} **Early days.** The foundation is solid — AST-based, tested, with a versioned JSON contract — but the rule set is young and only four frameworks have adapters. A release that sharpens a rule can change a verdict on code you did not touch, so [pin the CLI version](https://www.evlog.dev/cli/ci#pin-the-version) if you gate a pull request on the score. :: ::prompt --- actions: - copy - cursor - claude description: Raise my evlog map score icon: i-lucide-radar --- Run `npx @evlog/cli map` in this project and read the report. - Fix the entry points listed under FIX FIRST, in order - For each one, run `npx @evlog/cli map ` first to see the suggested shape - Keep the changes minimal: add `useLogger()`, `log.set()`, `log.audit()`, or `createError({ why, fix })` where the report says they are missing - Re-run `npx @evlog/cli map` and confirm the score went up Docs: {rel=""nofollow""} Rules: {rel=""nofollow""} :: ## The report This is a real run against the evlog playground, a Nuxt app with 29 entry points: ::code-collapse ```text [evlog map] ▀▀█ █▀▀ score /100 evlog-playground · Nuxt █ █▀█ ▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▱▱▱▱▱ 29 entry points scanned ▀ ▀▀▀ good ▂▂▂▃▃▃▃▃▄▆▆▆▆▆███████████████ COVERAGE ● API handlers ▰▰▰▰▰▰▰▰▱▱ 79 13 of 28 have gaps ● Middleware & jobs ▰▰▰▰▰▱▱▱▱▱ 45 run without any logging ● Money & auth ▰▰▰▰▰▰▰▱▱▱ 71 missing audit trails FIX FIRST 1. ANY /api/auth/:all* A — touches auth and logs nothing server/api/auth/[...all].ts:1 · evlog.dev/learn/wide-events 2. GET /api/test/catalog/payment-declined $ — moves money with no audit trail server/api/test/catalog/payment-declined.get.ts:11 · evlog.dev/use-cases/audit/overview 3. POST /api/auth/login A — missing log.audit server/api/auth/login.post.ts:1 · evlog.dev/use-cases/audit/overview THEN · add useLogger + log.set to /api/test/browser-ingest +4 · add log.audit to /api/payment/process, /api/test/better-auth/whoami · add log.set to /api/audit/deny · add log.set + createError({ why, fix }) to /api/audit/with-audit · add useLogger + log.set + createError({ why, fix }) to /api/test/h3-error · add createError({ why, fix }) to /api/test/tail-sampling/error ✓ Already solid: /api/audit/catalog/invoice-refund +14 ▲ 76 → 86 by fixing the 3 above ──────────────────────────── evlog.map.json updated · how this score works → evlog.dev/cli/scoring ▸ evlog map --all every entry point · evlog map inspect one --min-score 80 CI gate ``` :: Read from the top down, each block answers a different question. ### The score The big digits are the global score out of 100: a weighted average of every entry point, where money and auth routes count double and pages count half. The word underneath is its grade — `excellent`, `good`, `needs work`, or `at risk`. The row of blocks on the right is every entry point in the project, worst on the left, best on the right. A wall of tall blocks on the right with a few short ones on the left is a healthy app with a handful of blind spots. A flat low row is an app with no instrumentation at all. On a large project the row is sampled down to the width of your terminal, showing the worst entry point in each bucket. ::callout{color="neutral" icon="i-lucide-gauge"} The full calculation — per-rule weights, route weighting, and grade thresholds — is on [Scoring](https://www.evlog.dev/cli/scoring) . :: ### Coverage Where the score comes from, grouped the way you think about your app rather than by rule. Areas only appear when the project has them: a Nuxt app with no `pages/` never shows a Pages row. | Area | What it covers | | ----------------- | ----------------------------------------------------------------------- | | API handlers | Server route handlers | | Pages | Pages that fetch data server-side | | Middleware & jobs | Middleware, scheduled tasks, server actions | | Money & auth | Every entry point the sensitivity classifier flagged, wherever it lives | Money & auth deliberately overlaps the other rows. It is the group where a missing event costs the most, so it gets its own line and a `⚠` marker when it scores below the app as a whole. ### Fix first The three entry points with the most to gain, sensitive ones first, worst score first. Each gets the method, the path, a sensitivity marker, a sentence naming the actual problem, and the file, the line, and a docs link for the rule that failed. `$` is money, `A` is auth, `@` is PII. These come from the [sensitivity classifier](https://www.evlog.dev/cli/scoring#sensitivity), and a flagged entry point is held to one extra requirement: an audit trail. ### Then Everything else with a gap, batched by the fix rather than listed one entry point per line. `add useLogger + log.set to /api/test/browser-ingest +4` means five entry points need the same two lines, so you can work through them in one pass. ### Going further A separate section that only appears when the project already uses an evlog feature that an entry point is not benefiting from: ```text [Excerpt] GOING FURTHER you already use these — your app could get more out of them + Should these duplicated errors become catalog entries? — 2 entry points server/api/orders/[id].get.ts:4 · evlog.dev/learn/catalogs Suggestions never change the score. ``` These are suggestions, not gaps. They are gated on evidence that the feature is already in use somewhere in the project, they are never counted as failures, and a `--min-score` gate can never fail because of one. See [opportunities](https://www.evlog.dev/cli/rules#opportunities). ### The last two lines `✓ Already solid` names entry points with nothing left to fix, so a good app gets told so. `▲ 76 → 86 by fixing the 3 above` is the score you would land on if you fixed exactly what is under **FIX FIRST** — the reason to start there rather than anywhere else. ## Three views The default report answers "how am I doing". Two flags answer the other two questions you will have. ### Every entry point `--all` prints the check matrix: one row per entry point, worst first, grouped by directory. ```text [evlog map --all (trimmed)] evlog-playground · Nuxt · 76/100 · 29 entry points, worst first log ctx err audit catch fetch server/ ├─ api/auth/[...all].ts ▰▰▱▱▱▱▱▱▱▱ 20 A ● ● · ● · · ├─ …ment-declined.get.ts ▰▰▱▱▱▱▱▱▱▱ 20 $ ● ● ● ● · · ├─ …test/h3-error.get.ts ▰▰▰▱▱▱▱▱▱▱ 25 ● ● ● · · · ├─ …owser-ingest.post.ts ▰▰▰▰▰▱▱▱▱▱ 45 ● ● · · · · ├─ …t/with-audit.post.ts ▰▰▰▰▰▰▰▱▱▱ 65 ● ● ● · ● · ├─ …i/auth/login.post.ts ▰▰▰▰▰▰▰▰▱▱ 75 A ● ● · ● · · ├─ …ampling/error.get.ts ▰▰▰▰▰▰▰▰▱▱ 80 ● ● ● · · · └─ …st/wide-event.get.ts ▰▰▰▰▰▰▰▰▰▰ 100 ● ● · · · · ● covered ● gap · not applicable $ money A auth @ pii what each column checks → evlog.dev/cli/rules ``` One column per rule, in the order they cost points. A dot means the rule does not apply here — a handler that throws nothing is not asked whether its errors carry `why` and `fix`, and that is a dot rather than a free pass. A hollow `○` is a check you [disabled with a comment](https://www.evlog.dev/cli/rules#disabling-a-check). Every column is explained on [Rules](https://www.evlog.dev/cli/rules). ### One entry point Pass a route or a file path to get the full explanation of a single entry point: ```bash [Terminal] evlog map server/api/auth/login.post.ts # or by route evlog map /api/auth/login ``` ::code-collapse ```text [evlog map server/api/auth/login.post.ts] POST /api/auth/login A ▰▰▰▰▰▰▰▰▱▱ 75/100 server/api/auth/login.post.ts · Nuxt WHY THIS FILE IS SCANNED ▍ POST /api/auth/login — server handler FLAGGED SENSITIVE BECAUSE ▍ auth: path says "auth" CHECKS ✓ useLogger wide event emitted per request ✓ log.set context attached with log.set() ✗ log.audit sensitive action with no audit trail evlog.dev/use-cases/audit/overview SUGGESTED SHAPE — Nuxt │ export default defineEventHandler(async (event) => { │ log.audit({ │ action: 'auth.login', │ actor: { type: 'user', id: user.id }, │ }) │ }) ▲ fixing this entry point: 75 → 100 ``` :: Four things worth noting: - **Why this file is scanned** says what the CLI thinks this file is. If that is wrong, everything below it is wrong, and this is where you see it. - **Flagged sensitive because** shows the exact reason, so a false positive is one line to spot instead of a mystery. - **Suggested shape** is composed from the rules that actually failed, in your framework's idiom. The audit action is read off the route — `/api/auth/login` suggests `auth.login`, not a placeholder. - When every requirement passes but there are suggestions left, the verdict splits in two: nothing to fix, and *n* things left to gain. ## What counts as an entry point Detection is per framework, from the file layout. `--framework` overrides it when detection guesses wrong. ::code-group ```text [Nuxt] server/api/** → API handler, path prefixed /api, method from the filename suffix server/routes/** → API handler, path as written app/pages/**/*.vue → page (Nuxt 4 default; also pages/ and src/pages/) server/middleware/** → middleware server/tasks/** → scheduled job ``` ```text [Nitro] routes/** → API handler, path as written api/** → API handler, path prefixed /api middleware/** → middleware ``` ```text [Next.js App Router] app/**/route.ts → one API handler per exported HTTP method (GET, POST, …) app/**/page.tsx → page middleware.ts → middleware "use server" file → one server action per exported function ``` ```text [TanStack Start] src/routes/** → API handler when the route declares server handlers or createServerFn, page otherwise. __root files are skipped. ``` :: Both `app/` and `src/app/` are supported for Next.js, and `src/middleware.ts` alongside `middleware.ts`. A `route.ts` with no HTTP method exports is listed once with the method `ANY`. Kinds show up in the report as `ANY`, `PAGE`, `MID`, `ACT` (server action), `CRON`, and `WS`, or as the HTTP method when the entry point has one. ::callout{color="neutral" icon="i-lucide-shield-off"} Entry points with nothing to instrument are exempt: evlog's own client-log ingest endpoints, which are plumbing rather than app code, and pages that fetch nothing. Every rule reports `n/a` for them rather than failing. :: ## When a verdict is wrong A check you disagree with should cost you one comment, not your CI gate: ```ts [server/api/health.get.ts] // evlog-map-disable-next-line wide-event, context -- liveness probe, deliberately silent export default defineEventHandler(() => ({ ok: true })) ``` The check becomes `n/a` with your reason attached, so it stops costing score — and the report says how many checks the project has disabled, so a high score never hides an app that logs nothing. Full syntax on [Rules](https://www.evlog.dev/cli/rules#disabling-a-check). ## Flags | Flag | Default | What it does | | -------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------ | | `` | — | Inspect one entry point, by route path or file path | | `--all` | off | Every entry point as a check matrix | | `--min-score ` | off | Exit 1 when the global score is below `n` | | `--baseline [ref]` | off | Exit 1 on a [regression](https://www.evlog.dev/cli/ci#the-ratchet---baseline) against the committed map (path, or `git:`) | | `--framework ` | detected | Force `nuxt`, `nitro`, `next`, or `tanstack-start` | | `--no-write` | writes | Skip writing `evlog.map.json` | | `--verbose` | off | Show per-file parse warnings | | `--cwd ` | current | Scan another directory | | `--json` | off | The full map as JSON on stdout | ## evlog.map.json Every run writes `evlog.map.json` to the project root: the score, the framework, the CLI version and rule-set version that wrote it, and every entry point with its checks, its suggestions, its sensitivity, and its own score. It is the same data `--json` prints. Use `--no-write` when you do not want the file, for CI runs or a quick look at somebody else's project. Whether to commit it depends on how you gate: - **Not gating CI, or only `--min-score`**: the file is a build artifact, so [gitignore it](https://www.evlog.dev/cli/ci#the-map-file). It holds the same data as `--json`, so nothing is lost. - **Gating with `--baseline`**: track it, like a lockfile. The ratchet compares against the committed copy, so `git:` can only read a file that was committed. Regenerate it with `evlog map`, review it in the diff, never hand-edit it. ## What it will not tell you Static analysis has limits, and knowing them is the difference between trusting the score and being annoyed by it. - **It reads code, not traffic.** An entry point can score 100 and still emit useless events at runtime if the context it attaches is wrong. The score says the shape is there. - **It follows imports one hop.** Helpers re-exported through a local module are resolved — `import { useLogger } from '@/lib/evlog'` counts — but a logger passed through three layers of your own abstraction may not be recognised. - **Four frameworks.** Nuxt, Nitro, Next.js App Router, and TanStack Start have adapters. Other frameworks that evlog integrates with are not scanned yet. - **Sensitivity is a heuristic.** It reads imports and route paths. `evlog map ` always shows the reason, so a wrong call is visible rather than silent. ## Next - [Rules](https://www.evlog.dev/cli/rules) — every check, what satisfies it, and the exact fix - [Scoring](https://www.evlog.dev/cli/scoring) — weights, grades, and the sensitivity classifier - [CI](https://www.evlog.dev/cli/ci) — gate a pull request on the score # Map rules Coverage is checked by a rule engine, not a bag of heuristics. Each rule has a stable id, a documented weight, a docs link, and a set of entry point kinds it applies to — and every finding carries the file and line it came from, so a verdict is always something you can go and look at. ::warning{icon="i-lucide-flask-conical"} **This list is not final.** Ten rules is where the engine starts, not where it ends. Rules will be added, existing ones will get more precise about what counts, and a sharper rule can change a verdict on code you did not touch. Ids and weights are documented so the change is always explainable — but do not treat a score as comparable across CLI versions. :: Rules come in two categories, and the difference matters: | | Requirements | Opportunities | | ------------------- | -------------------------- | ---------------------------------------------- | | Effect on the score | Costs points when it fails | None, ever | | When it appears | Whenever it applies | Only when the project already uses the feature | | In the report | `FIX FIRST` and `THEN` | `GOING FURTHER` | | In the JSON | `checks` | `suggestions` | | Can fail a CI gate | Yes | No | ## Three statuses Every rule returns one of three verdicts for an entry point. | Status | Meaning | | ------ | ----------------------------------------------------------------------------------------------------- | | `pass` | The rule looked and found what it wanted | | `fail` | The rule looked and did not — costs weight, if it is a requirement | | `n/a` | The question does not make sense here, or you [disabled it](https://www.evlog.dev/#disabling-a-check) | `n/a` is doing real work. A handler that throws nothing is never asked whether its errors carry `why` and `fix`; a handler with no `catch` is never asked whether its catches log. Those used to pass for free, which made the report claim a file handled errors it did not have. Now they are dots in the matrix, and the score is calculated over the rules that actually applied. :map-rules-matrix ## Requirements Six rules move the score. They apply to handler-like entry points — API handlers, middleware, scheduled jobs, and server actions — except `fetch`, which is the pages-only rule. | Column | Id | Weight | Expects | | ------- | --------------------- | ------ | ----------------------------- | | `log` | `wide-event` | 40 | `useLogger()` | | `audit` | `audit` | 25 | `log.audit()` | | `err` | `structured-errors` | 20 | `createError({ why, fix })` | | `fetch` | `page-error-handling` | 20 | fetch error handling | | `ctx` | `context` | 15 | `log.set()` | | `catch` | `error-handling` | 15 | logging or rethrow in `catch` | ### log — does this entry point emit a wide event? The heaviest rule, because everything else depends on it. An entry point with no logger produces no event, and no amount of good error handling will tell you what happened inside it. Satisfied by a resolved evlog logger (`useLogger`, `createLogger`, `createRequestLogger`, `initLogger`) or an evlog wrapper (`withEvlog`, `withAudit`). The message depends on your framework. With evlog's Nitro plugin an event is emitted for every request whether the handler asks or not, so the failure there is not silence, it is emptiness: ```text [Nuxt / Nitro] handler adds nothing to its request event — only method, path and status are recorded ``` ```text [Next.js / TanStack Start] no useLogger() — handler is a dark event ``` ::code-group ```ts [Nuxt] export default defineEventHandler(async (event) => { const log = useLogger(event) }) ``` ```ts [Next.js] export async function POST(request: Request) { const log = useLogger() } ``` :: [Wide events →](https://www.evlog.dev/learn/wide-events) ### ctx — is request context attached? A logger with no `log.set()` produces a technically valid event that says nothing about the request it describes. That is the most common way a wide event ends up useless in production: you get one line per request, and none of them can answer a question. Only `set()` on a resolved evlog logger counts. An unrelated `Map.set()` does not. ```ts log.set({ user: { id }, order: { id, total } }) ``` [Wide events →](https://www.evlog.dev/learn/wide-events) ### err — do thrown errors carry why and fix? `throw new Error('failed')` reaches the client as a string with no cause and no remedy. `createError({ why, fix })` is what makes an error actionable for whoever reads it at 3am. Applies only when the handler raises something — a `throw` or a `createError()` call. The message names exactly what is missing: ```text throw new Error() — use createError({ why, fix }) createError() missing why and fix createError() has why but missing fix ``` A `createError()` that is returned rather than thrown is checked too, since it still shapes the response. ```ts throw createError({ status: 400, message: 'what the caller sees', why: 'what actually went wrong', fix: 'what to do about it', }) ``` [Structured errors →](https://www.evlog.dev/learn/structured-errors) ### audit — does this sensitive entry point leave a trail? Applies only where the [sensitivity classifier](https://www.evlog.dev/cli/scoring#sensitivity) found money or auth. Everywhere else an audit record would be noise, so the rule reports `n/a` rather than passing for free. `log.audit()` satisfies it, including through optional chaining — `log.audit?.deny()` counts. The suggested action is read off the route, so `/api/auth/login` suggests `auth.login` and `/api/orders/[id]/refund` suggests `orders.refund`: ```ts log.audit({ action: 'auth.login', actor: { type: 'user', id: user.id }, }) ``` [Audit logs →](https://www.evlog.dev/use-cases/audit/overview) ### catch — is every caught error logged or rethrown? A swallowed error is worse than an unhandled one: the request looks successful, the event says nothing, and the failure is invisible until a user reports it. A `catch` counts as handled when it logs (`log.error`, `log.warn`, `log.set`, `log.audit`, `captureException`, or any `console.*`), rethrows, or returns. Two failures are reported separately: ```text empty catch block swallows errors catch block swallows error without logging or rethrow ``` A handler with no `catch` at all is `n/a`, not a gap. evlog's framework integrations hook the runtime's error channel, so an exception that escapes your handler is still recorded on the event with its status. ```ts catch (error) { log.error(error) } ``` [Structured errors →](https://www.evlog.dev/learn/structured-errors) ### fetch — does this page survive its data fetch failing? The only pages rule. Applies only to pages that actually fetch something server-side — a purely presentational page has nothing to fail. Satisfied by a `catch`, a `.catch()`, an `onError` handler, or an `error` binding destructured from the fetch itself. ::code-group ```vue [Nuxt] ``` ```tsx [Next.js] try { const orders = await getOrders() } catch (error) { log.error(error) } ``` :: [Lifecycle →](https://www.evlog.dev/learn/lifecycle) ## Opportunities Four rules suggest going further. They never touch the score, and they only ever fire when the project has **already adopted** the feature somewhere — the point is to get more out of what you chose, not to sell you something. Adoption is confirmed against the AST, not by searching text, so a feature mentioned in a comment does not count. | Column | Id | Fires when | Scope | | ---------- | ---------------- | ----------------------------------------------------------------------------------------- | ---------------- | | `catalog` | `error-catalog` | The project declares a catalog **and** the same inline error appears in two or more files | Per entry point | | `audit+` | `audit-coverage` | The project records audit events, and this handler changes state without one | Per entry point | | `ai` | `ai-logging` | `ai` is a dependency and the AI SDK is called without `evlog/ai` | Once per project | | `identity` | `auth-identity` | `better-auth` is a dependency and `evlog/better-auth` is not installed | Once per project | ### catalog — should these duplicated errors become catalog entries? Deliberately narrow. An earlier version fired on any inline `createError()`, which meant handlers with perfectly good errors got lectured. Duplication is the one signal that makes the case on its own: the same status and message maintained in three places will drift. ```text "402 Card declined" is spelled out here and in 2 other files — one catalog entry would cover them ``` The suggestion names a catalog you already have, rather than inventing one. [Catalogs →](https://www.evlog.dev/learn/catalogs) ### audit+ — should this state change be on the audit trail too? Complements the `audit` requirement, which only fires on money and auth. This one is softer and wider: once you have an audit trail, every state change is a candidate, and you are the one who knows which ones matter. It looks for `create`, `update`, `insert`, `upsert`, `delete`, or `destroy` calls in a handler with no audit record. Entry points already covered by the `audit` requirement are skipped, so a gap is never reported twice. [Recording audit events →](https://www.evlog.dev/use-cases/audit/recording) ### ai — are model calls, tokens and latency on the event? Fires on `generateText`, `streamText`, `generateObject`, `streamObject`, `embed`, and `embedMany` when `evlog/ai` is not imported. Without it the event records that the request happened but not what the model cost, which is usually the most expensive and most variable part of it. Reported once for the whole project — one wrapped model serves every handler. [AI SDK →](https://www.evlog.dev/use-cases/ai-sdk/overview) ### identity — do events carry the authenticated user? Fires on entry points where auth is actually in play: the auth routes themselves, or a handler that reads the session. `evlog/better-auth` attaches the user and session to every event, which is what turns "a request failed" into "this user's request failed". Reported once for the whole project — it is one plugin, installed once. [Better Auth →](https://www.evlog.dev/use-cases/better-auth/overview) ## How evlog helpers are recognised Every rule that looks for a logger asks the same question: *is this evlog's?* It is answered from the AST, which is why a locally defined stub does not earn you points. - **Imported from evlog** — `import { useLogger } from 'evlog'`, including subpath imports. - **Auto-imported** — in Nuxt and Nitro, evlog's module injects `useLogger` and `createEvlogError`, so an un-imported call counts. Unless the file declares its own, in which case the local declaration wins. - **Re-exported from a local module** — `import { useLogger } from '@/lib/evlog'` counts when that module forwards evlog's export, which is the shape [the Next.js guide](https://www.evlog.dev/integrate/frameworks/nextjs) recommends. Exports destructured from a factory (`export const { useLogger } = createEvlog(...)`) are resolved too. - **Through an evlog wrapper** — `withEvlog` and `withAudit` instrument a handler without it ever naming a logger. Two things deliberately do **not** count: - **A local stub.** `function useLogger() { ... }` in your own file, or an import from `./my-logger` that does not forward evlog's export, is not evlog's logger. - **evlog's `log` export.** `log.info()` is the [simple logging API](https://www.evlog.dev/learn/simple-logging) — it emits its own line rather than contributing to the request's wide event, and it has no `set` or `audit`. It does not satisfy the `log` column. ## Disabling a check A static analyser you cannot argue with is one you stop running. When a rule is wrong about your code — or right about it and you have decided not to care — turn it off with a comment, next to the code it is about: ```ts [server/api/health.get.ts] // evlog-map-disable-next-line wide-event, context -- liveness probe, deliberately silent export default defineEventHandler(() => ({ ok: true })) ``` Three forms, matching what you would expect from a linter: | Directive | Covers | | -------------------------------------- | ------------------------------------------------- | | `// evlog-map-disable-next-line ` | The line below the comment | | `// evlog-map-disable-line ` | The line the comment is on, as a trailing comment | | `// evlog-map-disable ` | The whole file, wherever it appears | The ids are the ones in the tables above — `wide-event`, `audit`, `structured-errors`, `context`, `error-handling`, `page-error-handling`, and any opportunity id. Separate several with commas or spaces. **Name no id and the directive covers every rule**, which is the right shape for a generated or vendored file and the wrong one for a handler you simply have not got to yet. Block comments work the same way, which is what you want at the top of a file: `/* evlog-map-disable -- generated by the SDK */`. Everything after `--` is your reason. It is optional, and worth writing anyway: it is what the report shows, so the next person reads the decision rather than guessing at it. ### What a disabled check does to the score It becomes `n/a` with your reason attached — the same status as a rule that never applied — so it costs no points, and a `--min-score` gate stops failing on it. The rule still runs. What a directive waives is the *finding*, not the question: a check that would have passed is still reported as a `pass`, and a rule that never applied to that handler stays a plain `n/a`. So a file-wide directive on an already-instrumented handler disables nothing, and the number of disabled checks is the number of verdicts you actually chose not to see. Which means a disabled check has to stay visible, or the escape hatch would quietly become a way to score 100 on an app that logs nothing. It shows up in three places: ```text [evlog map] ○ 2 checks disabled by comment in 1 entry point ``` ```text [evlog map ] CHECKS ✓ log.set context attached with log.set() ○ useLogger disabled at line 1 — liveness probe, deliberately silent ``` In `--all`, a disabled cell is `○` rather than the `·` of a rule that did not apply. And in the JSON, the check carries `"suppressed": true` next to its `n/a`, with `evidence` pointing at the comment — so a CI job can report how much of a green score is suppressed, and `summary.suppressedChecks` gives the project total. ### A typo is loud An id no rule answers to does not silently do nothing — the scan warns above the report, and the check keeps failing: ```text ⚠ server/api/probe.get.ts:1 disables "wide-evnt", which is not a check evlog map runs ``` Believing a check is off while it is still failing is worse than either state on its own, so this one is never quiet. ## Exempt entry points evlog's own client-log ingest endpoints (`/api/evlog/ingest` and friends) are plumbing, not app code. Every rule reports `n/a` for them with the reason attached, they are never listed as something to fix, and the summary counts them as `exempt` rather than as your own instrumented entry points. This holds even when the file fails to parse: an exemption that only applied to readable files would not be much of a guarantee. A static page is exempt for the same reason. It fetches nothing, so `page-error-handling` has nothing to ask of it and reports `n/a` — counting that as an unobserved entry point would show a marketing page as an observability gap. ## Reporting a wrong verdict Rules are one file each with their own test bench, so a wrong verdict is a local fix rather than an archaeology session. If `evlog map ` claims something you can see is untrue, that is a bug worth [opening an issue](https://github.com/HugoRCD/evlog/issues){rel=""nofollow""} for — include the entry point's output and the handler, and the fix lands in one rule. In the meantime, [disable the check](https://www.evlog.dev/#disabling-a-check) on that line. A false positive should cost you one comment, not your CI gate. ## Next - [Scoring](https://www.evlog.dev/cli/scoring) — how weights become a number, and how sensitivity is decided - [CI](https://www.evlog.dev/cli/ci) — gate a pull request on requirements without suggestions getting in the way # Map scoring The score exists to be compared against itself. It is a number you can watch move as you fix things, and gate a pull request on — not a benchmark against other projects. ## One entry point Every entry point starts at 100 and loses the weight of each **requirement** it fails, floored at 0. | Requirement | Weight | | --------------------- | ------ | | `wide-event` | 40 | | `audit` | 25 | | `structured-errors` | 20 | | `page-error-handling` | 20 | | `context` | 15 | | `error-handling` | 15 | A rule that passes costs nothing. A rule that reports `n/a` costs nothing either — it never applied, so it is not held against the entry point. [Opportunities](https://www.evlog.dev/cli/rules#opportunities) carry no weight at all and cannot appear here. A check you [disabled with a comment](https://www.evlog.dev/cli/rules#disabling-a-check) is also `n/a`, so it costs nothing — and the report counts how many, because a score that is partly the result of disabled checks has to say so. Take the login handler from the playground: ```text [evlog map server/api/auth/login.post.ts] CHECKS ✓ useLogger wide event emitted per request ✓ log.set context attached with log.set() ✗ log.audit sensitive action with no audit trail ``` `structured-errors` and `error-handling` were `n/a` — the handler throws nothing and catches nothing. One requirement failed, so the score is `100 − 25 = 75`. ::callout{color="neutral" icon="i-lucide-info"} Weights add up to more than 100 on purpose. A handler that fails everything lands at 0, and the ordering of what to fix first stays meaningful: `wide-event` at 40 outweighs any pair of the smaller rules. :: Which makes the arithmetic legible in both directions. Here is the worst entry point in the playground going from `20` to `100`, one line at a time, each fix worth exactly the weight of the rule it satisfies: :map-score-climb ## The project score The global score is the average of every entry point, weighted by how much a blind spot there would cost you: | Entry point | Weight | | --------------------- | ------ | | Flagged money or auth | ×2 | | Page | ×0.5 | | Everything else | ×1 | Pages weigh less because a page that swallows a fetch error is a worse user experience than an observability hole, and they are usually the most numerous files in an app. Sensitive handlers weigh double because that is where you will need the event. Three entry points — a sensitive handler at 75, a plain handler at 100, and a page at 50: ```text (75 × 2) + (100 × 1) + (50 × 0.5) 275 ───────────────────────────────── = ───── = 79 2 + 1 + 0.5 3.5 ``` A project with no entry points at all scores 100. ## Grades | Score | Grade | | ------ | ------------ | | 90–100 | `excellent` | | 70–89 | `good` | | 50–69 | `needs work` | | 0–49 | `at risk` | ## Coverage classification Alongside the score, each entry point is classified — this is what the `summary` block in the JSON counts, and what the coverage rows in the report are built from. | Class | When | | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | | `instrumented` | A handler that passes both `wide-event` and `context`, or a page that handles its fetch errors | | `partial` | A handler that passes one of the two | | `dark` | A handler that passes neither, or a page that swallows fetch errors | | `exempt` | Nothing to instrument — evlog's own infrastructure, or a page that fetches nothing. Counted apart from your own entry points rather than as a gap | `dark` is the number to watch. A dark entry point produces nothing you can query: when it misbehaves, the only evidence you will have is the user's description of it. ## Sensitivity Sensitivity is what promotes an entry point to double weight and adds the `audit` requirement, so it is worth knowing exactly how it is decided. ### Money — `$` - **Imports** `stripe`, `@stripe/stripe-js`, `paddle-sdk`, or `@lemonsqueezy/lemonsqueezy.js` - **Path** contains `checkout`, `payment`, `billing`, `invoice`, `refund`, `subscription`, `charge`, or `payout` ### Auth — `A` - **Imports** `better-auth`, `next-auth`, `lucia`, `@auth/core`, or `@auth/nextjs` - **Path** contains `auth`, `oauth`, `login`, `logout`, `signin`, `signup`, `register`, `password`, `token`, `session`, `mfa`, or `otp` ### PII — `@` Fields matching `email`, `phone`, `address`, `ssn`, or `iban` **and** a write call (`create`, `update`, `insert`, `upsert`) in the same handler. Money and auth are `high` sensitivity: double weight, and an audit trail is required. PII is `medium`: no extra requirement, but it is marked in the report. ### How the matching works Two decisions keep this from producing noise. **Imports are read from the AST.** A package counts only when it is genuinely imported, so a `// TODO: drop stripe` comment does not make a route handle money. **Path terms match whole words**, allowing a plural. `/api/authors` is not an auth route, and `/api/refunds` is a money route. This matters more than it looks: a wrongly flagged route is handed a 25-point requirement it has no reason to satisfy, and counts double in the average — the fastest way to make the whole number untrustworthy. Every reason is shown in full when you inspect an entry point, so a wrong call is one line to spot: ```text [evlog map server/api/auth/login.post.ts] FLAGGED SENSITIVE BECAUSE ▍ auth: path says "auth" ``` ## Reading the score honestly - **A high score is not proof of good observability.** It says the shape is there: an event per entry point, context attached, errors explainable, sensitive actions audited. Whether the context you attach is the context you will need is a judgement no static analyser can make. [Best practices →](https://www.evlog.dev/reference/best-practices) - **Compare runs, not projects.** A 200-route app that scores 70 is in better shape than a 6-route app that scores 70. - **Watch `dark` and `Money & auth`** rather than the headline number. Those are the two places where the next incident is hiding. ## Next - [CI](https://www.evlog.dev/cli/ci) — turn the score into a pass/fail check - [Rules](https://www.evlog.dev/cli/rules) — what each requirement actually looks for # evlog map in CI A score you look at once is a nice afternoon. A score in CI is what stops the next handler from shipping dark. ## The gate `--min-score ` prints an explicit verdict and exits 1 when the project is below the threshold: ```bash [Terminal] evlog map --min-score 80 ``` ```text [Below the threshold] GATE score 76 is below --min-score 90 — exit code 1 fix what is listed under FIX FIRST to pass · evlog.dev/cli/ci ``` ```text [At or above it] GATE score 76 meets --min-score 70 — exit code 0 ``` The full report is printed either way, so a failed job tells you what to fix without a second run. The threshold has to be a whole number between 0 and 100. Anything else — a typo, a stray unit, an unexpanded shell variable — stops the command rather than being read as "no gate", because a gate that quietly disables itself reports success for a bar nobody checked. Which turns the score into something a pull request can move. One run says the app is short of the bar and names the three entry points responsible, the next one says it is over: :map-pr-gate ::callout{color="neutral" icon="i-lucide-shield-check"} [Opportunities](https://www.evlog.dev/cli/rules#opportunities) never affect the score, so a gate can never fail because the CLI suggested you adopt a feature. Only requirements can fail a build. :: ## The ratchet: `--baseline` `--min-score` asks "is this app good enough". `--baseline` asks the other question: **did this pull request make it worse**. ```bash [Terminal] evlog map --baseline ``` It compares the fresh scan against the `evlog.map.json` you committed, per entry point and per check: ```text [A regression] BASELINE score 56 → 44 (-12) vs evlog.map.json REGRESSED ✗ POST /api/checkout — useLogger no longer passes server/api/checkout.post.ts · evlog.dev/learn/wide-events ✗ POST /api/checkout — log.audit no longer passes server/api/checkout.post.ts · evlog.dev/use-cases/audit/overview NEW AND DARK ⚠ GET /api/reports — added with no instrumentation 2 regressions and a 12 point drop — exit code 1 · evlog.dev/cli/ci evlog.map.json was not rewritten — fix the regression, or re-run without --baseline to accept it ``` The unit is the requirement, not the total. A refactor that instruments one route and breaks another can leave the score untouched, and a gate watching only the number would call that a no-op. ::callout{color="neutral" icon="i-lucide-lock"} **Nothing is fetched.** The baseline is a file your repository already contains, so CI has it on disk right after `checkout` — no network, no token, no repository access. A private repo gates exactly like a public one. :: ### What counts as a regression | Change | Verdict | | ------------------------------------------------------------------------------------------------------ | ------------------------------------------ | | A requirement went from `pass` to `fail` | **Fails the gate** | | A passing requirement was [disabled with a comment](https://www.evlog.dev/cli/rules#disabling-a-check) | **Fails the gate** | | The global score dropped | **Fails the gate** | | A new entry point was added with no instrumentation | Listed under `NEW AND DARK`, does not fail | | An entry point was deleted | Counted as removed | | A requirement went from `fail` to `pass` | Counted as fixed | Silencing a check costs the same as breaking it, on purpose: a disable comment is the cheapest way to turn a gate green without writing any instrumentation, and a ratchet that let it through would be measuring the comments rather than the code. New dark routes are reported rather than gated. On an app that is not green yet, failing every pull request that adds an endpoint is how a team learns to turn the job off — `--min-score` is where a bar for new work belongs. Run both when you want both. ### The map file is the ratchet `--baseline` means you **commit** `evlog.map.json` instead of ignoring it: ```bash [Terminal] evlog map # updates the file git add evlog.map.json ``` A run that reports a regression deliberately leaves the file untouched. Overwriting it there would move the ratchet down to the worse state, and the same command run a second time would report no regression and exit 0. Accepting a drop is therefore explicit: re-run without `--baseline`, and commit the new map with the reason in your message. ```yaml [.github/workflows/observability.yml] - uses: actions/checkout@v5 - run: pnpm install --frozen-lockfile - run: pnpm evlog map --baseline ``` The file also churns on `generatedAt` every run. That is the cost of tracking it, and it buys a reviewable diff of exactly which entry points changed class. ### Comparing against a branch instead Pass a `git:` to read the committed copy through git rather than the working tree — useful when the pull request touches the map file itself: ```bash [Terminal] evlog map --baseline git:origin/main evlog map --baseline ../base-map.json # or any path ``` With a bare `--baseline` and no map on disk, the CLI falls back to `git:HEAD` on its own, so the answer stays "what did the last commit say" rather than "what did I say a minute ago". Reading through git only works for a file that was committed: a map you gitignored does not exist at any ref, and the CLI's error says exactly that and names the fix. ### When the rule set moves `evlog.map.json` records the CLI version that wrote it and a separate **rule-set version** that only changes when a rule's semantics change. On `--baseline`, the CLI compares the committed rule-set version against its own. If they differ, it refuses to diff and exits `2`: a rule tightened between the two versions would show up as a `pass` to `fail` transition on code the pull request did not touch, and a gate that blames the wrong thing is worse than one that admits it cannot run. ```text baseline was written by @evlog/cli 0.3.0, running @evlog/cli 0.4.1 (rule set 1 → 2) regenerate the baseline: evlog map && git add evlog.map.json ``` A release that ships a feature but no rule change keeps the rule-set version, so upgrading the CLI does not force everyone to regenerate. A map written before version reporting has no version fields; the CLI treats it as unknown and warns once instead of failing every project on upgrade. ## Exit codes | Code | Meaning | | ---- | -------------------------------------------------------------------------------------------------------------- | | `0` | Score met the threshold, no regression against the baseline, or neither was requested | | `1` | Score below `--min-score`, a regression against `--baseline`, or the scan could not run | | `2` | Usage error — unknown flag, invalid `--framework`, or a baseline whose rule set does not match the running CLI | ::warning A pipe replaces `$?` with the exit code of the last command in it, so `evlog map --min-score 90 \| tee map.log` always looks green. Add `set -o pipefail` to the step. :: ## GitHub Actions ```yaml [.github/workflows/observability.yml] name: Observability on: pull_request jobs: map: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - uses: actions/setup-node@v5 with: node-version: 22 - run: npx @evlog/cli map --min-score 80 --no-write ``` `--no-write` keeps the job from producing an `evlog.map.json` nobody will read. ### Pin the version The CLI is young: rules are still being refined and new ones will be added, so a release can change a verdict on code nobody touched. On a gated job that shows up as a pull request failing for reasons its author cannot see in the diff. Install the CLI as a dev dependency and let your lockfile hold it still: ```bash [Terminal] pnpm add -D @evlog/cli ``` ```yaml [.github/workflows/observability.yml] - run: pnpm install --frozen-lockfile - run: pnpm evlog map --min-score 80 --no-write ``` Then a score change is always something you did, and upgrading the CLI is its own pull request — where a moved score is the point rather than a surprise. ### Ratchet, don't cliff Setting the threshold to 90 on an app that scores 41 fails every pull request and teaches the team to ignore the job. Set it to today's score, then raise it as you fix things: ```bash [Terminal] # what are we at right now? evlog map --json --no-write | jq '.map.score' ``` Each pull request that raises the score raises the floor with it. The report already tells you what the next step is worth: `▲ 76 → 86 by fixing the 3 above`. ## The JSON contract `--json` writes the whole map to stdout. The report goes to stderr, so the two never mix. ```bash [Terminal] evlog map --json --no-write > map.json ``` ```json [Shape] { "schemaVersion": 2, "environment": "production", "map": { "version": 1, "generatedAt": "2026-07-25T18:42:10.114Z", "framework": "nuxt", "projectName": "evlog-playground", "score": 76, "routes": [] }, "summary": { "instrumented": 19, "partial": 2, "dark": 8, "exempt": 0, "suppressedChecks": 0 }, "mapPath": "/path/to/app/evlog.map.json" } ``` `mapPath` is `null` under `--no-write`. Each entry in `map.routes` looks like this: ::code-collapse ```json [One entry point] { "framework": "nuxt", "kind": "api", "method": "POST", "path": "/api/auth/login", "file": "server/api/auth/login.post.ts", "handler": { "line": 1, "column": 0 }, "id": "337325358269", "checks": { "wide-event": { "status": "pass" }, "context": { "status": "pass" }, "structured-errors": { "status": "n/a" }, "error-handling": { "status": "n/a" }, "audit": { "status": "fail", "message": "has logger + context but no log.audit() — sensitive route needs audit trail", "evidence": { "file": "server/api/auth/login.post.ts", "line": 1 } } }, "suggestions": {}, "sensitivity": { "level": "high", "reasons": ["auth: path says \"auth\""] }, "score": 75 } ``` :: `checks` holds requirements, `suggestions` holds opportunities. They are separate keys precisely so a consumer — or a CI script — can never mistake a suggestion for a failure. Rule ids are stable: the registry and the published id union are checked against each other at build time, so a release cannot silently change what you receive. A check somebody [disabled with a comment](https://www.evlog.dev/cli/rules#disabling-a-check) is `n/a` with `"suppressed": true`, and its `evidence` points at the comment rather than at the handler: ```json [A disabled check] "wide-event": { "status": "n/a", "suppressed": true, "message": "disabled at line 1 — liveness probe, deliberately silent", "evidence": { "file": "server/api/health.get.ts", "line": 1 } } ``` `summary.suppressedChecks` is the project total. It is the number to watch alongside the score: the gate only measures what the rules were allowed to look at. ### Recipes ```bash [Terminal] # the score, for a badge or a comment evlog map --json --no-write | jq '.map.score' # every entry point with no event at all evlog map --json --no-write \ | jq -r '.map.routes[] | select(.checks["wide-event"].status == "fail") | .file' # every failure as file:line — message evlog map --json --no-write \ | jq -r '.map.routes[].checks | to_entries[] | select(.value.status == "fail") | "\(.value.evidence.file):\(.value.evidence.line) — \(.value.message)"' # fail a script when anything is dark test "$(evlog map --json --no-write | jq '.summary.dark')" -eq 0 # how much of the score is disabled checks evlog map --json --no-write | jq '.summary.suppressedChecks' ``` ## The map file Unless you pass `--no-write`, every run writes `evlog.map.json` to the project root. It holds the same data as `--json`. Whether to commit it depends on how you gate: - **Not gating CI, or only `--min-score`**: the file is a build artifact, so ignore it: ```bash \[.gitignore] evlog.map.json ``` :br`--no-write` keeps the job from producing one at all. - **Gating with `--baseline`**: track it, like a lockfile. Regenerate it with `evlog map`, review it in the diff, never hand-edit it. The ratchet compares against the committed copy, and `git:` can only read a file that was committed. A tracked map is readable, and that is a feature, not a cost: the diff of a pull request shows exactly which entry points changed class, which is a useful thing to argue about. What it costs is churn on every run, since `generatedAt` changes each time. ## Monorepos `evlog map` scans one app at a time. Gate each one: ```yaml [.github/workflows/observability.yml] jobs: map: runs-on: ubuntu-latest strategy: matrix: app: [apps/web, apps/admin] steps: - uses: actions/checkout@v5 - uses: actions/setup-node@v5 with: node-version: 22 - run: npx @evlog/cli map --cwd ${{ matrix.app }} --min-score 80 --no-write ``` Different apps can carry different thresholds, which is usually what you want: the app that takes payments should be held higher than the marketing site. ## Next - [Rules](https://www.evlog.dev/cli/rules) — what a failing check means and how to fix it - [Scoring](https://www.evlog.dev/cli/scoring) — how the number you are gating on is calculated # evlog doctor `evlog doctor` answers "is evlog actually wired up here?" — the question you have when you followed the setup guide and nothing is showing up. ```bash [Terminal] evlog doctor ``` ```text [Output] pnpm workspace ENVIRONMENT │ ✓ node v24.18.0 │ ✓ project evlog-playground · pnpm · apps/playground │ ✓ stack nuxt EVLOG │ ✓ evlog v2.22.3 (workspace:*) │ ✓ logs 1 file · .evlog/logs 5 ok · 0 warn · 0 fail ``` ## What it checks | Check | Passes when | Warns or fails when | | --------- | ------------------------------------------------------------------------------------ | ----------------------------------------------------------------- | | `node` | Node 20 or later | Older than 20 — the CLI will not run reliably (fail) | | `project` | A `package.json` was resolved | None found above the working directory | | `stack` | A framework was detected and evlog is installed | A framework was detected but evlog is not wired to it | | `evlog` | `evlog` resolves from `node_modules` | Declared in `package.json` but not installed, or missing entirely | | `logs` | A local sink exists, or the fs drain is wired (the directory appears on first write) | Never — the check is omitted when no local sink is configured | In a workspace, the header names the workspace kind and `project` shows which package was resolved — the fastest way to notice you are diagnosing the repo root instead of your app. A local sink is optional: the [fs drain](https://www.evlog.dev/integrate/adapters/self-hosted/fs) writes under `.evlog/logs` and creates the directory on first write, so a wired drain counts as a sink before any event. A project with no local drain gets no `logs` check at all. ## Exit code `0` when nothing failed, `1` when any check failed. Warnings do not fail the command — a project that has not written logs yet is not broken. ## JSON ```bash [Terminal] evlog doctor --json | jq '.checks' ``` The payload carries the resolved project (`cwd`, `root`, `packageDir`, workspace kind, package name, detected stack), every check with its status, and the summary counts. Each warning and failure also carries a code from the CLI's own error catalog — `cli.EVLOG_NOT_FOUND`, `cli.EVLOG_DECLARED_NOT_INSTALLED`, `cli.NODE_TOO_OLD`, `cli.PROJECT_NO_PACKAGE` — each with a `why` and a `fix`, the same way [structured errors](https://www.evlog.dev/learn/structured-errors) work in your app. ## Options | Flag | What it does | | ------------- | ---------------------------------------- | | `--cwd ` | Diagnose another directory | | `--json` | Machine-readable output on stdout | | `--debug` | Print the steps the command went through | | `--noHeader` | Skip the branded header | ## Next - [Installation](https://www.evlog.dev/start/installation) — wire evlog into your framework - [`evlog map`](https://www.evlog.dev/cli/map) — once it is installed, see where it is missing # CLI telemetry The evlog CLI records one anonymous wide event per run, so we know which commands people use and which ones error. It is the same [telemetry toolkit](https://www.evlog.dev/use-cases/telemetry/overview) evlog ships for your own CLIs, pointed at itself. ::callout{color="neutral" icon="i-lucide-info"} This page is about the CLI's own telemetry. If you are instrumenting a CLI **you** are building, you want [Telemetry](https://www.evlog.dev/use-cases/telemetry/overview) instead. :: ## What `init` records `evlog init` asks more questions than the other commands, so it records the answers — which lets the flow lead with the options people actually pick and drop the ones nobody does. | Recorded | Example | | --------------------------------------------------- | ------------------------------------------------- | | Framework, development sink, sampling preset | `nuxt`, `fs`, `balanced` | | One flag per destination, extra and enricher chosen | `initProdAxiom: true` | | Counts | files written, manual steps left, doctor failures | | Whether an offer had anything behind it | `initHadRepeatedErrors: true` | Every string is an id from the CLI's own catalog and is checked against an allowlist before it is sent — an undeclared value is dropped rather than transmitted. **Your service name is never recorded**, nor package names, paths, or anything read out of your source. The last row says only whether the scan found something, never what it found. ## What `map` records `evlog map` records the shape of a scan: the score and its grade, how many entry points there were and how many are instrumented, partial, dark or exempt, which framework was detected, and whether a `--min-score` or `--baseline` gate failed the run. | Recorded | Example | | ------------------------------------------------------------- | -------------------------------------------------- | | Score and grade | `mapScore: 72`, `mapGrade: good` | | Entry point counts by coverage | `mapEntryPoints: 34`, `mapDark: 9` | | Per kind: entry points and dark entry points | `mapKindPage: 12`, `mapDarkPage: 3` | | Per sensitivity: money / auth / PII entry points, and dark | `mapSensitiveMoney: 2`, `mapDarkMoney: 1` | | Per rule: how many entry points failed it, how many waived it | `mapFailWideEvent: 6`, `mapSuppressedWideEvent: 2` | | Which gate ran, and whether it failed | `mapGate: baseline`, `mapGateFailed: true` | Rule ids are the CLI's own closed set and are already public. Everything read out of your source is a count: **no route path, no file name, no project name, no snippet**. Kinds are the CLI's own closed set too, and a kind absent from the project is omitted rather than sent as zero, the same convention as a flag left at its default. The dark count for a kind that is present but fully covered is `0`, so `mapKindPage: 12, mapDarkPage: 3` always reads as "12 pages, 3 dark", never as a missing number. Sensitivity is a heuristic classification (imports and path terms, see the report's `$` / `A` / `o` markers), so the per-sensitivity counts are an estimate of what the classifier found, not ground truth. ## See what is collected ```bash [Terminal] evlog telemetry status ``` The command prints whether telemetry is on, where its data directory is, and the full disclosure table — every field, its type, and what it is for. The disclosure is generated from the code that sends the event, so it cannot drift from what actually goes over the wire. ```text [Output] Telemetry: enabled (preference: enabled) Data directory: ~/.config/evlog-cli/telemetry ``` ## What is in an event One event per run, with the command name, how long it took, whether it succeeded, and the error code when it did not. Alongside that: the Node version, the OS and architecture, whether the run was in CI and on which provider, whether stdout is a TTY, and whether an AI coding agent was driving it. Flags are recorded as the ones you actually passed. Booleans and numbers keep their value; a string value is recorded as `` rather than by content, unless the flag is explicitly allowlisted. Positional arguments are never recorded at all, and a flag left at its default is not recorded either — `evlog map` on its own sends an empty `flags` object. The machine id is a hash, and it is omitted entirely in ephemeral CI. ::callout{color="neutral" icon="i-lucide-lock"} No file paths, no source code, no project names, no route paths, no argument values. :: ## Turn it off Any one of these is enough: ```bash [Terminal] # persisted preference evlog telemetry disable # per-run, or in a shell profile EVLOG_TELEMETRY=0 evlog map # the cross-tool standard, respected everywhere export DO_NOT_TRACK=1 ``` `evlog telemetry disable` also purges anything that had been queued and not yet delivered. `evlog telemetry enable` turns it back on. ## Inspect before trusting To see exactly what a run would send, without sending it: ```bash [Terminal] EVLOG_TELEMETRY_DEBUG=1 evlog map ``` The would-be payload is printed to stderr. ## Commands | Command | What it does | | ------------------------- | ------------------------------------------------------ | | `evlog telemetry status` | Current state, data directory, and the full disclosure | | `evlog telemetry enable` | Enable anonymous usage telemetry | | `evlog telemetry disable` | Disable it and purge undelivered data | # evlog agents Wiring evlog into an app is half the job. The other half is the assistant writing the handlers, which keeps reaching for `console.log` and `throw new Error(...)` until something in the repository tells it not to. `evlog agents` writes that something. ```bash [Terminal] evlog agents ``` ```text [Output] nuxt ✓ created AGENTS.md ✓ created CLAUDE.md ✓ installed the evlog skills ran npx --yes skills add https://www.evlog.dev ``` ## What it writes | File | What happens | | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `AGENTS.md` | Created if missing. Otherwise the evlog block is replaced in place, between `` and `` — everything outside those markers is left exactly as it was. | | `CLAUDE.md` | Created as a one-line `@AGENTS.md` if missing. If it exists and already mentions `AGENTS.md`, it is left alone. | The block is short on purpose — it is loaded into every agent turn. It states the rules (one wide event per operation, grouped context, structured errors, audit on sensitive actions, what never gets logged) and points at the skills for the depth. The logger accessor named in it follows your framework: `useLogger(event)` on Nuxt and Nitro, `useLogger()` from your `lib/evlog.ts` on Next.js, `req.context.log` on TanStack Start. When no framework is detected the block is still written with a generic accessor — the conventions apply just as well on Express or Hono. ## The skills are not ours to install The [agent skills](https://www.evlog.dev/reference/agent-skills) come from [`npx skills`](https://github.com/vercel-labs/skills){rel=""nofollow""}, which `evlog agents` shells out to — the same way [`evlog init`](https://www.evlog.dev/cli/init) runs your package manager instead of unpacking a tarball itself. That is deliberate. Every agent reads a different directory (`.claude/skills`, `.agents/skills`, `.codex/skills`, …), and the skills CLI already resolves them per agent, symlinks a canonical copy, and supports a global scope. It also keeps no manifest, so any copy we wrote behind its back would be a second one it could never update. Delegating means one copy, and `npx skills update` / `remove` / `list` keep working on it. Before running anything, `evlog agents` looks for evlog skills already installed — any agent, project-local or global — and leaves them alone if it finds them: ```text [Output] · AGENTS.md is up to date · CLAUDE.md already points at AGENTS.md ✓ skills already installed · .agents/skills, .claude/skills npx skills update to refresh them ``` ::callout{color="neutral" icon="i-lucide-info"} Interactively, the skills CLI asks its own questions — which agents to install for, project or global. That question is its to ask, so `evlog agents` hands over the terminal rather than answering on your behalf. Non-interactive runs ( `--json` , `--yes` , CI, no TTY) pass `--yes` so nothing blocks on a prompt nobody will answer. :: ### What you are trusting Delegating means running third-party code, so it is worth being explicit about the trust model. - **What runs.** Exactly one command, never assembled from anything you did not pass: `npx --yes skills add `, plus a trailing `--yes` when the run is non-interactive and `--skill` / `--global` when you asked for them. Interactively it is shown twice — in the plan you confirm, and again as the step starts. Non-interactively (`--json`, `--yes`, CI, no TTY) it appears in the report the run prints afterward. Either way the string is the command argument for argument, so re-typing it reproduces the run exactly; `--dry-run` shows it without running anything. - **Not pinned.** `npx` resolves the latest [`skills`](https://www.npmjs.com/package/skills){rel=""nofollow""} at run time. That is the point — skills guidance tracks the docs site, not a CLI release — but it does mean the version you get today is not the one you got last month. If your policy needs a fixed version, use `--no-skills` and run your own pinned `npx skills@ add https://www.evlog.dev`. - **`--source` is validated.** It must be a plain `http:` / `https:` origin — letters, digits, and `. _ ~ : / -` only, so no query string and no shell metacharacters — and `--skills` entries must be lowercase dashed names. On Windows the spawn needs a shell to resolve `npx` (Node refuses to run a `.cmd` without one), so both are checked before anything is spawned rather than trusted down the chain. - **Nothing is spawned without a decision.** `--no-skills` skips it entirely, and the interactive flow will not reach the command until you confirm the plan. If none of that fits your policy, `evlog agents --no-skills` still writes `AGENTS.md` and `CLAUDE.md` — those never touch the network — and you can install the skills however you prefer. ## Safe to re-run Running it again refreshes the block against the current CLI. Anything already identical is reported rather than rewritten, so a second run leaves the working tree clean. Run it after upgrading `@evlog/cli`. ## Flags | Flag | What it does | | ----------------- | ---------------------------------------------------------------------------------------------- | | `--skills ` | Comma-separated skill names, passed to `npx skills add --skill` (default: all of them) | | `--no-skills` | Still write `AGENTS.md` and `CLAUDE.md`; skip only the skill installation — nothing is spawned | | `--global`, `-g` | Install the skills for every project instead of just this one | | `--source ` | Where the skills are published — a plain http(s) origin (default: `https://www.evlog.dev`) | | `--dry-run` | Show the plan without writing or running anything | | `--yes`, `-y` | Apply without confirming | ```bash [Terminal] evlog agents --skills review-logging-patterns evlog agents --global evlog agents --no-skills evlog agents --dry-run ``` If the skills CLI fails — no network, no `npx` — the block is still on disk and the command reports the failure and exits 1. The `AGENTS.md` block never needs the network. ## As part of `evlog init` [`evlog init`](https://www.evlog.dev/cli/init) offers this as its last question. The `AGENTS.md` and `CLAUDE.md` writes land in the same plan as the evlog wiring — one list, one confirmation — and the skills run alongside the package-manager install. Skip it with `--no-agents`: ```bash [Terminal] evlog init --no-agents ``` ## Next Steps - [Agent Skills](https://www.evlog.dev/reference/agent-skills) — what each skill teaches - [`evlog map`](https://www.evlog.dev/cli/map) — score what your agent still has not covered - [Wide Events](https://www.evlog.dev/learn/wide-events) — the conventions the block is summarising # Integrate evlog Once you understand the [logging modes](https://www.evlog.dev/learn/overview), there are two questions left to answer before evlog is wired into your stack: 1. **Where does the logger live?** → Pick a [framework integration](https://www.evlog.dev/integrate/frameworks/overview). Each integration creates the logger on every request, attaches it to the context, and emits the wide event when the response ends. You don't manage the lifecycle. 2. **Where do events go?** → Pick one or more [adapters](https://www.evlog.dev/integrate/adapters/overview). Adapters ship the wide event to an external observability platform — Axiom, Datadog, Sentry, PostHog, OTLP-compatible systems, file system, NuxtHub. The two are independent. A Nuxt app can drain to Axiom, an Express app can drain to OTLP + Sentry simultaneously, a SvelteKit app can drain to a local file in dev and to Datadog in production. Once something is wired, try [`evlog map`](https://www.evlog.dev/cli/map) (`npx @evlog/cli map`) to see which entry points are still dark — or [`evlog doctor`](https://www.evlog.dev/cli/doctor) if nothing is showing up. Separate early package; optional, worth a run. ::card-group :::card --- color: neutral icon: i-lucide-layers title: Frameworks (16) to: https://www.evlog.dev/integrate/frameworks/overview --- Nuxt, Next.js, SvelteKit, Nitro, TanStack Start, NestJS, Express, Hono, Fastify, Elysia, React Router, Cloudflare Workers, AWS Lambda, Astro, Standalone, Custom integration. ::: :::card --- color: neutral icon: i-custom-plug title: Adapters (9) to: https://www.evlog.dev/integrate/adapters/overview --- Cloud destinations (Axiom, OTLP, HyperDX, PostHog, Sentry, Better Stack, Datadog) and self-hosted (file system, NuxtHub). ::: :: ## Don't see your framework? Check [Custom Framework Integration](https://www.evlog.dev/extend/custom-framework). The `evlog/toolkit` package exposes the same building blocks every built-in integration uses — most HTTP frameworks need \~30 lines of glue. ## Don't see your destination? Check [Custom Drains](https://www.evlog.dev/extend/custom-drains). `defineHttpDrain` from `evlog/toolkit` ships any backend with a single function and gives you batching, retries, timeouts, and identity headers for free. # Adapters Overview Adapters let you send logs to external observability platforms. evlog provides built-in adapters for popular services, and you can create custom adapters for any destination. :drain-fan-out ## How Adapters Work Adapters receive a `DrainContext` after each request completes and send the data to an external service. The drain runs in **fire-and-forget** mode, meaning it never blocks the HTTP response. How you wire an adapter depends on your framework: ::code-group ```typescript [Nuxt / Nitro] // server/plugins/evlog-drain.ts import { createAxiomDrain } from 'evlog/axiom' export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createAxiomDrain()) }) ``` ```typescript [Next.js] // lib/evlog.ts import { createEvlog } from 'evlog/next' import { createAxiomDrain } from 'evlog/axiom' export const { withEvlog, useLogger, log, createError } = createEvlog({ service: 'my-app', drain: createAxiomDrain(), }) ``` ```typescript [Hono] import { createAxiomDrain } from 'evlog/axiom' app.use(evlog({ drain: createAxiomDrain() })) ``` ```typescript [Express] import { createAxiomDrain } from 'evlog/axiom' app.use(evlog({ drain: createAxiomDrain() })) ``` ```typescript [Fastify] import { createAxiomDrain } from 'evlog/axiom' await app.register(evlog, { drain: createAxiomDrain() }) ``` ```typescript [Elysia] import { createAxiomDrain } from 'evlog/axiom' app.use(evlog({ drain: createAxiomDrain() })) ``` ```typescript [NestJS] import { createAxiomDrain } from 'evlog/axiom' EvlogModule.forRoot({ drain: createAxiomDrain() }) ``` ```typescript [Standalone] import { createAxiomDrain } from 'evlog/axiom' initLogger({ drain: createAxiomDrain() }) ``` :: ::callout{color="info" icon="i-lucide-cloud"} **Serverless Support:** On Cloudflare Workers and Vercel Edge, evlog automatically uses `waitUntil()` to ensure drains complete before the runtime terminates. No additional configuration needed. :: ## Available Adapters ::card-group :::card --- icon: i-custom-axiom title: Axiom to: https://www.evlog.dev/integrate/adapters/cloud/axiom --- Send logs to Axiom for powerful querying and dashboards. ::: :::card --- icon: i-simple-icons-posthog title: PostHog to: https://www.evlog.dev/integrate/adapters/cloud/posthog --- Send logs to PostHog Logs for structured logging and observability. ::: :::card --- icon: i-simple-icons-sentry title: Sentry to: https://www.evlog.dev/integrate/adapters/cloud/sentry --- Send structured logs to Sentry Logs for high-cardinality querying. ::: :::card --- icon: i-simple-icons-betterstack title: Better Stack to: https://www.evlog.dev/integrate/adapters/cloud/better-stack --- Send logs to Better Stack for log management and alerting. ::: :::card --- icon: i-simple-icons-datadog title: Datadog to: https://www.evlog.dev/integrate/adapters/cloud/datadog --- Send logs to Datadog Logs via the native HTTP intake API. ::: :::card --- icon: i-simple-icons-grafana title: Grafana Loki to: https://www.evlog.dev/integrate/adapters/hybrid/loki --- Push logs to self-hosted, multi-tenant, or Grafana Cloud Loki. ::: :::card --- icon: i-simple-icons-clickhouse title: ClickHouse to: https://www.evlog.dev/integrate/adapters/hybrid/clickhouse --- Insert logs into ClickHouse for fast aggregation over huge volumes. ::: :::card --- icon: i-simple-icons-opentelemetry title: OTLP to: https://www.evlog.dev/integrate/adapters/hybrid/otlp --- OpenTelemetry Protocol for Grafana, Datadog, Honeycomb, and more. ::: :::card --- icon: i-custom-hyperdx title: HyperDX to: https://www.evlog.dev/integrate/adapters/hybrid/hyperdx --- Send logs to HyperDX via OTLP/HTTP using their documented ingest endpoint and API key. ::: :::card --- icon: i-lucide-hard-drive title: File System to: https://www.evlog.dev/integrate/adapters/self-hosted/fs --- Write logs to local NDJSON files for debugging and AI agent integration. ::: :::card --- icon: i-simple-icons-nuxt title: NuxtHub to: https://www.evlog.dev/integrate/adapters/self-hosted/nuxthub --- Self-hosted log storage in your NuxtHub database with automatic retention. ::: :::card --- icon: i-lucide-cpu title: Memory to: https://www.evlog.dev/integrate/adapters/self-hosted/memory --- In-memory ring buffer that works in any runtime, including Cloudflare Workers. ::: :::card --- icon: i-lucide-code title: Custom to: https://www.evlog.dev/extend/custom-drains --- Build your own adapter for any destination. ::: :::card --- icon: i-lucide-globe title: HTTP to: https://www.evlog.dev/extend/drain-pipeline --- Send client logs to your server over HTTP without framework coupling. ::: :::card --- icon: i-lucide-workflow title: Pipeline to: https://www.evlog.dev/extend/drain-pipeline --- Batch events, retry on failure, and handle buffer overflow. ::: :: ## Standalone Usage In plain TypeScript or Bun scripts (no HTTP framework), use the `drain` option in `initLogger`. Every emitted event is drained automatically. ```typescript [index.ts] import type { DrainContext } from 'evlog' import { initLogger, log, createRequestLogger } from 'evlog' import { createAxiomDrain } from 'evlog/axiom' import { createDrainPipeline } from 'evlog/pipeline' const pipeline = createDrainPipeline() const drain = pipeline(createAxiomDrain()) initLogger({ env: { service: 'my-script' }, drain, }) log.info({ action: 'job_started' }) // drained automatically const reqLog = createRequestLogger({ method: 'POST', path: '/process' }) reqLog.set({ processed: 42 }) reqLog.emit() // drained automatically await drain.flush() ``` ::callout{color="neutral" icon="i-lucide-arrow-right"} See the full [bun-script example](https://github.com/hugorcd/evlog/tree/main/examples/bun-script){rel=""nofollow""} for a realistic batch processing script. :: ## Multiple Destinations Send logs to multiple services simultaneously by composing drains: ```typescript [src/index.ts] import { createAxiomDrain } from 'evlog/axiom' import { createOTLPDrain } from 'evlog/otlp' const axiom = createAxiomDrain() const otlp = createOTLPDrain() const drain = async (ctx) => { await Promise.allSettled([axiom(ctx), otlp(ctx)]) } ``` Then pass `drain` to your framework: ::code-group ```typescript [Nuxt / Nitro] // server/plugins/evlog-drain.ts export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', drain) }) ``` ```typescript [Next.js] // lib/evlog.ts import { createEvlog } from 'evlog/next' export const { withEvlog, useLogger, log, createError } = createEvlog({ service: 'my-app', drain, }) ``` ```typescript [Hono] app.use(evlog({ drain })) ``` ```typescript [Express] app.use(evlog({ drain })) ``` ```typescript [Fastify] await app.register(evlog, { drain }) ``` ```typescript [Elysia] app.use(evlog({ drain })) ``` ```typescript [NestJS] EvlogModule.forRoot({ drain }) ``` ```typescript [Standalone] initLogger({ drain }) ``` :: ## Drain Context Every adapter receives a `DrainContext` with: | Field | Type | Description | | --------- | ----------- | --------------------------------------------------- | | `event` | `WideEvent` | The complete log event with all accumulated context | | `request` | `object` | Request metadata (`method`, `path`, `requestId`) | | `headers` | `object` | Safe HTTP headers (sensitive headers are filtered) | ::callout{color="success" icon="i-lucide-shield-check"} **Security:** Sensitive headers ( `authorization` , `cookie` , `x-api-key` , etc.) are automatically filtered and never passed to adapters. :: ## Zero-Config Setup All adapters support automatic configuration via environment variables. No code changes needed when deploying to different environments. Each adapter reads from standard environment variables — the same names work in every framework: ```bash [.env] # Axiom AXIOM_API_KEY=xaat-xxx AXIOM_DATASET=my-logs # OTLP OTLP_ENDPOINT=https://otlp.example.com # HyperDX HYPERDX_API_KEY= # PostHog POSTHOG_API_KEY=phc_xxx # Sentry SENTRY_DSN=https://key@o0.ingest.sentry.io/123 # Better Stack BETTER_STACK_API_KEY=your-source-token # Datadog DD_API_KEY=your-api-key DD_SITE=datadoghq.eu # Grafana Loki LOKI_ENDPOINT=http://localhost:3100 # Grafana Cloud only: LOKI_USER=123456 LOKI_API_KEY=glc_xxx # ClickHouse CLICKHOUSE_ENDPOINT=http://localhost:8123 CLICKHOUSE_PASSWORD=your-password ``` Adapters auto-read from these variables, so just call `createXDrain()` with no arguments. ## Missing credentials Behavior depends on which API you call: | API | Missing credentials | | ----------------------------------- | ------------------------------------------------------------------------------ | | `create*Drain()` (factory) | `console.error` + drain becomes a no-op — the HTTP response is never blocked | | `sendTo*` / `sendBatchTo*` (direct) | Throws if required credentials are absent — caller must pass a complete config | This split is intentional: drains run fire-and-forget after each request; direct send helpers are for scripts and tests where a silent failure would hide misconfiguration. # Axiom Adapter [Axiom](https://axiom.co){rel=""nofollow""} is a cloud-native logging platform with powerful querying capabilities. The evlog Axiom adapter sends your wide events directly to Axiom datasets. ::prompt --- actions: - copy - cursor - claude description: Add the Axiom drain adapter icon: i-custom-axiom --- Add the Axiom drain adapter to send evlog wide events to Axiom. 1. Identify which framework I'm using and follow its evlog integration pattern 2. Install evlog if not already installed 3. Import createAxiomDrain from 'evlog/axiom' 4. Wire createAxiomDrain() into my framework's drain configuration 5. Set AXIOM\_API\_KEY and AXIOM\_DATASET environment variables in .env 6. Test by triggering a request and checking the Axiom dataset Adapter docs: {rel=""nofollow""} Framework setup: {rel=""nofollow""} :: ## Installation The Axiom adapter comes bundled with evlog: ```typescript [src/index.ts] import { createAxiomDrain } from 'evlog/axiom' ``` ## Quick Start ### 1. Get your Axiom credentials 1. Create an [Axiom account](https://app.axiom.co){rel=""nofollow""} 2. Create a dataset for your logs 3. Generate an API token with ingest permissions ### 2. Set environment variables ```bash [.env] AXIOM_API_KEY=xaat-your-token-here AXIOM_DATASET=your-dataset-name ``` ::callout{color="info" icon="i-lucide-info"} In Axiom's dashboard this credential is called an **API token** ( `xaat-...` ). evlog names the config field `apiKey` for consistency across adapters. Legacy `token` / `AXIOM_TOKEN` still work until the next major release. :: ### 3. Wire the drain to your framework ::code-group ```typescript [Nuxt / Nitro] // server/plugins/evlog-drain.ts import { createAxiomDrain } from 'evlog/axiom' export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createAxiomDrain()) }) ``` ```typescript [Next.js] // lib/evlog.ts import { createEvlog } from 'evlog/next' import { createAxiomDrain } from 'evlog/axiom' export const { withEvlog, useLogger, log, createError } = createEvlog({ service: 'my-app', drain: createAxiomDrain(), }) ``` ```typescript [Hono] import { createAxiomDrain } from 'evlog/axiom' app.use(evlog({ drain: createAxiomDrain() })) ``` ```typescript [Express] import { createAxiomDrain } from 'evlog/axiom' app.use(evlog({ drain: createAxiomDrain() })) ``` ```typescript [Fastify] import { createAxiomDrain } from 'evlog/axiom' await app.register(evlog, { drain: createAxiomDrain() }) ``` ```typescript [Elysia] import { createAxiomDrain } from 'evlog/axiom' app.use(evlog({ drain: createAxiomDrain() })) ``` ```typescript [NestJS] import { createAxiomDrain } from 'evlog/axiom' EvlogModule.forRoot({ drain: createAxiomDrain() }) ``` ```typescript [Standalone] import { createAxiomDrain } from 'evlog/axiom' initLogger({ drain: createAxiomDrain() }) ``` :: That's it! Your logs will now appear in Axiom. ## Configuration The adapter reads configuration from multiple sources (highest priority first): 1. **Overrides** passed to `createAxiomDrain()` 2. **Runtime config** at `runtimeConfig.axiom` (Nuxt/Nitro only) 3. **Environment variables** (`AXIOM_*`) ### Environment Variables | Variable | Description | | ---------------- | ----------------------------------------------------- | | `AXIOM_API_KEY` | Axiom API token with ingest permissions | | `AXIOM_DATASET` | Dataset name to ingest logs into | | `AXIOM_ORG_ID` | Organization ID (required for Personal Access Tokens) | | `AXIOM_EDGE_URL` | Edge base URL for ingest/query (for edge deployments) | | `AXIOM_URL` | API base URL (legacy/default ingest endpoint) | ### Runtime Config (Nuxt only) Configure via `nuxt.config.ts` for type-safe configuration: ```typescript [nuxt.config.ts] export default defineNuxtConfig({ runtimeConfig: { axiom: { apiKey: '', // Set via AXIOM_API_KEY dataset: '', // Set via AXIOM_DATASET }, }, }) ``` ### Override Options Pass options directly to override any configuration: ```typescript [server/plugins/evlog-drain.ts] const drain = createAxiomDrain({ dataset: 'production-logs', timeout: 10000, }) ``` ### Full Configuration Reference | Option | Type | Default | Description | | --------- | -------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `apiKey` | `string` | - | Axiom API token (required) | | `token` | `string` | - | **Deprecated.** Use `apiKey` instead | | `dataset` | `string` | - | Dataset name (required) | | `orgId` | `string` | - | Organization ID (for PAT tokens) | | `edgeUrl` | `string` | - | Edge URL for ingest. Uses `/v1/ingest/{dataset}` when no path is provided; custom paths are used as-is (trailing slash trimmed). Mutually exclusive with `baseUrl` | | `baseUrl` | `string` | `https://api.axiom.co` | API base URL (`/v1/datasets/{dataset}/ingest`), mutually exclusive with `edgeUrl` | | `timeout` | `number` | `5000` | Request timeout in milliseconds | ## Querying Logs in Axiom evlog sends structured wide events that are perfect for Axiom's APL query language: ```apl [Axiom APL queries] // Find slow requests ['your-dataset'] | where durationMs > 1000 | project timestamp, path, durationMs, status // Error rate by endpoint ['your-dataset'] | where level == "error" | summarize count() by path | order by count_ desc // Request volume over time ['your-dataset'] | summarize count() by bin(timestamp, 1h) | render timechart ``` ## Troubleshooting ### Missing dataset or apiKey error ```text [Console] [evlog/axiom] Missing dataset or apiKey. Set AXIOM_API_KEY/AXIOM_DATASET env vars or pass to createAxiomDrain() ``` Make sure your environment variables are set and the server was restarted after adding them. ### 401 Unauthorized Your token may be invalid or expired. Generate a new token in the Axiom dashboard with **Ingest** permissions. ### 403 Forbidden with PAT tokens Personal Access Tokens require an organization ID: ```bash [.env] AXIOM_ORG_ID=your-org-id ``` ## Direct API Usage For advanced use cases, you can use the lower-level functions: ```typescript [server/utils/axiom.ts] import { sendToAxiom, sendBatchToAxiom } from 'evlog/axiom' // Send a single event await sendToAxiom(event, { apiKey: 'xaat-xxx', dataset: 'logs', }) // Send multiple events in one request await sendBatchToAxiom(events, { apiKey: 'xaat-xxx', dataset: 'logs', }) ``` ## Next Steps - [OTLP Adapter](https://www.evlog.dev/integrate/adapters/hybrid/otlp) - Send logs via OpenTelemetry Protocol - [PostHog Adapter](https://www.evlog.dev/integrate/adapters/cloud/posthog) - Send logs to PostHog - [Custom Adapters](https://www.evlog.dev/extend/custom-drains) - Build your own adapter - [Best Practices](https://www.evlog.dev/reference/best-practices) - Security and production tips # PostHog Adapter [PostHog](https://posthog.com){rel=""nofollow""} is an open-source product analytics platform. The evlog PostHog adapter sends your wide events to [PostHog Logs](https://posthog.com/docs/logs){rel=""nofollow""} via the standard OTLP format, giving you a dedicated log viewer with filtering, search, and tail mode using your existing PostHog API key. ::prompt --- actions: - copy - cursor - claude description: Add the PostHog drain adapter icon: i-simple-icons-posthog --- Add the PostHog drain adapter to send evlog wide events to PostHog Logs. 1. Identify which framework I'm using and follow its evlog integration pattern 2. Install evlog if not already installed 3. Import createPostHogDrain from 'evlog/posthog' 4. Wire createPostHogDrain() into my framework's drain configuration 5. Set POSTHOG\_API\_KEY environment variable 6. Optionally set POSTHOG\_HOST for EU or self-hosted instances 7. Test by triggering a request and checking PostHog > Logs Adapter docs: {rel=""nofollow""} Framework setup: {rel=""nofollow""} :: ## Installation The PostHog adapter comes bundled with evlog: ```typescript [src/index.ts] import { createPostHogDrain } from 'evlog/posthog' ``` ## Quick Start ### 1. Get your PostHog project API key 1. Log in to your [PostHog dashboard](https://app.posthog.com){rel=""nofollow""} 2. Go to **Settings** > **Project** > **Project API Key** 3. Copy the key (starts with `phc_`) ### 2. Set environment variables ```bash [.env] POSTHOG_API_KEY=phc_your-project-api-key ``` ### 3. Wire the drain to your framework ::code-group ```typescript [Nuxt / Nitro] // server/plugins/evlog-drain.ts import { createPostHogDrain } from 'evlog/posthog' export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createPostHogDrain()) }) ``` ```typescript [Next.js] // lib/evlog.ts import { createEvlog } from 'evlog/next' import { createPostHogDrain } from 'evlog/posthog' export const { withEvlog, useLogger, log, createError } = createEvlog({ service: 'my-app', drain: createPostHogDrain(), }) ``` ```typescript [Hono] import { createPostHogDrain } from 'evlog/posthog' app.use(evlog({ drain: createPostHogDrain() })) ``` ```typescript [Express] import { createPostHogDrain } from 'evlog/posthog' app.use(evlog({ drain: createPostHogDrain() })) ``` ```typescript [Fastify] import { createPostHogDrain } from 'evlog/posthog' await app.register(evlog, { drain: createPostHogDrain() }) ``` ```typescript [Elysia] import { createPostHogDrain } from 'evlog/posthog' app.use(evlog({ drain: createPostHogDrain() })) ``` ```typescript [NestJS] import { createPostHogDrain } from 'evlog/posthog' EvlogModule.forRoot({ drain: createPostHogDrain() }) ``` ```typescript [Standalone] import { createPostHogDrain } from 'evlog/posthog' initLogger({ drain: createPostHogDrain() }) ``` :: That's it! Your wide events will now appear in PostHog Logs with full OTLP structure including severity levels, trace context, and structured attributes. ## Configuration The adapter reads configuration from multiple sources (highest priority first): 1. **Overrides** passed to `createPostHogDrain()` 2. **Runtime config** at `runtimeConfig.posthog` (Nuxt/Nitro only) 3. **Environment variables** (`POSTHOG_*`) ### Environment Variables | Variable | Description | | ----------------- | ---------------------------------------- | | `POSTHOG_API_KEY` | Project API key (starts with `phc_`) | | `POSTHOG_HOST` | PostHog host URL (for EU or self-hosted) | ### Runtime Config (Nuxt only) Configure via `nuxt.config.ts` for type-safe configuration: ```typescript [nuxt.config.ts] export default defineNuxtConfig({ runtimeConfig: { posthog: { apiKey: '', // Set via POSTHOG_API_KEY host: '', // Set via POSTHOG_HOST }, }, }) ``` ### Override Options Pass options directly to override any configuration: ```typescript [server/plugins/evlog-drain.ts] const drain = createPostHogDrain({ host: 'https://eu.i.posthog.com', timeout: 10000, }) ``` ### Full Configuration Reference | Option | Type | Default | Description | | ----------------- | -------------------- | -------------------------- | ------------------------------------------------------------------------------ | | `apiKey` | `string` | - | Project API key (required) | | `host` | `string` | `https://us.i.posthog.com` | PostHog host URL | | `distinctId` | `string` | - | Static person identifier for every event | | `distinctIdField` | `string` | `userId` | Event field holding the person identifier (dot path) | | `sessionIdField` | `string` | `sessionId` | Event field holding the PostHog session id (dot path) | | `recordShape` | `'json' | 'compact'` | `'json'` | Log record shape — see [below](https://www.evlog.dev/#choosing-a-record-shape) | | `timeout` | `number` | `5000` | Request timeout in milliseconds | | `retries` | `number` | `2` | Retry attempts on transient failures | ## How It Works Under the hood, `createPostHogDrain()` wraps the OTLP adapter's `sendBatchToOTLP()` with PostHog-specific defaults: - **Endpoint**: `{host}/i/v1/logs` (PostHog's OTLP log ingest endpoint) - **Auth**: `Authorization: Bearer {apiKey}` header - **Format**: Standard OTLP `ExportLogsServiceRequest` with severity levels, trace context, and structured attributes - **Identity**: `posthogDistinctId` and `sessionId` attributes, which is how PostHog joins a log to the rest of your project data ## Choosing a Record Shape PostHog treats log attributes as facets: you filter, break down, and alert on them. With the default `json` shape a nested field arrives as one serialized attribute — `ai` = `{"calls":2,"costUsd":0.0012}` — which PostHog can display but not chart. `compact` flattens those into `ai.calls` and `ai.costUsd`, and replaces the body with a one-line summary instead of repeating the whole event: ```typescript [server/plugins/evlog-drain.ts] const drain = createPostHogDrain({ recordShape: 'compact' }) ``` This is the recommended shape for PostHog. It also cuts what you send: Logs is billed per GB ingested, and the default shape transmits every field twice — once in the body, once in the attributes. ::callout{color="info" icon="i-lucide-info"} `compact` becomes the default in the next major. Set it on a new project now; switching later means rewriting the saved views and alerts built on the `json` shape. :: ## Linking Logs to People and Session Replays A log that carries a person identifier shows up on that person's profile in PostHog, under the **Logs** tab — no service-name guessing to find what a specific user hit. Carry a session id too and the log links to their session replay. PostHog reads both from log attributes: `posthogDistinctId` for the person, `sessionId` for the replay. The adapter fills them from your wide event, so this works as soon as your event carries the values: ```typescript [server/api/checkout.post.ts] const log = useLogger(event) log.set({ userId: user.id, // → posthogDistinctId, links to the person sessionId: body.sessionId, // → sessionId, links to the session replay }) ``` The session id comes from the frontend — read it with `posthog.get_session_id()` and send it along with the request: ```typescript [app/checkout.ts] import posthog from 'posthog-js' await $fetch('/api/checkout', { method: 'POST', body: { ...payload, sessionId: posthog.get_session_id() }, }) ``` ### Pointing at Your Own Fields When identity lives somewhere else on your events, point the adapter at it. Both options take a dot path: ```typescript [server/plugins/evlog-drain.ts] const drain = createPostHogDrain({ distinctIdField: 'user.id', sessionIdField: 'session.id', }) ``` For an [eve agent](https://www.evlog.dev/use-cases/eve), the caller principal is the identity eve itself routes on: ```typescript [agent/hooks/evlog.ts] const drain = createPostHogDrain({ distinctIdField: 'eve.caller.principalId' }) ``` A static `distinctId` overrides the field lookup entirely — use it for a backend that acts as one identity rather than on behalf of users. ::callout{color="info" icon="i-lucide-info"} PostHog matches the attribute value against every `distinct_id` it knows for a person, so any one of their identifiers works. The attribute key is configurable per project under **Settings** \> **Logs** — leave it at the default `posthogDistinctId` and this works out of the box. :: ## Regions PostHog offers US and EU cloud hosting. Set the `host` to match your region: | Region | Host | | ------------ | -------------------------- | | US (default) | `https://us.i.posthog.com` | | EU | `https://eu.i.posthog.com` | | Self-hosted | Your instance URL | ```bash [.env] # EU region POSTHOG_API_KEY=phc_xxx POSTHOG_HOST=https://eu.i.posthog.com ``` ## Querying Logs in PostHog Once your logs are flowing, use the **Logs** tab in PostHog to query them: 1. Go to **Logs** and filter by service, severity, or any structured attribute 2. Use the search bar to find specific log entries 3. Click on a log entry to see all structured attributes ## PostHog Events (Custom Events) If you prefer sending logs as PostHog custom events (e.g., for product analytics, cohorts, or funnels), use `createPostHogDrain()` with `mode: 'events'`: ```typescript [server/plugins/evlog-drain.ts] import { createPostHogDrain } from 'evlog/posthog' const drain = createPostHogDrain({ mode: 'events', eventName: 'server_request', distinctId: 'my-backend-service', }) ``` Then pass `drain` to your framework the same way as the default logs drain (see [Quick Start](https://www.evlog.dev/#quick-start) above). ::callout{color="info" icon="i-lucide-info"} Custom events count towards your PostHog event quota. PostHog Logs (the default `createPostHogDrain()` ) is significantly cheaper. :: ::callout{color="warning" icon="i-lucide-triangle-alert"} **Legacy:** `createPostHogEventsDrain()` is deprecated and re-routes to `createPostHogDrain({ mode: 'events' })` . It will be removed in the next major release. :: ### Events Configuration | Option | Type | Default | Description | | ----------------- | -------- | -------------------------- | ------------------------------------------------ | | `apiKey` | `string` | - | Project API key (required) | | `host` | `string` | `https://us.i.posthog.com` | PostHog host URL | | `eventName` | `string` | `evlog_wide_event` | PostHog event name | | `distinctId` | `string` | - | Static `distinct_id` for all events | | `distinctIdField` | `string` | `userId` | Event field holding the `distinct_id` (dot path) | | `timeout` | `number` | `5000` | Request timeout in milliseconds | ### Event Format evlog maps wide events to PostHog events: | evlog Field | PostHog Field | | -------------------------------------------- | ------------------------------ | | `config.distinctId` or `userId` or `service` | `distinct_id` (fallback chain) | | `timestamp` | `timestamp` | | `level` | `properties.level` | | `service` | `properties.service` | | `environment` | `properties.environment` | | All other fields | `properties.*` | ### Distinct ID Resolution The `distinct_id` follows a fallback chain: 1. **`config.distinctId`** - explicit override in `createPostHogDrain({ mode: 'events' })` 2. **`event.userId`** - or whatever `distinctIdField` points at, when it holds a string or a number 3. **`event.service`** - final fallback, sent as an anonymous event ### Identified vs Anonymous Events An event that resolves to a real person is an **identified** event: PostHog creates a person profile for it and attaches person properties. When no identifier resolves, the event is sent as **anonymous** — `$process_person_profile: false` — rather than piling every request onto one "person" named after your service. PostHog bills anonymous events at a lower rate and keeps them out of person profiles. ```typescript [server/plugins/evlog-drain.ts] // Identified: events carrying `userId` create and update a person profile const drain = createPostHogDrain({ mode: 'events' }) // Identified as one backend identity, whatever the request const service = createPostHogDrain({ mode: 'events', distinctId: 'checkout-worker' }) ``` ::callout{color="info" icon="i-lucide-info"} Anonymous events can be up to 4× cheaper than identified ones. Only set an identity on the events where you actually need per-person analysis. :: ### Logs vs Events | | `createPostHogDrain()` | `createPostHogDrain({ mode: 'events' })` | | -------------- | ------------------------------------ | ---------------------------------------- | | **Format** | OTLP Logs (`/i/v1/logs`) | PostHog Events (`/batch/`) | | **PostHog UI** | Logs viewer | Events explorer | | **Cost** | Lower (dedicated logs pipeline) | Higher (counts as events) | | **Best for** | Debugging, log search, observability | Product analytics, cohorts, funnels | You can use both drains simultaneously to get the best of both worlds: ```typescript [server/plugins/evlog-drain.ts] import { createPostHogDrain } from 'evlog/posthog' const logs = createPostHogDrain() const events = createPostHogDrain({ mode: 'events' }) const drain = async (ctx) => { await Promise.allSettled([logs(ctx), events(ctx)]) } ``` ## Troubleshooting ### Missing apiKey error ```text [Console] [evlog/posthog] Missing apiKey. Set POSTHOG_API_KEY env var or pass to createPostHogDrain() ``` Make sure your environment variable is set and the server was restarted after adding it. ### Events not appearing PostHog processes events asynchronously. There may be a short delay (typically under 1 minute) before events appear in the dashboard. 1. Check the server console for `[evlog/posthog]` error messages 2. Verify your API key is correct and starts with `phc_` 3. Confirm your `host` matches your PostHog region (US vs EU) ### Wrong region If you're on PostHog EU but using the default US host, event delivery will fail and the adapter will log errors (for example under `[evlog/posthog]`) to your server console. Set the correct host: ```bash [.env] POSTHOG_HOST=https://eu.i.posthog.com ``` ## Direct API Usage For advanced use cases, you can use the lower-level functions: ```typescript [server/utils/posthog.ts] import { sendToPostHog, sendBatchToPostHog } from 'evlog/posthog' // Send a single event to PostHog Logs (OTLP) await sendToPostHog(event, { apiKey: 'phc_xxx', }) // Send multiple events in one request await sendBatchToPostHog(events, { apiKey: 'phc_xxx', }) ``` For custom events, use the events-specific functions: ```typescript [server/utils/posthog.ts] import { sendToPostHogEvents, sendBatchToPostHogEvents, toPostHogEvent } from 'evlog/posthog' // Send a single custom event await sendToPostHogEvents(event, { apiKey: 'phc_xxx', }) // Send multiple custom events in one request await sendBatchToPostHogEvents(events, { apiKey: 'phc_xxx', }) // Convert event to PostHog format (for inspection) const posthogEvent = toPostHogEvent(event, { apiKey: 'phc_xxx' }) ``` ## Next Steps - [Axiom Adapter](https://www.evlog.dev/integrate/adapters/cloud/axiom) - Send logs to Axiom - [OTLP Adapter](https://www.evlog.dev/integrate/adapters/hybrid/otlp) - Send logs via OpenTelemetry Protocol - [Custom Adapters](https://www.evlog.dev/extend/custom-drains) - Build your own adapter - [Best Practices](https://www.evlog.dev/reference/best-practices) - Security and production tips # Sentry Adapter [Sentry](https://sentry.io){rel=""nofollow""} is an error tracking and performance monitoring platform. The evlog Sentry adapter sends your wide events as **Sentry Structured Logs**, visible in **Explore > Logs** in the Sentry dashboard with high-cardinality searchable attributes. ::prompt --- actions: - copy - cursor - claude description: Add the Sentry drain adapter icon: i-simple-icons-sentry --- Add the Sentry drain adapter to send evlog wide events to Sentry Logs. 1. Identify which framework I'm using and follow its evlog integration pattern 2. Install evlog if not already installed 3. Import createSentryDrain from 'evlog/sentry' 4. Wire createSentryDrain() into my framework's drain configuration 5. Set SENTRY\_DSN environment variable 6. Test by triggering a request and checking Sentry > Explore > Logs Adapter docs: {rel=""nofollow""} Framework setup: {rel=""nofollow""} :: ## Installation The Sentry adapter comes bundled with evlog: ```typescript [src/index.ts] import { createSentryDrain } from 'evlog/sentry' ``` ## Quick Start ### 1. Get your Sentry DSN 1. Create a [Sentry account](https://sentry.io){rel=""nofollow""} 2. Create a new project (Node.js or JavaScript) 3. Find your DSN in **Settings > Projects > [Your Project] > Client Keys (DSN)** ### 2. Set environment variables ```bash [.env] SENTRY_DSN=https://your-public-key@o0.ingest.sentry.io/your-project-id ``` ### 3. Wire the drain to your framework ::code-group ```typescript [Nuxt / Nitro] // server/plugins/evlog-drain.ts import { createSentryDrain } from 'evlog/sentry' export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createSentryDrain()) }) ``` ```typescript [Next.js] // lib/evlog.ts import { createEvlog } from 'evlog/next' import { createSentryDrain } from 'evlog/sentry' export const { withEvlog, useLogger, log, createError } = createEvlog({ service: 'my-app', drain: createSentryDrain(), }) ``` ```typescript [Hono] import { createSentryDrain } from 'evlog/sentry' app.use(evlog({ drain: createSentryDrain() })) ``` ```typescript [Express] import { createSentryDrain } from 'evlog/sentry' app.use(evlog({ drain: createSentryDrain() })) ``` ```typescript [Fastify] import { createSentryDrain } from 'evlog/sentry' await app.register(evlog, { drain: createSentryDrain() }) ``` ```typescript [Elysia] import { createSentryDrain } from 'evlog/sentry' app.use(evlog({ drain: createSentryDrain() })) ``` ```typescript [NestJS] import { createSentryDrain } from 'evlog/sentry' EvlogModule.forRoot({ drain: createSentryDrain() }) ``` ```typescript [Standalone] import { createSentryDrain } from 'evlog/sentry' initLogger({ drain: createSentryDrain() }) ``` :: That's it! Your logs will now appear in **Explore > Logs** in Sentry. ## Configuration The adapter reads configuration from multiple sources (highest priority first): 1. **Overrides** passed to `createSentryDrain()` 2. **Runtime config** at `runtimeConfig.sentry` (Nuxt/Nitro only) 3. **Environment variables** (`SENTRY_*`) ### Environment Variables | Variable | Description | | -------------------- | ------------------------- | | `SENTRY_DSN` | Sentry DSN (required) | | `SENTRY_ENVIRONMENT` | Environment name override | | `SENTRY_RELEASE` | Release version override | ### Runtime Config (Nuxt only) Configure via `nuxt.config.ts` for type-safe configuration: ```typescript [nuxt.config.ts] export default defineNuxtConfig({ modules: ['evlog/nuxt'], evlog: { sentry: { dsn: '', // Set via SENTRY_DSN environment: 'production', release: '1.0.0', }, }, }) ``` ### Override Options Pass options directly to override any configuration: ```typescript [server/plugins/evlog-drain.ts] const drain = createSentryDrain({ dsn: 'https://key@o0.ingest.sentry.io/123', tags: { team: 'backend' }, timeout: 10000, }) ``` ### Full Configuration Reference | Option | Type | Default | Description | | ------------- | ------------------------ | ----------------- | ------------------------------- | | `dsn` | `string` | - | Sentry DSN (required) | | `environment` | `string` | Event environment | Environment name | | `release` | `string` | Event version | Release version | | `tags` | `Record` | - | Additional attributes to attach | | `timeout` | `number` | `5000` | Request timeout in milliseconds | ## Log Transformation evlog wide events are converted to Sentry Logs using `toSentryLog()`: - **Level mapping**: evlog levels map directly (`debug`, `info`, `warn`, `error`) - **Severity numbers**: Follow the OpenTelemetry spec (`debug=5`, `info=9`, `warn=13`, `error=17`) - **Body**: Derived from the event's `message`, `action`, or `path` fields (first available) - **Attributes**: All wide event fields are sent as typed attributes (string, integer, double, boolean). Complex objects are serialized to JSON strings. - **Sentry attributes**: `sentry.environment` and `sentry.release` are set automatically - **Trace ID**: Uses `event.traceId` if available, otherwise generates a random one ## Querying Logs in Sentry evlog sends wide events as structured logs. In the Sentry dashboard: - **Explore > Logs**: View all evlog wide events with full attribute search - **Filter by attributes**: `service:my-app`, `level:error`, or any wide event field - **Trace correlation**: Logs are linked to traces via `trace_id` for cross-referencing ::callout{color="info" icon="i-lucide-info"} Sentry Structured Logs support high-cardinality attributes, making them a great fit for evlog's wide events. Every field in your wide event becomes a searchable attribute in Sentry. :: ## Troubleshooting ### Missing DSN error ```text [Console] [evlog/sentry] Missing DSN. Set SENTRY_DSN env var or pass to createSentryDrain() ``` Make sure your environment variable is set and the server was restarted after adding it. ### Invalid DSN If the DSN is malformed (missing public key or project ID), the adapter will throw an error. Verify your DSN format: ```text [Sentry DSN format] https://@/ ``` ### 401 Unauthorized Your DSN may be revoked or invalid. Generate a new DSN in **Settings > Projects > Client Keys (DSN)**. ## Direct API Usage For advanced use cases, you can use the lower-level functions: ```typescript [server/utils/sentry.ts] import { sendToSentry, sendBatchToSentry } from 'evlog/sentry' // Send a single event as a Sentry log await sendToSentry(event, { dsn: process.env.SENTRY_DSN!, }) // Send multiple events in one request await sendBatchToSentry(events, { dsn: process.env.SENTRY_DSN!, }) ``` ## Next Steps - [Axiom Adapter](https://www.evlog.dev/integrate/adapters/cloud/axiom) - Send logs to Axiom for querying and dashboards - [OTLP Adapter](https://www.evlog.dev/integrate/adapters/hybrid/otlp) - Send logs via OpenTelemetry Protocol - [PostHog Adapter](https://www.evlog.dev/integrate/adapters/cloud/posthog) - Send logs to PostHog - [Custom Adapters](https://www.evlog.dev/extend/custom-drains) - Build your own adapter # Better Stack Adapter [Better Stack](https://betterstack.com){rel=""nofollow""} is a DX-first log management platform with powerful search, alerting, and dashboards. The evlog Better Stack adapter sends your wide events to the Better Stack HTTP ingestion API. ::prompt --- actions: - copy - cursor - claude description: Add the Better Stack drain adapter icon: i-simple-icons-betterstack --- Add the Better Stack drain adapter to send evlog wide events to Better Stack. 1. Identify which framework I'm using and follow its evlog integration pattern 2. Install evlog if not already installed 3. Import createBetterStackDrain from 'evlog/better-stack' 4. Wire createBetterStackDrain() into my framework's drain configuration 5. Set BETTER\_STACK\_API\_KEY environment variable 6. Test by triggering a request and checking the Better Stack logs dashboard Adapter docs: {rel=""nofollow""} Framework setup: {rel=""nofollow""} :: ## Installation The Better Stack adapter comes bundled with evlog: ```typescript [src/index.ts] import { createBetterStackDrain } from 'evlog/better-stack' ``` ## Quick Start ### 1. Get your source token 1. Create a [Better Stack account](https://betterstack.com){rel=""nofollow""} 2. Go to **Telemetry > Sources** and create a new source 3. Copy the **Source Token** ### 2. Set environment variables ```bash [.env] BETTER_STACK_API_KEY=your-source-token-here ``` ::callout{color="info" icon="i-lucide-info"} In Better Stack's dashboard this credential is called a **source token** . evlog names the config field `apiKey` for consistency across adapters. The legacy `sourceToken` field still works until the next major release. :: ### 3. Wire the drain to your framework ::code-group ```typescript [Nuxt / Nitro] // server/plugins/evlog-drain.ts import { createBetterStackDrain } from 'evlog/better-stack' export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createBetterStackDrain()) }) ``` ```typescript [Next.js] // lib/evlog.ts import { createEvlog } from 'evlog/next' import { createBetterStackDrain } from 'evlog/better-stack' export const { withEvlog, useLogger, log, createError } = createEvlog({ service: 'my-app', drain: createBetterStackDrain(), }) ``` ```typescript [Hono] import { createBetterStackDrain } from 'evlog/better-stack' app.use(evlog({ drain: createBetterStackDrain() })) ``` ```typescript [Express] import { createBetterStackDrain } from 'evlog/better-stack' app.use(evlog({ drain: createBetterStackDrain() })) ``` ```typescript [Fastify] import { createBetterStackDrain } from 'evlog/better-stack' await app.register(evlog, { drain: createBetterStackDrain() }) ``` ```typescript [Elysia] import { createBetterStackDrain } from 'evlog/better-stack' app.use(evlog({ drain: createBetterStackDrain() })) ``` ```typescript [NestJS] import { createBetterStackDrain } from 'evlog/better-stack' EvlogModule.forRoot({ drain: createBetterStackDrain() }) ``` ```typescript [Standalone] import { createBetterStackDrain } from 'evlog/better-stack' initLogger({ drain: createBetterStackDrain() }) ``` :: That's it! Your logs will now appear in Better Stack. ## Configuration The adapter reads configuration from multiple sources (highest priority first): 1. **Overrides** passed to `createBetterStackDrain()` 2. **Runtime config** at `runtimeConfig.betterStack` (Nuxt/Nitro only) 3. **Environment variables** (`BETTER_STACK_*`) ### Environment Variables | Variable | Description | | ----------------------- | ------------------------------------ | | `BETTER_STACK_API_KEY` | Better Stack source token (required) | | `BETTER_STACK_ENDPOINT` | Custom ingestion endpoint | ### Runtime Config (Nuxt only) Configure via `nuxt.config.ts` for type-safe configuration: ```typescript [nuxt.config.ts] export default defineNuxtConfig({ runtimeConfig: { betterStack: { apiKey: '', // Set via BETTER_STACK_API_KEY }, }, }) ``` ### Override Options Pass options directly to override any configuration: ```typescript [server/plugins/evlog-drain.ts] const drain = createBetterStackDrain({ apiKey: 'my-key', timeout: 10000, }) ``` ### Full Configuration Reference | Option | Type | Default | Description | | ------------- | -------- | --------------------------------- | ------------------------------------ | | `apiKey` | `string` | - | Better Stack source token (required) | | `sourceToken` | `string` | - | **Deprecated.** Use `apiKey` instead | | `endpoint` | `string` | `https://in.logs.betterstack.com` | Ingestion endpoint | | `timeout` | `number` | `5000` | Request timeout in milliseconds | ## Log Transformation evlog wide events are transformed using `toBetterStackEvent()`: - **Timestamp**: `timestamp` is mapped to `dt` (Better Stack's expected ISO-8601 timestamp field) - **All other fields**: Spread as-is into the event body Better Stack accepts arbitrary JSON fields, so all your wide event context (level, service, action, user data, etc.) is automatically searchable. ## Querying Logs in Better Stack Better Stack provides a powerful log search interface: - **Live tail**: Stream logs in real time - **Full-text search**: Search across all fields - **Structured queries**: Filter by `level:error`, `service:my-app`, or any wide event field - **Dashboards**: Create custom dashboards from your wide event data - **Alerts**: Set up alerts based on log patterns or thresholds ## Troubleshooting ### Missing apiKey error ```text [Console] [evlog/better-stack] Missing apiKey. Set BETTER_STACK_API_KEY env var or pass to createBetterStackDrain() ``` Make sure your environment variable is set and the server was restarted after adding it. ### 401 Unauthorized Your source token may be invalid or revoked. Generate a new source token in **Telemetry > Sources** in the Better Stack dashboard. ### 403 Forbidden The source may be archived or deleted. Create a new source in Better Stack. ## Direct API Usage For advanced use cases, you can use the lower-level functions: ```typescript [server/utils/better-stack.ts] import { sendToBetterStack, sendBatchToBetterStack } from 'evlog/better-stack' // Send a single event await sendToBetterStack(event, { apiKey: process.env.BETTER_STACK_API_KEY!, }) // Send multiple events in one request await sendBatchToBetterStack(events, { apiKey: process.env.BETTER_STACK_API_KEY!, }) ``` ## Next Steps - [Axiom Adapter](https://www.evlog.dev/integrate/adapters/cloud/axiom) - Send logs to Axiom for querying and dashboards - [OTLP Adapter](https://www.evlog.dev/integrate/adapters/hybrid/otlp) - Send logs via OpenTelemetry Protocol - [Custom Adapters](https://www.evlog.dev/extend/custom-drains) - Build your own adapter # Datadog Adapter [Datadog](https://www.datadoghq.com){rel=""nofollow""} is a monitoring and security platform. The evlog Datadog adapter sends your wide events to [Datadog Logs](https://docs.datadoghq.com/logs/){rel=""nofollow""} using the **HTTP Logs intake API (v2)** with the `DD-API-KEY` header. For OpenTelemetry-based ingestion instead, see the [OTLP adapter](https://www.evlog.dev/integrate/adapters/hybrid/otlp). ::prompt --- actions: - copy - cursor - claude description: Add the Datadog drain adapter icon: i-simple-icons-datadog --- Add the Datadog drain adapter to send evlog wide events to Datadog Logs. 1. Identify which framework I'm using and follow its evlog integration pattern 2. Install evlog if not already installed 3. Import createDatadogDrain from 'evlog/datadog' 4. Wire createDatadogDrain() into my framework's drain configuration 5. Set DD\_API\_KEY (or DATADOG\_API\_KEY) and optionally DD\_SITE in .env 6. Test by triggering a request and checking Log Explorer in Datadog Adapter docs: {rel=""nofollow""} Framework setup: {rel=""nofollow""} :: ## Installation The Datadog adapter comes bundled with evlog: ```typescript [src/index.ts] import { createDatadogDrain } from 'evlog/datadog' ``` ## Quick Start ### 1. Get your API key 1. Open [Datadog Organization Settings → API Keys](https://app.datadoghq.com/organization-settings/api-keys){rel=""nofollow""} 2. Create or copy an API key with permission to submit logs ### 2. Set environment variables ```bash [.env] DD_API_KEY=your-api-key # Optional — defaults to datadoghq.com (US1) DD_SITE=datadoghq.eu ``` ### 3. Wire the drain to your framework ::code-group ```typescript [Nuxt / Nitro] // server/plugins/evlog-drain.ts import { createDatadogDrain } from 'evlog/datadog' export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createDatadogDrain()) }) ``` ```typescript [Next.js] // lib/evlog.ts import { createEvlog } from 'evlog/next' import { createDatadogDrain } from 'evlog/datadog' export const { withEvlog, useLogger, log, createError } = createEvlog({ service: 'my-app', drain: createDatadogDrain(), }) ``` ```typescript [Hono] import { createDatadogDrain } from 'evlog/datadog' app.use(evlog({ drain: createDatadogDrain() })) ``` ```typescript [Express] import { createDatadogDrain } from 'evlog/datadog' app.use(evlog({ drain: createDatadogDrain() })) ``` ```typescript [Fastify] import { createDatadogDrain } from 'evlog/datadog' await app.register(evlog, { drain: createDatadogDrain() }) ``` ```typescript [Elysia] import { createDatadogDrain } from 'evlog/datadog' app.use(evlog({ drain: createDatadogDrain() })) ``` ```typescript [NestJS] import { createDatadogDrain } from 'evlog/datadog' EvlogModule.forRoot({ drain: createDatadogDrain() }) ``` ```typescript [Standalone] import { createDatadogDrain } from 'evlog/datadog' initLogger({ drain: createDatadogDrain() }) ``` :: Wide events appear in **Logs → Explorer**. The adapter sets `ddsource` to `evlog` and `message` to a JSON string of the full wide event for easy JSON parsing in pipelines. ## Configuration The adapter reads configuration from multiple sources (highest priority first): 1. **Overrides** passed to `createDatadogDrain()` 2. **Runtime config** at `runtimeConfig.datadog` or `runtimeConfig.evlog.datadog` (Nuxt/Nitro) 3. **Environment variables** — see table below ### Environment Variables | Variable | Description | | ------------------ | ----------------------------------------------------------------------------------------------- | | `DD_API_KEY` | Datadog API key (required). Also: `DATADOG_API_KEY` | | `DD_SITE` | Site hostname (e.g. `datadoghq.com`, `datadoghq.eu`, `us3.datadoghq.com`). Also: `DATADOG_SITE` | | `DATADOG_LOGS_URL` | Full intake URL — overrides URL derived from `site` | ### Runtime Config (Nuxt only) ```typescript [nuxt.config.ts] export default defineNuxtConfig({ runtimeConfig: { datadog: { apiKey: '', // Set via DD_API_KEY or DATADOG_API_KEY site: 'datadoghq.eu', }, }, }) ``` ### Override Options ```typescript [server/plugins/evlog-drain.ts] const drain = createDatadogDrain({ apiKey: '***', site: 'us5.datadoghq.com', timeout: 10000, }) ``` ### Full Configuration Reference | Option | Type | Default | Description | | ----------- | -------- | --------------- | ----------------------------------------------- | | `apiKey` | `string` | — | Datadog API key (required) | | `site` | `string` | `datadoghq.com` | Site for intake host `http-intake.logs.${site}` | | `intakeUrl` | `string` | from `site` | Full `POST` URL for `/api/v2/logs` | | `timeout` | `number` | `5000` | Request timeout (ms) | | `retries` | `number` | `2` | Retries on transient failures | ## Log shape Each wide event becomes one Datadog log with: - **`message`** — short one-line summary for the list view (e.g. `ERROR GET /api/checkout (400)`), built with `formatDatadogMessageLine`. Easier to scan than a full JSON blob in Live Tail. - **`evlog`** — full wide event as a **JSON object** (not a string). Numeric HTTP **`status`** fields anywhere in the tree are renamed to **`httpStatusCode`** so they never clash with Datadog’s reserved severity `status`. - **`dd`** — `{ trace_id, span_id }` when the event carries trace context. See [Trace correlation](https://www.evlog.dev/#trace-correlation). - **`service`**, **`status`** (Datadog severity — drives Live Tail color), **`ddsource`**: `evlog`, **`ddtags`**: `env:…` and optional `version:…` - **`timestamp`**: Unix milliseconds from `WideEvent.timestamp` **Severity (`status`)** at intake root is computed by the adapter from the wide event’s **`level`** and HTTP **`status`** (`resolveDatadogLogStatus` in `evlog/datadog`). Business-only fields on **HTTP 200** stay **`info`** unless you call **`log.error()`**. For advanced use, `sanitizeWideEventForDatadog(event)` returns only the sanitized object you would store under `evlog`. ## Trace correlation Datadog links a log to a trace through the reserved **`dd.trace_id`** / **`dd.span_id`** attributes at the **root** of the payload. Nested copies (`@evlog.traceId`) are searchable but do not correlate on their own — that takes a [Trace Id Remapper](https://docs.datadoghq.com/logs/log_configuration/processors/trace_remapper/){rel=""nofollow""} in a Datadog log pipeline, configuration living outside your codebase. The adapter lifts `event.traceId` and `event.spanId` into a root `dd` block instead, so correlation works with no pipeline setup: ```json { "message": "ERROR GET /api/checkout (400)", "evlog": { "traceId": "4bf92f35…", "spanId": "00f067aa…", "…": "full wide event" }, "dd": { "trace_id": "4bf92f35…", "span_id": "00f067aa…" }, "service": "my-app", "status": "error", "ddsource": "evlog" } ``` The nested copy under `evlog` stays, so existing `@evlog.*` facets and dashboards keep working. Only **non-empty strings** are lifted — an empty or non-string `traceId` / `spanId` is skipped rather than sent as an id Datadog would fail to resolve, and the `dd` key is absent when neither id survives that check. Those fields are populated by `createTraceContextEnricher`, included in [`createDefaultEnrichers()`](https://www.evlog.dev/use-cases/enrichers) — it parses the incoming W3C `traceparent` header into `event.traceId` / `event.spanId`: ```typescript [server/plugins/evlog-enrich.ts] import { createDefaultEnrichers } from 'evlog/enrichers' const enrich = createDefaultEnrichers() export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:enrich', enrich) }) ``` If your ids come from somewhere else (a tracer SDK, a vendor header), set `event.traceId` / `event.spanId` yourself and the adapter picks them up the same way: ```typescript log.set({ traceId: tracer.scope().active()?.context().toTraceId() }) ``` `resolveDatadogTraceContext(event)` is exported from `evlog/datadog` if you need the same mapping in a custom drain. ## Querying in Datadog - **Log Explorer**: `source:evlog`, `service:your-app`, `status:error` - **Facets**: prefer `@evlog.path`, `@evlog.requestId`, `@evlog.level`, etc. — core fields are under **`evlog`**, not a JSON string in `message` - **Metrics**: log-based metrics on `@evlog.*` attributes - **Pipelines**: if you previously parsed a full JSON **string** inside `message`, move those facets to **`@evlog.*`**. The `message` field is now a short summary line only. ## Simple logs vs wide events Plain-text lines in Live Tail (e.g. “Form field is empty”) usually come from **`log.info('tag', 'msg')`** or similar, not from the **wide event** sent on **`emit()`**. Those lines go to the console (and any Agent-based log stream), while the Datadog drain sends one structured log per wide event under **`source:evlog`**. ## Troubleshooting ### Missing API key ```text [Console] [evlog/datadog] Missing API key. Set DATADOG_API_KEY, DD_API_KEY... ``` Set `DD_API_KEY` (or unprefixed `DATADOG_API_KEY`) and restart the process. ### 403 Forbidden The API key may lack log ingestion permission or belong to the wrong organization. Verify the key in Datadog and try a new key. ### Wrong region / site If logs never appear, confirm `DD_SITE` matches your Datadog account (e.g. EU: `datadoghq.eu`). For a custom intake URL, set `DATADOG_LOGS_URL`. ## Direct API usage ```typescript [server/utils/datadog.ts] import { sendToDatadog, sendBatchToDatadog } from 'evlog/datadog' await sendToDatadog(event, { apiKey: process.env.DD_API_KEY!, site: process.env.DD_SITE, }) await sendBatchToDatadog(events, { apiKey: process.env.DD_API_KEY!, }) ``` ## Next Steps - [OTLP Adapter](https://www.evlog.dev/integrate/adapters/hybrid/otlp) — Send logs via OpenTelemetry (works with Datadog Agent / OTLP endpoint) - [Custom Adapters](https://www.evlog.dev/extend/custom-drains) — Build your own destination # Grafana Loki Adapter The Loki adapter pushes wide events to [Grafana Loki](https://grafana.com/docs/loki/latest/){rel=""nofollow""} via its push API. It works with a self-hosted single-tenant instance, a multi-tenant deployment, and Grafana Cloud. Each event is pushed as a **JSON log line** under a small, low-cardinality label set. That distinction matters in Loki: labels are indexed and billed by cardinality, while the log line is searched at query time. evlog labels only `service`, `environment`, and `level` by default, and leaves everything else — `requestId`, `path`, `user`, your custom fields — in the line, queryable with `| json`. ::prompt --- actions: - copy - cursor - claude description: Add the Grafana Loki drain adapter icon: i-simple-icons-grafana --- Add the Grafana Loki drain adapter to send evlog wide events to Loki. 1. Identify which framework I'm using and follow its evlog integration pattern 2. Install evlog if not already installed 3. Import createLokiDrain from 'evlog/loki' 4. Wire createLokiDrain() into my framework's drain configuration 5. Set LOKI\_ENDPOINT (e.g. {rel=""nofollow""}). For Grafana Cloud also set LOKI\_USER (instance ID) and LOKI\_API\_KEY 6. For multi-tenant self-hosted Loki, set LOKI\_TENANT\_ID instead 7. Test by triggering a request and querying {service="my-app"} in Grafana Adapter docs: {rel=""nofollow""} Framework setup: {rel=""nofollow""} :: ## Installation The Loki adapter comes bundled with evlog: ```typescript [src/index.ts] import { createLokiDrain } from 'evlog/loki' ``` ## Quick Start Set `LOKI_ENDPOINT` and wire the drain into your framework. ::code-group ```typescript [Nuxt / Nitro] // server/plugins/evlog.ts import { createLokiDrain } from 'evlog/loki' export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createLokiDrain()) }) ``` ```typescript [Hono] import { Hono } from 'hono' import { evlog } from 'evlog/hono' import { createLokiDrain } from 'evlog/loki' const app = new Hono() app.use(evlog({ drain: createLokiDrain() })) ``` ```typescript [Express] import express from 'express' import { evlog } from 'evlog/express' import { createLokiDrain } from 'evlog/loki' const app = express() app.use(evlog({ drain: createLokiDrain() })) ``` ```typescript [Fastify] import Fastify from 'fastify' import { evlog } from 'evlog/fastify' import { createLokiDrain } from 'evlog/loki' const app = Fastify() await app.register(evlog, { drain: createLokiDrain() }) ``` ```typescript [Elysia] import { Elysia } from 'elysia' import { evlog } from 'evlog/elysia' import { createLokiDrain } from 'evlog/loki' const app = new Elysia().use(evlog({ drain: createLokiDrain() })) ``` ```typescript [NestJS] import { Module } from '@nestjs/common' import { EvlogModule } from 'evlog/nestjs' import { createLokiDrain } from 'evlog/loki' @Module({ imports: [EvlogModule.forRoot({ drain: createLokiDrain() })], }) export class AppModule {} ``` ```typescript [Standalone] import { initLogger } from 'evlog' import { createLokiDrain } from 'evlog/loki' initLogger({ env: { service: 'my-app' }, drain: createLokiDrain(), }) ``` :: ## Configuration ### Environment variables | Variable | Required | Description | | ---------------- | -------- | ------------------------------------------------------------------------------------------------ | | `LOKI_ENDPOINT` | Yes | Base URL of the Loki instance, without the push path (`LOKI_URL` also accepted) | | `LOKI_API_KEY` | No | API token. Sent as Basic with `LOKI_USER`, otherwise as Bearer (`GRAFANA_API_KEY` also accepted) | | `LOKI_USER` | No | Grafana Cloud instance ID. Switches auth to Basic (`GRAFANA_USER` also accepted) | | `LOKI_TENANT_ID` | No | Tenant for multi-tenant self-hosted Loki, sent as `X-Scope-OrgID` | ### Priority Configuration is resolved highest to lowest: 1. Overrides passed to `createLokiDrain()` 2. `runtimeConfig.evlog.loki` (Nitro) 3. `runtimeConfig.loki` (Nitro) 4. Environment variables ### Options | Option | Type | Default | Description | | ------------- | ------------------------ | ------------------------------------- | ---------------------------------------------- | | `endpoint` | `string` | — | Base URL, without `/loki/api/v1/push` | | `apiKey` | `string` | — | API token | | `user` | `string` | — | Grafana Cloud instance ID (enables Basic auth) | | `tenantId` | `string` | — | `X-Scope-OrgID` for multi-tenant Loki | | `labelFields` | `string[]` | `['service', 'environment', 'level']` | Event fields promoted to Loki labels | | `labels` | `Record` | — | Static labels merged into every stream | | `timeout` | `number` | `5000` | Request timeout in ms | | `retries` | `number` | `2` | Retry attempts on transient failures | ## Deployment Loki runs either way, and the adapter is the same in both cases — only authentication differs. ### Self-hosted A single-tenant instance needs nothing but the endpoint: ```typescript [server/plugins/evlog.ts] createLokiDrain({ endpoint: 'http://localhost:3100' }) ``` ```bash [.env] LOKI_ENDPOINT=http://localhost:3100 ``` For a **multi-tenant** deployment, name the tenant — it is sent as `X-Scope-OrgID`: ```typescript [server/plugins/evlog.ts] createLokiDrain({ endpoint: 'http://loki.internal:3100', tenantId: 'team-checkout', }) ``` ```bash [.env] LOKI_ENDPOINT=http://loki.internal:3100 LOKI_TENANT_ID=team-checkout ``` If your instance sits behind an authenticating proxy, `apiKey` alone is sent as `Authorization: Bearer`. ### Grafana Cloud Grafana Cloud authenticates with your **instance ID** plus an access policy token, sent together as HTTP Basic: ```typescript [server/plugins/evlog.ts] createLokiDrain({ endpoint: 'https://logs-prod-eu-west-0.grafana.net', user: '123456', apiKey: process.env.GRAFANA_API_KEY, }) ``` ```bash [.env] LOKI_ENDPOINT=https://logs-prod-eu-west-0.grafana.net LOKI_USER=123456 LOKI_API_KEY=glc_xxx ``` ::callout{color="info" icon="i-lucide-info"} `user` is the **numeric instance ID** of the Loki datasource — find it under *Connections → Data sources → Loki* in your Grafana Cloud stack. It is not your account email. Using the wrong value is the usual cause of a `401` . :: ### Which auth applies | `user` | `apiKey` | `tenantId` | Header sent | | ------ | -------- | ---------- | ------------------------------------------ | | ✓ | ✓ | | `Authorization: Basic base64(user:apiKey)` | | | ✓ | | `Authorization: Bearer ` | | | | ✓ | `X-Scope-OrgID: ` | | | | | none — unauthenticated instance | `tenantId` is independent and can be combined with either auth mode. ## Labels and cardinality ::callout{color="warning" icon="i-lucide-triangle-alert"} Never promote a high-cardinality field to a label. Loki creates one stream per unique label combination — labelling `requestId` or `userId` will create millions of streams and degrade or break your instance. :: Add a label only for values you filter on and that have a bounded set: ```typescript [server/plugins/evlog.ts] createLokiDrain({ // `region` has a handful of values — safe labelFields: ['service', 'environment', 'level', 'region'], // Static labels for everything in this deployment labels: { cluster: 'prod-eu' }, }) ``` Everything else stays in the JSON line and is fully queryable — see below. ## Querying in Grafana Filter by label, then reach into the wide event with `| json`: ```logql {service="checkout", environment="production"} | json | status >= 500 ``` ```logql # Slow requests on one route {service="checkout"} | json | path="/api/orders" | durationMs > 1000 ``` ```logql # One request end to end {service="checkout"} | json | requestId="4a8ff3a8-..." ``` ```logql # Error rate per service sum by (service) (rate({environment="production", level="error"}[5m])) ``` ## Batching Pair the drain with a pipeline to push in batches rather than per request: ```typescript [server/plugins/evlog.ts] import { createDrainPipeline } from 'evlog/pipeline' import { createLokiDrain } from 'evlog/loki' import type { DrainContext } from 'evlog' const pipeline = createDrainPipeline({ batch: { size: 100, intervalMs: 5000 }, }) export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', pipeline(createLokiDrain())) }) ``` Events sharing a label set are grouped into a single Loki stream, and entries are sorted by timestamp — Loki rejects out-of-order pushes within a stream. ## Verify it locally Spin up Loki in Docker and push a real event — no cloud account needed: ```bash [Terminal] docker compose -f packages/evlog/test/e2e/docker-compose.yml up -d LOKI_ENDPOINT=http://localhost:3100 pnpm run test:e2e docker compose -f packages/evlog/test/e2e/docker-compose.yml down -v ``` The suite is a **round-trip**: it pushes events, queries them back through Loki's range API, and asserts the label set, the JSON log line and the timestamp survived. Without `LOKI_ENDPOINT` it skips itself with a visible label rather than passing silently. To eyeball the result instead, point any Grafana at `http://localhost:3100` and run `{service="evlog-e2e"}`. ## Troubleshooting **`Missing endpoint`** — `LOKI_ENDPOINT` is unset and no `endpoint` was passed. The drain logs `[evlog/loki] Missing endpoint` once and then does nothing, so requests are never blocked or failed. **`Loki API error: 401`** — On Grafana Cloud, check that `user` is the numeric **instance ID** of the Loki datasource, not your account email. **`Loki API error: 400` mentioning out-of-order entries** — Older Loki versions reject entries older than the most recent one in a stream. evlog sorts within each push; if you still hit this, enable `unordered_writes` in Loki or reduce the batch interval. **Nothing appears in Grafana** — Confirm the label set you are querying. Run `{service=~".+"}` first to see which streams arrived. ## Direct API usage Bypass the drain to push events yourself: ```typescript [scripts/backfill.ts] import { sendBatchToLoki, sendToLoki } from 'evlog/loki' await sendToLoki(event, { endpoint: 'http://localhost:3100' }) await sendBatchToLoki(events, { endpoint: 'http://localhost:3100' }) ``` `buildLokiPayload()`, `toLokiLabels()`, and `resolveLokiPushUrl()` are also exported for custom transports. ## Next steps - [Sampling](https://www.evlog.dev/learn/sampling) — control log volume before it reaches Loki - [Enrichers](https://www.evlog.dev/use-cases/enrichers) — add derived fields to every event - [Pipeline](https://www.evlog.dev/learn/pipeline) — batching, retries, and fan-out - [Adapters Overview](https://www.evlog.dev/integrate/adapters/overview) — all available destinations # ClickHouse Adapter The ClickHouse adapter inserts wide events over the [HTTP interface](https://clickhouse.com/docs/en/interfaces/http){rel=""nofollow""} in `JSONEachRow` format. It works with a local instance, a self-managed cluster, and ClickHouse Cloud. The default schema gives you **typed columns** for the fields you filter and aggregate on, plus the complete wide event as JSON in `data` — so no field is ever lost, and adding a field to your events never requires a migration. ::prompt --- actions: - copy - cursor - claude description: Add the ClickHouse drain adapter icon: i-simple-icons-clickhouse --- Add the ClickHouse drain adapter to store evlog wide events in ClickHouse. 1. Identify which framework I'm using and follow its evlog integration pattern 2. Install evlog if not already installed 3. Create the evlog\_events table using the CREATE TABLE from the adapter docs 4. Import createClickHouseDrain from 'evlog/clickhouse' 5. Wire createClickHouseDrain() into my framework's drain configuration 6. Set CLICKHOUSE\_ENDPOINT (e.g. {rel=""nofollow""}), plus CLICKHOUSE\_USER / CLICKHOUSE\_PASSWORD if authenticated 7. Test by triggering a request and running SELECT \* FROM evlog\_events ORDER BY timestamp DESC LIMIT 10 Adapter docs: {rel=""nofollow""} Framework setup: {rel=""nofollow""} :: ## Installation The ClickHouse adapter comes bundled with evlog: ```typescript [src/index.ts] import { createClickHouseDrain } from 'evlog/clickhouse' ``` ## Create the table Run this once before wiring the drain: ```sql [schema.sql] CREATE TABLE IF NOT EXISTS evlog_events ( timestamp DateTime64(3, 'UTC'), level LowCardinality(String), service LowCardinality(String), environment LowCardinality(String), request_id String, trace_id String, span_id String, method LowCardinality(String), path String, status Nullable(UInt16), duration String, duration_ms Nullable(UInt32), error_name String, error_message String, data String ) ENGINE = MergeTree PARTITION BY toYYYYMM(timestamp) ORDER BY (service, environment, timestamp) TTL toDateTime(timestamp) + INTERVAL 30 DAY; ``` ::callout{color="info" icon="i-lucide-info"} `ORDER BY (service, environment, timestamp)` matches how you'll filter most often. Adjust the `TTL` to your retention policy — drop the clause entirely to keep events forever. :: ## Quick Start ::code-group ```typescript [Nuxt / Nitro] // server/plugins/evlog.ts import { createClickHouseDrain } from 'evlog/clickhouse' export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createClickHouseDrain()) }) ``` ```typescript [Hono] import { Hono } from 'hono' import { evlog } from 'evlog/hono' import { createClickHouseDrain } from 'evlog/clickhouse' const app = new Hono() app.use(evlog({ drain: createClickHouseDrain() })) ``` ```typescript [Express] import express from 'express' import { evlog } from 'evlog/express' import { createClickHouseDrain } from 'evlog/clickhouse' const app = express() app.use(evlog({ drain: createClickHouseDrain() })) ``` ```typescript [Fastify] import Fastify from 'fastify' import { evlog } from 'evlog/fastify' import { createClickHouseDrain } from 'evlog/clickhouse' const app = Fastify() await app.register(evlog, { drain: createClickHouseDrain() }) ``` ```typescript [Elysia] import { Elysia } from 'elysia' import { evlog } from 'evlog/elysia' import { createClickHouseDrain } from 'evlog/clickhouse' const app = new Elysia().use(evlog({ drain: createClickHouseDrain() })) ``` ```typescript [NestJS] import { Module } from '@nestjs/common' import { EvlogModule } from 'evlog/nestjs' import { createClickHouseDrain } from 'evlog/clickhouse' @Module({ imports: [EvlogModule.forRoot({ drain: createClickHouseDrain() })], }) export class AppModule {} ``` ```typescript [Standalone] import { initLogger } from 'evlog' import { createClickHouseDrain } from 'evlog/clickhouse' initLogger({ env: { service: 'my-app' }, drain: createClickHouseDrain(), }) ``` :: ## Configuration ### Environment variables | Variable | Required | Description | | --------------------- | -------- | ---------------------------------------------------- | | `CLICKHOUSE_ENDPOINT` | Yes | HTTP interface URL (`CLICKHOUSE_URL` also accepted) | | `CLICKHOUSE_USER` | No | Username. Default `default` | | `CLICKHOUSE_PASSWORD` | No | Password. Omit for an unauthenticated local instance | | `CLICKHOUSE_DATABASE` | No | Database. Default `default` | | `CLICKHOUSE_TABLE` | No | Table. Default `evlog_events` | ### Options | Option | Type | Default | Description | | -------------------- | ------------------------------------ | ----------------- | ------------------------------------ | | `endpoint` | `string` | — | HTTP interface URL | | `database` | `string` | `default` | Target database | | `table` | `string` | `evlog_events` | Target table | | `username` | `string` | `default` | Username | | `password` | `string` | — | Password | | `asyncInsert` | `boolean` | `true` | Batch inserts server-side | | `waitForAsyncInsert` | `boolean` | `false` | Wait for the flush before responding | | `transform` | `(event) => Record` | `toClickHouseRow` | Map an event to a row | | `timeout` | `number` | `5000` | Request timeout in ms | | `retries` | `number` | `2` | Retry attempts | ## Deployment The adapter is identical either way — only the endpoint and credentials change. ### Self-hosted A local or self-managed instance, with or without auth: ```typescript [server/plugins/evlog.ts] createClickHouseDrain({ endpoint: 'http://localhost:8123' }) ``` ```bash [.env] CLICKHOUSE_ENDPOINT=http://localhost:8123 # Only if your instance requires auth: CLICKHOUSE_USER=evlog CLICKHOUSE_PASSWORD=your-password ``` A fresh container runs as `default` with no password, which is why `username` defaults to `default` and `password` is optional. ### ClickHouse Cloud Cloud services always require credentials and speak HTTPS on port 8443: ```typescript [server/plugins/evlog.ts] createClickHouseDrain({ endpoint: 'https://abc123.eu-west-1.aws.clickhouse.cloud:8443', password: process.env.CLICKHOUSE_PASSWORD, database: 'logs', }) ``` ```bash [.env] CLICKHOUSE_ENDPOINT=https://abc123.eu-west-1.aws.clickhouse.cloud:8443 CLICKHOUSE_PASSWORD=your-service-password CLICKHOUSE_DATABASE=logs ``` ::callout{color="info" icon="i-lucide-info"} Copy the endpoint from *Connect → HTTPS* in the ClickHouse Cloud console — including the `:8443` port. Cloud services idle-suspend, so the first insert after a pause can take a few seconds; raise `timeout` if you see aborts. :: ::callout{color="success" icon="i-lucide-shield-check"} Credentials are sent as `X-ClickHouse-User` / `X-ClickHouse-Key` **headers** , never as query parameters, so they never reach `system.query_log` or an intermediate proxy's access log. :: ## Verify it locally Spin up ClickHouse in Docker, create the table and push a real event: ```bash [Terminal] docker compose -f packages/evlog/test/e2e/docker-compose.yml up -d CLICKHOUSE_ENDPOINT=http://localhost:8123 pnpm run test:e2e docker compose -f packages/evlog/test/e2e/docker-compose.yml down -v ``` The suite is a **round-trip**: it inserts events, reads them back with `SELECT`, and asserts the typed columns and the `data` JSON survived. The compose file creates `evlog_events` from the schema above on first boot. Without `CLICKHOUSE_ENDPOINT` it skips itself with a visible label. To *see* the events rather than assert on them, seed the sandbox and open the provisioned dashboard — the stack ships a Grafana with the ClickHouse datasource already wired up: ```bash [Terminal] pnpm run sandbox:up pnpm run sandbox:seed # → http://localhost:3001/d/evlog-wide-events ``` ClickHouse's built-in `/play` page at `http://localhost:8123/play` also works for raw SQL, but it is deliberately minimal — the dashboard is the better starting point. ## Async inserts Log ingestion means many small inserts, and one MergeTree part per request would quickly degrade a table. The adapter therefore enables [asynchronous inserts](https://clickhouse.com/docs/en/optimize/asynchronous-inserts){rel=""nofollow""} by default, and does **not** wait for the flush: ```text async_insert=1&wait_for_async_insert=0 ``` ClickHouse buffers rows server-side and writes them in batches. Draining never blocks a request on disk writes. Set `waitForAsyncInsert: true` if you need the insert acknowledged as durable before the drain resolves — at the cost of latency. Set `asyncInsert: false` to insert synchronously, which only makes sense when you already batch client-side with `evlog/pipeline`. ## Querying Typed columns are indexed by the sort key; everything else lives in `data` and is reachable with ClickHouse's JSON functions: ```sql -- Error rate per service over the last hour SELECT service, countIf(level = 'error') / count() AS error_rate FROM evlog_events WHERE timestamp > now() - INTERVAL 1 HOUR GROUP BY service; ``` ```sql -- Slowest routes SELECT path, count() AS hits, avg(duration_ms) AS avg_ms, quantile(0.95)(duration_ms) AS p95_ms FROM evlog_events WHERE timestamp > now() - INTERVAL 1 DAY AND method = 'GET' AND duration_ms IS NOT NULL GROUP BY path ORDER BY avg_ms DESC LIMIT 20; ``` ```sql -- One request end to end SELECT timestamp, level, path, status, data FROM evlog_events WHERE request_id = '4a8ff3a8-...' ORDER BY timestamp; ``` ```sql -- Reach into a custom field kept only in data SELECT JSONExtractString(data, 'user', 'plan') AS plan, count() FROM evlog_events WHERE timestamp > now() - INTERVAL 7 DAY GROUP BY plan; ``` ## Custom schema Pass `transform` to target a table of your own shape: ```typescript [server/plugins/evlog.ts] import { createClickHouseDrain } from 'evlog/clickhouse' createClickHouseDrain({ table: 'app_logs', transform: event => ({ ts: event.timestamp, lvl: event.level, svc: event.service, payload: JSON.stringify(event), }), }) ``` Keys must match your column names — ClickHouse rejects a `JSONEachRow` insert containing unknown columns. ## Batching Pair with a pipeline to insert in larger batches: ```typescript [server/plugins/evlog.ts] import { createDrainPipeline } from 'evlog/pipeline' import { createClickHouseDrain } from 'evlog/clickhouse' import type { DrainContext } from 'evlog' const pipeline = createDrainPipeline({ batch: { size: 500, intervalMs: 5000 }, }) export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', pipeline(createClickHouseDrain())) }) ``` ## Troubleshooting **`Missing endpoint`** — `CLICKHOUSE_ENDPOINT` is unset and no `endpoint` was passed. The drain logs `[evlog/clickhouse] Missing endpoint` once and then does nothing, so requests are never blocked or failed. **`ClickHouse API error: 401`** — Check `CLICKHOUSE_USER` / `CLICKHOUSE_PASSWORD`. Credentials are sent as `X-ClickHouse-User` / `X-ClickHouse-Key` headers, never in the query string, so they never reach `system.query_log`. **`ClickHouse API error: 400` mentioning unknown column** — Your table does not match the row shape. Either create the table from the DDL above, or supply a `transform` matching your columns. **`Cannot parse input`** — The table exists but a column type disagrees. `status` is `Nullable(UInt16)` because it is absent on non-HTTP events. ## Direct API usage ```typescript [scripts/backfill.ts] import { sendBatchToClickHouse, sendToClickHouse } from 'evlog/clickhouse' await sendToClickHouse(event, { endpoint: 'http://localhost:8123' }) await sendBatchToClickHouse(events, { endpoint: 'http://localhost:8123' }) ``` `toClickHouseRow()`, `toJSONEachRow()`, and `resolveClickHouseUrl()` are also exported. ## Next steps - [Sampling](https://www.evlog.dev/learn/sampling) — control volume before it reaches ClickHouse - [Enrichers](https://www.evlog.dev/use-cases/enrichers) — add derived fields to every event - [Pipeline](https://www.evlog.dev/learn/pipeline) — batching, retries, and fan-out - [Adapters Overview](https://www.evlog.dev/integrate/adapters/overview) — all available destinations # OTLP Adapter The OTLP (OpenTelemetry Protocol) adapter sends logs in the standard OpenTelemetry format. This works with any OTLP-compatible backend including: - **Grafana Cloud** (Loki) - **Datadog** - **Honeycomb** - **Jaeger** - **Splunk** - **New Relic** - **Self-hosted OpenTelemetry Collector** - **HyperDX** ::prompt --- actions: - copy - cursor - claude description: Add the OTLP drain adapter icon: i-simple-icons-opentelemetry --- Add the OTLP drain adapter to send evlog wide events via OpenTelemetry Protocol. 1. Identify which framework I'm using and follow its evlog integration pattern 2. Install evlog if not already installed 3. Import createOTLPDrain from 'evlog/otlp' 4. Wire createOTLPDrain() into my framework's drain configuration 5. Set OTLP\_ENDPOINT environment variable (collector URL) 6. Optionally set OTLP\_HEADERS for authentication 7. Test by triggering a request and checking your OTLP backend (Grafana, Datadog, Honeycomb, etc.) Adapter docs: {rel=""nofollow""} Framework setup: {rel=""nofollow""} :: ## Installation The OTLP adapter comes bundled with evlog: ```typescript [src/index.ts] import { createOTLPDrain } from 'evlog/otlp' ``` ## Quick Start ### 1. Set your OTLP endpoint ```bash [.env] OTLP_ENDPOINT=http://localhost:4318 ``` ### 2. Wire the drain to your framework ::code-group ```typescript [Nuxt / Nitro] // server/plugins/evlog-drain.ts import { createOTLPDrain } from 'evlog/otlp' export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createOTLPDrain()) }) ``` ```typescript [Next.js] // lib/evlog.ts import { createEvlog } from 'evlog/next' import { createOTLPDrain } from 'evlog/otlp' export const { withEvlog, useLogger, log, createError } = createEvlog({ service: 'my-app', drain: createOTLPDrain(), }) ``` ```typescript [Hono] import { createOTLPDrain } from 'evlog/otlp' app.use(evlog({ drain: createOTLPDrain() })) ``` ```typescript [Express] import { createOTLPDrain } from 'evlog/otlp' app.use(evlog({ drain: createOTLPDrain() })) ``` ```typescript [Fastify] import { createOTLPDrain } from 'evlog/otlp' await app.register(evlog, { drain: createOTLPDrain() }) ``` ```typescript [Elysia] import { createOTLPDrain } from 'evlog/otlp' app.use(evlog({ drain: createOTLPDrain() })) ``` ```typescript [NestJS] import { createOTLPDrain } from 'evlog/otlp' EvlogModule.forRoot({ drain: createOTLPDrain() }) ``` ```typescript [Standalone] import { createOTLPDrain } from 'evlog/otlp' initLogger({ drain: createOTLPDrain() }) ``` :: ## Configuration The adapter reads configuration from multiple sources (highest priority first): 1. **Overrides** passed to `createOTLPDrain()` 2. **Runtime config** at `runtimeConfig.otlp` (Nuxt/Nitro only) 3. **Environment variables** ### Environment Variables | Variable | Description | | ------------------- | ---------------------------------------------------------------------------------------------------------- | | `OTLP_ENDPOINT` | OTLP HTTP endpoint (e.g., `http://localhost:4318`). The standard `OTEL_EXPORTER_OTLP_ENDPOINT` also works. | | `OTLP_HEADERS` | Headers as `key=value` pairs, comma-separated. The standard `OTEL_EXPORTER_OTLP_HEADERS` also works. | | `OTEL_SERVICE_NAME` | Service name override | ### Runtime Config (Nuxt only) ```typescript [nuxt.config.ts] export default defineNuxtConfig({ runtimeConfig: { otlp: { endpoint: '', // Set via OTLP_ENDPOINT (or OTEL_EXPORTER_OTLP_ENDPOINT) }, }, }) ``` ### Override Options ```typescript [server/plugins/evlog-drain.ts] const drain = createOTLPDrain({ endpoint: 'http://localhost:4318', serviceName: 'my-api', headers: { 'Authorization': 'Bearer xxx', }, resourceAttributes: { 'deployment.environment': 'staging', }, }) ``` ### Full Configuration Reference | Option | Type | Default | Description | | -------------------- | -------------------- | ---------- | --------------------------------------------------------------------------------- | | `endpoint` | `string` | - | OTLP HTTP endpoint (required) | | `serviceName` | `string` | From event | Override `service.name` resource attribute | | `headers` | `object` | - | Custom HTTP headers for authentication | | `resourceAttributes` | `object` | - | Additional OTLP resource attributes | | `recordShape` | `'json' | 'compact'` | `'json'` | How the record carries the event ([details](https://www.evlog.dev/#record-shape)) | | `timeout` | `number` | `5000` | Request timeout in milliseconds | ## Deployment OTLP is a protocol, not a product — the same adapter talks to a collector you run yourself and to a managed gateway. Only the endpoint and headers change. ### Self-hosted Run an OpenTelemetry Collector and point evlog at it. Nothing else to configure: ```yaml [otel-collector.yaml] receivers: otlp: protocols: http: endpoint: 0.0.0.0:4318 exporters: debug: verbosity: detailed service: pipelines: logs: receivers: [otlp] exporters: [debug] ``` ```bash [Terminal] docker run --rm -p 4318:4318 \ -v $(pwd)/otel-collector.yaml:/etc/otelcol/config.yaml \ otel/opentelemetry-collector:latest ``` ```bash [.env] OTLP_ENDPOINT=http://localhost:4318 ``` From there the collector fans out wherever you want — Loki, ClickHouse, Elasticsearch, a managed backend, or several at once. That indirection is the reason to pick OTLP over a direct adapter. ### Managed gateways Same adapter, a credentialed endpoint: ::code-group ```bash [Grafana Cloud] OTLP_ENDPOINT=https://otlp-gateway-prod-us-central-0.grafana.net/otlp OTEL_EXPORTER_OTLP_HEADERS=Authorization=Basic%20base64-encoded-credentials ``` ```bash [Datadog] OTLP_ENDPOINT=https://http-intake.logs.datadoghq.com OTLP_HEADERS=DD-API-KEY=your-api-key ``` ```bash [Honeycomb] OTLP_ENDPOINT=https://api.honeycomb.io OTLP_HEADERS=x-honeycomb-team=your-api-key ``` :: ::callout{color="info" icon="i-lucide-info"} Grafana Cloud uses URL-encoded headers — the `%20` is a space. The adapter decodes that format automatically. :: ## OTLP Log Format evlog maps wide events to the OTLP log format: | evlog Field | OTLP Field | | ---------------- | ------------------------------------------- | | `level` | `severityNumber` / `severityText` | | `timestamp` | `timeUnixNano` | | `service` | Resource attribute `service.name` | | `environment` | Resource attribute `deployment.environment` | | `version` | Resource attribute `service.version` | | `region` | Resource attribute `cloud.region` | | `traceId` | `traceId` | | `spanId` | `spanId` | | All other fields | Log attributes | Every field of the wide event is sent as an OTLP record field, a resource attribute, or a log attribute. `null` and `undefined` are omitted rather than transmitted — `json` drops them at the top level, `compact` at every level, so a nested `{ user: { id: null } }` has no `user.id` attribute. ### Record Shape `recordShape` controls how the record carries the event. The default is `json`. ::code-group ```json [json (default)] { "body": { "stringValue": "{\"timestamp\":\"…\",\"method\":\"POST\",\"user\":{\"id\":\"usr_123\"}}" }, "attributes": [ { "key": "method", "value": { "stringValue": "POST" } }, { "key": "user", "value": { "stringValue": "{\"id\":\"usr_123\",\"plan\":\"premium\"}" } } ] } ``` ```json [compact] { "body": { "stringValue": "POST /api/checkout (500)" }, "attributes": [ { "key": "method", "value": { "stringValue": "POST" } }, { "key": "user.id", "value": { "stringValue": "usr_123" } }, { "key": "user.plan", "value": { "stringValue": "premium" } } ] } ``` :: `compact` is worth switching to when your backend charges by ingested volume or facets on attributes: - **The body is a one-line summary** — `POST /api/checkout (500)`, falling back to the service name — instead of the whole event repeated next to the attributes. Backends that cluster messages into templates can only do so with a stable body. - **Nested fields become dotted attributes**, so each leaf is its own facet: ```typescript \[server/api/checkout.post.ts] const drain = createOTLPDrain({ recordShape: 'compact' }) log.set({ user: { id: 'usr_123', plan: 'premium' } }) // → user.id, user.plan ``` Only plain objects are walked. Arrays are serialized as a single JSON string — indexing them (`ai.tools.0.name`) would turn a list into an unbounded set of distinct attribute keys, which most backends charge for and none can chart — and so is anything else that is not a plain object, such as a `Date`. An empty object stays a single `{}` attribute rather than disappearing. ::callout{color="info" icon="i-lucide-info"} `compact` becomes the default in the next major. Switch early if you are setting a project up now — moving later means rewriting the queries built on the `json` shape. :: ### Severity Mapping | evlog Level | OTLP Severity Number | OTLP Severity Text | | ----------- | -------------------- | ------------------ | | `debug` | 5 | DEBUG | | `info` | 9 | INFO | | `warn` | 13 | WARN | | `error` | 17 | ERROR | ## Troubleshooting ### Missing endpoint error ```text [Console] [evlog/otlp] Missing endpoint. Set OTLP_ENDPOINT or OTEL_EXPORTER_OTLP_ENDPOINT ``` Make sure your endpoint environment variable is set and the server was restarted. ### 401 Unauthorized Your authentication headers may be missing or incorrect. Check: 1. The `OTEL_EXPORTER_OTLP_HEADERS` format is correct 2. Credentials are valid and not expired 3. The endpoint URL is correct ### 404 Not Found The adapter sends to `/v1/logs`. Make sure your endpoint: - Supports OTLP HTTP (not gRPC) - Is the base URL without `/v1/logs` suffix ### Logs not appearing 1. Check the server console for `[evlog/otlp]` error messages 2. Test with a local collector first to verify the format 3. Check your backend's ingestion delay (some have 1-2 minute delays) ## Direct API Usage For advanced use cases: ```typescript [server/utils/otlp.ts] import { sendToOTLP, sendBatchToOTLP, toOTLPLogRecord } from 'evlog/otlp' // Send a single event await sendToOTLP(event, { endpoint: 'http://localhost:4318', }) // Send multiple events await sendBatchToOTLP(events, { endpoint: 'http://localhost:4318', }) // Convert event to OTLP format (for inspection) const otlpRecord = toOTLPLogRecord(event) ``` ## Next Steps - [Axiom Adapter](https://www.evlog.dev/integrate/adapters/cloud/axiom) - Send logs to Axiom - [PostHog Adapter](https://www.evlog.dev/integrate/adapters/cloud/posthog) - Send logs to PostHog - [Custom Adapters](https://www.evlog.dev/extend/custom-drains) - Build your own adapter - [Best Practices](https://www.evlog.dev/reference/best-practices) - Security and production tips # HyperDX Adapter [HyperDX](https://hyperdx.io){rel=""nofollow""} is an open-source observability platform. The evlog HyperDX adapter sends your wide events to HyperDX using **OTLP over HTTP**, with defaults aligned to [HyperDX’s OpenTelemetry documentation](https://hyperdx.io/docs/install/opentelemetry){rel=""nofollow""}. ::prompt --- actions: - copy - cursor - claude description: Add the HyperDX drain adapter icon: i-custom-hyperdx --- Add the HyperDX drain adapter to send evlog wide events to HyperDX. 1. Identify which framework I'm using and follow its evlog integration pattern 2. Install evlog if not already installed 3. Import createHyperDXDrain from 'evlog/hyperdx' 4. Wire createHyperDXDrain() into my framework's drain configuration 5. Set HYPERDX\_API\_KEY environment variable in .env 6. Test by triggering a request and checking HyperDX Adapter docs: {rel=""nofollow""} Framework setup: {rel=""nofollow""} :: ## Installation The HyperDX adapter comes bundled with evlog: ```typescript [src/index.ts] import { createHyperDXDrain } from 'evlog/hyperdx' ``` ## Quick Start ### 1. Get your ingestion API key 1. Open the [HyperDX](https://hyperdx.io){rel=""nofollow""} dashboard for your team 2. Copy your **ingestion API key** (HyperDX documents this as the value for the `authorization` header in their OpenTelemetry examples) ### 2. Set environment variables ```bash [.env] HYPERDX_API_KEY= ``` ### 3. Wire the drain to your framework ::code-group ```typescript [Nuxt / Nitro] // server/plugins/evlog-drain.ts import { createHyperDXDrain } from 'evlog/hyperdx' export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createHyperDXDrain()) }) ``` ```typescript [Next.js] // lib/evlog.ts import { createEvlog } from 'evlog/next' import { createHyperDXDrain } from 'evlog/hyperdx' export const { withEvlog, useLogger, log, createError } = createEvlog({ service: 'my-app', drain: createHyperDXDrain(), }) ``` ```typescript [Hono] import { createHyperDXDrain } from 'evlog/hyperdx' app.use(evlog({ drain: createHyperDXDrain() })) ``` ```typescript [Express] import { createHyperDXDrain } from 'evlog/hyperdx' app.use(evlog({ drain: createHyperDXDrain() })) ``` ```typescript [Fastify] import { createHyperDXDrain } from 'evlog/hyperdx' await app.register(evlog, { drain: createHyperDXDrain() }) ``` ```typescript [Elysia] import { createHyperDXDrain } from 'evlog/hyperdx' app.use(evlog({ drain: createHyperDXDrain() })) ``` ```typescript [NestJS] import { createHyperDXDrain } from 'evlog/hyperdx' EvlogModule.forRoot({ drain: createHyperDXDrain() }) ``` ```typescript [Standalone] import { createHyperDXDrain } from 'evlog/hyperdx' initLogger({ drain: createHyperDXDrain() }) ``` :: That's it! Your wide events will now appear in HyperDX. ## Configuration The adapter reads configuration from multiple sources (highest priority first): 1. **Overrides** passed to `createHyperDXDrain()` 2. **Runtime config** at `runtimeConfig.evlog.hyperdx` or `runtimeConfig.hyperdx` (Nuxt/Nitro only) 3. **Environment variables** (`HYPERDX_*`) ### Environment Variables | Variable | Description | | ----------------------- | ---------------------------------------------------------- | | `HYPERDX_API_KEY` | Ingestion API key (sent as the `authorization` header) | | `HYPERDX_OTLP_ENDPOINT` | OTLP HTTP base URL (default: `https://in-otel.hyperdx.io`) | | `HYPERDX_SERVICE_NAME` | Override `service.name` | The following variable is also read when resolving `serviceName` (same as the OTLP adapter): | Variable | Description | | ------------------- | --------------------------------------------------------- | | `OTEL_SERVICE_NAME` | Fallback for service name (HyperDX SDK examples use this) | ### Runtime Config (Nuxt only) Configure via `nuxt.config.ts` for type-safe configuration: ```typescript [nuxt.config.ts] export default defineNuxtConfig({ runtimeConfig: { hyperdx: { apiKey: '', // Set via HYPERDX_API_KEY // endpoint: '', // Set via HYPERDX_OTLP_ENDPOINT }, }, }) ``` You can also nest keys under `runtimeConfig.evlog.hyperdx`; both match how the adapter resolves Nuxt runtime config. ### Override Options Pass options directly to override any configuration: ```typescript [server/plugins/evlog-drain.ts] const drain = createHyperDXDrain({ apiKey: process.env.HYPERDX_API_KEY!, endpoint: 'https://in-otel.hyperdx.io', timeout: 10000, }) ``` For self-hosted HyperDX, set `endpoint` to your OTLP HTTP base URL (same role as `endpoint` in HyperDX’s `otlphttp` exporter example). ### Full Configuration Reference | Option | Type | Default | Description | | -------------------- | -------- | ---------------------------- | ---------------------------------------------------------------------- | | `apiKey` | `string` | - | Ingestion API key (required). Sent as the `authorization` header value | | `endpoint` | `string` | `https://in-otel.hyperdx.io` | OTLP HTTP base URL (evlog appends `/v1/logs`) | | `serviceName` | `string` | - | Override `service.name` resource attribute | | `resourceAttributes` | `object` | - | Additional OTLP resource attributes | | `timeout` | `number` | `5000` | Request timeout in milliseconds | | `retries` | `number` | `2` | Retry attempts on transient failures | ## Deployment HyperDX is open source, so the adapter targets a managed account and a cluster you run yourself with the same code — only `endpoint` changes. ### HyperDX Cloud The default. Set the API key and nothing else: ```typescript [server/plugins/evlog.ts] createHyperDXDrain() ``` ```bash [.env] HYPERDX_API_KEY=your-ingestion-key ``` The endpoint defaults to `https://in-otel.hyperdx.io`. ### Self-hosted Point `endpoint` at your own OTLP HTTP collector — the same value you would put in an `otlphttp` exporter's `endpoint`: ```typescript [server/plugins/evlog.ts] createHyperDXDrain({ endpoint: 'http://hyperdx.internal:4318' }) ``` ```bash [.env] HYPERDX_OTLP_ENDPOINT=http://hyperdx.internal:4318 # Only if your deployment enforces auth: HYPERDX_API_KEY=your-key ``` ::callout{color="info" icon="i-lucide-info"} Give the **base** URL, not the signal path — evlog appends `/v1/logs` itself. A self-hosted collector on the default OTLP HTTP port is `http://host:4318` . :: ## How It Works Under the hood, `createHyperDXDrain()` maps your HyperDX settings to the shared [OTLP adapter](https://www.evlog.dev/integrate/adapters/hybrid/otlp) and calls `sendBatchToOTLP()`: - **Endpoint**: OTLP HTTP base URL, defaulting to `https://in-otel.hyperdx.io` (evlog posts to `{endpoint}/v1/logs`) - **Auth**: `authorization` header set to your API key (same as HyperDX’s documented `otlphttp` exporter) - **Format**: Standard OTLP JSON `ExportLogsServiceRequest` with severity, trace context when present, and structured attributes ## Official HyperDX OpenTelemetry reference From [HyperDX — OpenTelemetry](https://hyperdx.io/docs/install/opentelemetry){rel=""nofollow""}: > Our OpenTelemetry HTTP endpoint is hosted at `https://in-otel.hyperdx.io` (gRPC at port 4317), and requires the `authorization` header to be set to your API key. HyperDX documents this collector configuration (HTTP and gRPC exporters): ```yaml [OpenTelemetry HyperDX exporters] exporters: # HTTP setup otlphttp/hdx: endpoint: 'https://in-otel.hyperdx.io' headers: authorization: compression: gzip # gRPC setup (alternative) otlp/hdx: endpoint: 'in-otel.hyperdx.io:4317' headers: authorization: compression: gzip ``` evlog uses the **HTTP** path: JSON to `{endpoint}/v1/logs` with `Content-Type: application/json` and the `authorization` header above. The collector may enable `compression: gzip`; evlog sends uncompressed JSON bodies like typical OTLP HTTP clients. ## Querying logs in HyperDX Use the HyperDX UI to search and explore wide events: - **Search**: Filter by fields from your wide events (level, service, path, custom attributes, etc.) - **Live tail**: Stream incoming logs - **Dashboards**: Build views on top of structured log data ## Troubleshooting ### Missing apiKey error ```text [Console] [evlog/hyperdx] Missing apiKey. Set HYPERDX_API_KEY, or pass to createHyperDXDrain() ``` Make sure your environment variables are set and the server was restarted after adding them. ### 401 Unauthorized or ingest rejected Your API key may be invalid or not permitted to ingest. Confirm the key in HyperDX matches the ingestion key used in their [OpenTelemetry](https://hyperdx.io/docs/install/opentelemetry){rel=""nofollow""} examples (`authorization: `). ## Direct API Usage For advanced use cases, you can use the lower-level functions: ```typescript [server/utils/hyperdx.ts] import { sendToHyperDX, sendBatchToHyperDX } from 'evlog/hyperdx' // Send a single event await sendToHyperDX(event, { apiKey: process.env.HYPERDX_API_KEY!, }) // Send multiple events in one request await sendBatchToHyperDX(events, { apiKey: process.env.HYPERDX_API_KEY!, endpoint: 'https://in-otel.hyperdx.io', }) ``` ## Next Steps - [OTLP Adapter](https://www.evlog.dev/integrate/adapters/hybrid/otlp) - Send logs via OpenTelemetry Protocol to any OTLP backend - [PostHog Adapter](https://www.evlog.dev/integrate/adapters/cloud/posthog) - Send logs to PostHog Logs via OTLP - [Custom Adapters](https://www.evlog.dev/extend/custom-drains) - Build your own adapter - [Best Practices](https://www.evlog.dev/reference/best-practices) - Security and production tips # File System Adapter The File System adapter writes your wide events to local NDJSON files (one JSON object per line, one file per day). This enables: - **AI agent integration** - point a skill to `.evlog/logs/` to parse structured logs for debugging and pattern analysis - **Local dev debugging** - persistent log history without scrolling the terminal (`tail -f .evlog/logs/2026-03-14.jsonl`) - **Production backup** - combine with a network drain (Axiom, OTLP) for local fallback ::prompt --- actions: - copy - cursor - claude description: Add the file system drain adapter icon: i-lucide-hard-drive --- Add the file system drain adapter to write evlog wide events locally as NDJSON files. 1. Identify which framework I'm using and follow its evlog integration pattern 2. Install evlog if not already installed 3. Import createFsDrain from 'evlog/fs' 4. Wire createFsDrain() into my framework's drain configuration 5. Logs are written to .evlog/logs/ by default (one file per day, auto .gitignore) 6. Optionally configure dir, maxFiles, maxSizePerFile, or pretty options 7. Test by triggering a request and checking .evlog/logs/\*.jsonl Adapter docs: {rel=""nofollow""} Framework setup: {rel=""nofollow""} :: ## Installation The File System adapter comes bundled with evlog: ```typescript [src/index.ts] import { createFsDrain } from 'evlog/fs' ``` ## Quick Start No credentials or environment variables needed. Just wire the drain to your framework: ::code-group ```typescript [Nuxt / Nitro] // server/plugins/evlog-drain.ts import { createFsDrain } from 'evlog/fs' export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createFsDrain()) }) ``` ```typescript [Next.js] // lib/evlog.ts — Node.js routes only; keep evlog/fs out of root instrumentation.ts import { createEvlog } from 'evlog/next' import { createFsDrain } from 'evlog/fs' export const { withEvlog, useLogger, log, createError } = createEvlog({ service: 'my-app', drain: createFsDrain(), }) ``` :::callout{color="info" icon="i-lucide-info"} The FS adapter requires Node.js ( `node:fs` ). On the Edge runtime, or when its directory is not writable, it logs a one-time `[evlog/fs]` warning and skips writes — so attaching it on a serverless host is safe but pointless, since only the temp directory is writable there and it does not outlive the instance. Use `evlog/memory` or an HTTP adapter for those. ::: ```typescript [Hono] import { createFsDrain } from 'evlog/fs' app.use(evlog({ drain: createFsDrain() })) ``` ```typescript [Express] import { createFsDrain } from 'evlog/fs' app.use(evlog({ drain: createFsDrain() })) ``` ```typescript [Fastify] import { createFsDrain } from 'evlog/fs' await app.register(evlog, { drain: createFsDrain() }) ``` ```typescript [Elysia] import { createFsDrain } from 'evlog/fs' app.use(evlog({ drain: createFsDrain() })) ``` ```typescript [NestJS] import { createFsDrain } from 'evlog/fs' EvlogModule.forRoot({ drain: createFsDrain() }) ``` ```typescript [Standalone] import { createFsDrain } from 'evlog/fs' initLogger({ drain: createFsDrain() }) ``` :: Logs start appearing in `.evlog/logs/` immediately. ## File Structure ```text [.evlog/logs directory layout] .evlog/ logs/ 2026-03-14.jsonl ← one file per day 2026-03-13.jsonl 2026-03-12.jsonl ``` Each `.jsonl` file contains one JSON object per line (NDJSON format), making it easy to parse, grep, and stream. ::callout{color="success" icon="i-lucide-git-branch"} A `.gitignore` is automatically created on first write, inside the `.evlog/` ancestor directory when present or in the configured `dir` otherwise. Log files are never committed to version control. :: ## Configuration ### Options | Option | Type | Default | Description | | ---------------- | --------- | --------------- | ---------------------------------------- | | `dir` | `string` | `'.evlog/logs'` | Directory for log files | | `maxFiles` | `number` | `undefined` | Max files to keep (auto-deletes oldest) | | `maxSizePerFile` | `number` | `undefined` | Max bytes per file before rotating | | `pretty` | `boolean` | `false` | Pretty-print JSON (multi-line, readable) | ### Examples ```typescript [server/plugins/evlog-drain.ts] // Keep only the last 7 days of logs createFsDrain({ maxFiles: 7 }) // Rotate files at 10MB, keep 30 files createFsDrain({ maxSizePerFile: 10 * 1024 * 1024, maxFiles: 30, }) // Pretty-print for human reading createFsDrain({ pretty: true }) // Custom directory createFsDrain({ dir: '/var/log/myapp' }) ``` ### File Rotation By default, a new file is created each day (`2026-03-14.jsonl`). When `maxSizePerFile` is set, the adapter creates suffixed files when the current file exceeds the limit: ```text [Rotated log files] .evlog/logs/ 2026-03-14.jsonl ← base file (full) 2026-03-14.1.jsonl ← first rotation 2026-03-14.2.jsonl ← second rotation ``` ### Cleanup When `maxFiles` is set, the adapter automatically deletes the oldest `.jsonl` files after each write, keeping only the most recent files. ## Combining with Network Drains Use the FS adapter alongside a network drain for local backup: ```typescript [server/plugins/evlog-drain.ts] import { createFsDrain } from 'evlog/fs' import { createAxiomDrain } from 'evlog/axiom' const fs = createFsDrain({ maxFiles: 7 }) const axiom = createAxiomDrain() const drain = async (ctx) => { await Promise.allSettled([fs(ctx), axiom(ctx)]) } ``` ## Querying Logs ### Stream in real-time ```bash [Terminal] tail -f .evlog/logs/2026-03-14.jsonl ``` ### Search with jq ```bash [Terminal] # Find errors cat .evlog/logs/2026-03-14.jsonl | jq 'select(.level == "error")' # Slow requests (over 1s) cat .evlog/logs/2026-03-14.jsonl | jq 'select(.durationMs > 1000)' # Requests by path cat .evlog/logs/2026-03-14.jsonl | jq 'select(.path == "/api/checkout")' ``` ### Search with grep ```bash [Terminal] # Find all errors grep '"level":"error"' .evlog/logs/2026-03-14.jsonl # Find by request ID grep 'req_abc123' .evlog/logs/*.jsonl ``` ## Direct API Usage For advanced use cases, use the lower-level write functions: ```typescript [src/index.ts] import { writeToFs, writeBatchToFs } from 'evlog/fs' await writeToFs(event, { dir: '.evlog/logs', pretty: false, }) await writeBatchToFs(events, { dir: '.evlog/logs', pretty: false, }) ``` ## AI Log Analysis The file system drain pairs with the [`analyze-logs` agent skill](https://www.evlog.dev/reference/agent-skills). When installed, your AI assistant can read the NDJSON logs directly to debug errors, trace requests, and investigate performance without any external tools. ## Next Steps - [Agent Skills](https://www.evlog.dev/reference/agent-skills) - Let AI analyze your logs - [Axiom Adapter](https://www.evlog.dev/integrate/adapters/cloud/axiom) - Send logs to Axiom for querying and dashboards - [Pipeline](https://www.evlog.dev/extend/drain-pipeline) - Add batching and retry to any drain - [Custom Adapters](https://www.evlog.dev/extend/custom-drains) - Build your own adapter # NuxtHub Storage `@evlog/nuxthub` stores your evlog wide events directly in your NuxtHub database. No external logging service needed. Your logs live next to your data, with automatic cleanup based on a retention policy. ::prompt --- actions: - copy - cursor - claude description: Store evlog wide events in NuxtHub icon: i-simple-icons-nuxt --- Store evlog wide events in my NuxtHub database (self-hosted log retention). - Install both modules: pnpm add @nuxthub/core @evlog/nuxthub - Add @nuxthub/core and @evlog/nuxthub to nuxt.config.ts modules (in that order) - Enable hub.database = true in nuxt.config.ts - Configure evlog.nuxthub: { retentionDays, batchSize, ... } for retention and batching - Run database migrations so the wide-events table is created - Confirm wide events are written to my NuxtHub database after triggering a request - For production at scale, combine with an external drain (Axiom / OTLP) for long-term storage Docs: {rel=""nofollow""} NuxtHub: {rel=""nofollow""} :: ## Why Self-Hosted Logs? External logging services (Axiom, Datadog, etc.) are great for production at scale. But sometimes you want: - **Zero external dependencies** - logs stored in the same database as your app - **Full data ownership** - no third-party access to your log data - **Free tier friendly** - no per-event pricing, just your existing database - **Development & staging** - full log visibility without paying for a service `@evlog/nuxthub` works as a drop-in drain. Your existing evlog setup stays the same, you just get a database-backed storage layer on top. ## Install ::code-group ```bash [pnpm] pnpm add @nuxthub/core @evlog/nuxthub ``` ```bash [bun] bun add @nuxthub/core @evlog/nuxthub ``` ```bash [yarn] yarn add @nuxthub/core @evlog/nuxthub ``` ```bash [npm] npm install @nuxthub/core @evlog/nuxthub ``` :: Or with `nuxi`: ```bash [Terminal] npx nuxi module add @nuxthub/core @evlog/nuxthub ``` ## Setup Add the module to your `nuxt.config.ts`: ```typescript [nuxt.config.ts] export default defineNuxtConfig({ modules: ['@nuxthub/core', '@evlog/nuxthub'], evlog: { retention: '7d', }, }) ``` Even if `@evlog/nuxthub` can auto-register missing modules, we recommend explicitly installing `@nuxthub/core` and registering it in `modules` for a clearer and more predictable setup. That's it. The module automatically: 1. Installs `evlog/nuxt` and `@nuxthub/core` if not already registered 2. Registers the `evlog_events` database schema with NuxtHub 3. Hooks into `evlog:drain` to store every event in the database 4. Schedules a cleanup task based on your retention policy ::callout{color="info" icon="i-lucide-info"} **Prerequisites:** Your project must use [NuxtHub](https://hub.nuxt.com){rel=""nofollow""} with a database configured. `@evlog/nuxthub` uses Drizzle ORM to interact with the database. :: ## How It Works ```text Request → evlog wide event → evlog:drain hook → INSERT into evlog_events table ↓ Cron task (automatic) → DELETE events older than retention ``` Every wide event emitted by evlog is stored as a row in the `evlog_events` table. The drain plugin handles both single events and batches (when used with the [pipeline](https://www.evlog.dev/extend/drain-pipeline)). ### Database Schema The `evlog_events` table stores indexed columns for fast querying and a `data` JSON column for all remaining fields: | Column | Type | Description | | ------------- | --------- | --------------------------------------- | | `id` | `text` | UUID primary key | | `timestamp` | `text` | Event timestamp | | `level` | `text` | Log level (info, warn, error, debug) | | `service` | `text` | Service name | | `environment` | `text` | Environment (production, staging, etc.) | | `method` | `text` | HTTP method | | `path` | `text` | Request path | | `status` | `integer` | HTTP status code | | `duration_ms` | `integer` | Request duration in milliseconds | | `request_id` | `text` | Request correlation ID | | `source` | `text` | Event source (server, client) | | `error` | `text` | Error details (JSON string) | | `data` | `text` | All remaining event fields (JSON) | | `created_at` | `text` | Row insertion timestamp | Indexed columns: `timestamp`, `level`, `service`, `status`, `request_id`, `created_at`. ### Dialect Support The schema is automatically registered for your NuxtHub database dialect: - **SQLite** (default for Cloudflare D1) - **MySQL** - **PostgreSQL** The correct schema is selected via the `hub:db:schema:extend` hook based on your NuxtHub configuration. ## Combining with External Adapters `@evlog/nuxthub` doesn't replace external adapters, you can use both. The module registers its own `evlog:drain` hook, so any other drain plugins you have will still work: ```typescript [server/plugins/evlog-drain.ts] import { createAxiomDrain } from 'evlog/axiom' export default defineNitroPlugin((nitroApp) => { // This runs alongside @evlog/nuxthub's built-in drain nitroApp.hooks.hook('evlog:drain', createAxiomDrain()) }) ``` ## Retention `@evlog/nuxthub` automatically deletes old events based on your retention policy. No manual cleanup needed. ### Configuration Set the retention period in your `nuxt.config.ts`: ```typescript [nuxt.config.ts] export default defineNuxtConfig({ modules: ['@nuxthub/core', '@evlog/nuxthub'], evlog: { retention: '7d', // default }, }) ``` The retention value is a number followed by a unit: | Unit | Description | Example | | ---- | ----------- | ------------------ | | `d` | Days | `7d` = 7 days | | `h` | Hours | `24h` = 24 hours | | `m` | Minutes | `60m` = 60 minutes | ### How Cleanup Works The module registers a Nitro scheduled task (`evlog:cleanup`) that runs on a cron schedule derived from your retention value. The cron frequency is set to roughly half the retention period: | Retention | Cron Schedule | Description | | --------- | -------------- | ---------------- | | `60m` | `*/30 * * * *` | Every 30 minutes | | `24h` | `0 */12 * * *` | Every 12 hours | | `7d` | `0 3 * * *` | Daily at 3:00 AM | | `30d` | `0 3 * * *` | Daily at 3:00 AM | The cleanup task deletes all rows in `evlog_events` where `created_at` is older than the retention period. ### Manual Cleanup You can trigger cleanup manually via the API endpoint: ```bash [Terminal] curl https://your-app.com/api/_cron/evlog-cleanup ``` If the `CRON_SECRET` environment variable is set, the endpoint requires a Bearer token: ```bash [Terminal] curl -H "Authorization: Bearer your-secret" \ https://your-app.com/api/_cron/evlog-cleanup ``` This is recommended for production deployments to prevent unauthorized cleanup triggers. ### Vercel Cron When installing the module with `nuxi module add`, you'll be prompted to create a `vercel.json` with the appropriate cron schedule: ```json [vercel.json] { "crons": [ { "path": "/api/_cron/evlog-cleanup", "schedule": "0 3 * * *" } ] } ``` On Vercel, the `CRON_SECRET` environment variable is automatically set and validated. ### Cloudflare & Other Platforms On Cloudflare Workers and other platforms, the Nitro scheduled task handles cleanup automatically without any additional cron configuration. The task is registered with `experimental.tasks` enabled in the Nitro config. ## Next Steps - [Adapters](https://www.evlog.dev/integrate/adapters/overview) - Send logs to external services alongside NuxtHub storage - [Pipeline](https://www.evlog.dev/extend/drain-pipeline) - Batch events for better database performance # Memory Adapter The Memory adapter stores wide events in a module-level ring buffer. Unlike the [File System adapter](https://www.evlog.dev/integrate/adapters/self-hosted/fs), it has **zero runtime dependencies** and runs anywhere — including Cloudflare Workers (workerd), Deno Deploy, and other edge runtimes that don't expose Node's `fs` module. The primary use case is **local dev agent access**: wire the drain during development, expose a lightweight HTTP endpoint, and let your AI agent fetch structured logs over HTTP without any external tooling. ::prompt --- actions: - copy - cursor - claude description: Add the memory drain adapter icon: i-lucide-cpu --- Add the memory drain adapter to store evlog wide events in an in-memory ring buffer. 1. Identify which framework I'm using and follow its evlog integration pattern 2. Install evlog if not already installed 3. Import createMemoryDrain and readMemoryLogs from 'evlog/memory' 4. Wire createMemoryDrain() into my framework's drain configuration 5. Expose a dev-only HTTP endpoint that returns readMemoryLogs() as JSON 6. Agents can now hit that endpoint to retrieve structured logs over HTTP 7. Optionally configure maxEvents (default 1000) or use named stores Adapter docs: {rel=""nofollow""} Framework setup: {rel=""nofollow""} :: ## Installation The Memory adapter comes bundled with evlog: ```typescript [src/index.ts] import { createMemoryDrain, readMemoryLogs } from 'evlog/memory' ``` ## Quick Start ::code-group ```typescript [Hono (Cloudflare Workers)] // src/index.ts import { Hono } from 'hono' import { evlog } from 'evlog/hono' import { createMemoryDrain, readMemoryLogs } from 'evlog/memory' const app = new Hono() app.use(evlog({ drain: createMemoryDrain() })) // Dev-only endpoint — restrict or remove in production app.get('/_evlog/logs', (c) => { return c.json(readMemoryLogs()) }) ``` ```typescript [Nuxt / Nitro] // server/plugins/evlog-drain.ts import { createMemoryDrain } from 'evlog/memory' export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createMemoryDrain()) }) ``` ```typescript [Next.js] // lib/evlog.ts import { createEvlog } from 'evlog/next' import { createMemoryDrain } from 'evlog/memory' export const { withEvlog, useLogger, log, createError } = createEvlog({ service: 'my-app', drain: createMemoryDrain(), }) ``` ```typescript [Express] import { evlog } from 'evlog/express' import { createMemoryDrain } from 'evlog/memory' app.use(evlog({ drain: createMemoryDrain() })) ``` ```typescript [Fastify] import { evlog } from 'evlog/fastify' import { createMemoryDrain } from 'evlog/memory' await app.register(evlog, { drain: createMemoryDrain() }) ``` ```typescript [Elysia] import { evlog } from 'evlog/elysia' import { createMemoryDrain } from 'evlog/memory' app.use(evlog({ drain: createMemoryDrain() })) ``` ```typescript [NestJS] import { createMemoryDrain } from 'evlog/memory' EvlogModule.forRoot({ drain: createMemoryDrain() }) ``` ```typescript [Standalone] import { createMemoryDrain } from 'evlog/memory' initLogger({ drain: createMemoryDrain() }) ``` :: ## Agent Access via HTTP Expose a route so agents can retrieve structured logs during a local dev session. Use `parseReadMemoryLogsQuery` to let agents pass filter params directly as query strings: ```typescript [src/index.ts (Hono)] import { readMemoryLogs, parseReadMemoryLogsQuery } from 'evlog/memory' // Restrict to dev — agents hit this endpoint to retrieve logs if (process.env.NODE_ENV !== 'production') { app.get('/_evlog/logs', (c) => { return c.json(readMemoryLogs(parseReadMemoryLogsQuery(c.req.query()))) }) } ``` An agent can now call `/_evlog/logs?level=error&limit=50&since=2026-01-01T00:00:00Z` and the query params are coerced to the correct types before being passed to `readMemoryLogs`. Supported query params: `store`, `since`, `until`, `level` (comma-separated for multiple), `limit`. The response is a JSON array of [`WideEvent`](https://www.evlog.dev/reference/configuration) objects — the same shape used by every other evlog adapter. ## Configuration ### Options | Option | Type | Default | Description | | ----------- | -------- | ----------- | ----------------------------------------------------------------------------- | | `maxEvents` | `number` | `1000` | Maximum events to keep in the ring buffer (oldest are dropped) | | `store` | `string` | `'default'` | Named buffer key — multiple drains sharing the same key share the same buffer | ```typescript [server/plugins/evlog-drain.ts] // Keep only the last 500 events createMemoryDrain({ maxEvents: 500 }) // Use a named store for isolation createMemoryDrain({ store: 'my-service' }) ``` ### Environment Variables | Variable | Description | | ------------------------- | --------------------------------------- | | `EVLOG_MEMORY_STORE` | Named buffer key (default: `'default'`) | | `EVLOG_MEMORY_MAX_EVENTS` | Ring buffer size (default: `1000`) | Configuration priority matches other adapters: overrides → `runtimeConfig.evlog.memory` → env vars. ### Named Stores Use named stores to isolate events from different services or for testing: ```typescript [src/index.ts] import { createMemoryDrain, readMemoryLogs, clearMemoryLogs } from 'evlog/memory' // Two separate buffers const authDrain = createMemoryDrain({ store: 'auth' }) const apiDrain = createMemoryDrain({ store: 'api' }) // Read from a specific store const authErrors = readMemoryLogs({ store: 'auth', level: 'error' }) // Clear a store (useful in tests) clearMemoryLogs('auth') ``` ## Querying `readMemoryLogs` supports the same filtering options as `readFsLogs`: ```typescript [src/index.ts] import { readMemoryLogs } from 'evlog/memory' // All events const all = readMemoryLogs() // Errors only const errors = readMemoryLogs({ level: 'error' }) // Last 10 minutes const recent = readMemoryLogs({ since: new Date(Date.now() - 10 * 60 * 1000), }) // Custom predicate const slow = readMemoryLogs({ filter: e => (e.durationMs ?? 0) > 1000, }) // Most recent 50 events const latest = readMemoryLogs({ limit: 50 }) ``` ### `readMemoryLogs` Options | Option | Type | Description | | -------- | ----------------------- | ----------------------------------------------- | | `store` | `string` | Named store to read from (default: `'default'`) | | `since` | `Date | string` | Only events with `timestamp >= since` | | `until` | `Date | string` | Only events with `timestamp <= until` | | `level` | `LogLevel | LogLevel[]` | Filter by level | | `filter` | `(event) => boolean` | Custom predicate | | `limit` | `number` | Return at most N most-recent matching events | ## Combining with Network Drains Use the memory adapter locally while sending to an observability platform in production: ```typescript [server/plugins/evlog-drain.ts] import { createMemoryDrain } from 'evlog/memory' import { createAxiomDrain } from 'evlog/axiom' const memory = createMemoryDrain() const axiom = createAxiomDrain() const drain = async (ctx) => { if (process.env.NODE_ENV === 'development') { await memory(ctx) } else { await axiom(ctx) } } ``` ## Ring Buffer Behaviour The buffer is **bounded**: once it reaches `maxEvents`, the oldest events are discarded to make room for incoming ones. This means memory usage stays constant regardless of how long the service runs. ```text [Ring buffer (maxEvents: 5)] Write events 1–5 → [1, 2, 3, 4, 5] Write event 6 → [2, 3, 4, 5, 6] (1 is dropped) Write events 7–8 → [4, 5, 6, 7, 8] ``` ::callout{color="warning" icon="i-lucide-triangle-alert"} The in-memory buffer is lost when the worker/process restarts. For persistent storage, use the [File System adapter](https://www.evlog.dev/integrate/adapters/self-hosted/fs) (Node-based runtimes) or [NuxtHub](https://www.evlog.dev/integrate/adapters/self-hosted/nuxthub) . :: ## Direct API Usage For advanced use cases, call the underlying helpers directly: ```typescript [src/index.ts] import { writeToMemory, readMemoryLogs, clearMemoryLogs, parseReadMemoryLogsQuery } from 'evlog/memory' // Write events directly (skips the drain pipeline) writeToMemory([event], { store: 'default', maxEvents: 1000 }) // Read the current buffer const events = readMemoryLogs() // Parse HTTP query params into ReadMemoryLogsOptions const opts = parseReadMemoryLogsQuery({ level: 'error', limit: '50' }) // → { level: 'error', limit: 50 } // Reset for tests clearMemoryLogs() ``` ### `parseReadMemoryLogsQuery` coercion rules | Query param | Type in `ReadMemoryLogsOptions` | Notes | | ----------- | ------------------------------- | ---------------------------------------------------------------------------- | | `store` | `string` | Passed through as-is | | `since` | `string` | ISO 8601 string — parsed by `readMemoryLogs` | | `until` | `string` | ISO 8601 string — parsed by `readMemoryLogs` | | `level` | `LogLevel | LogLevel[]` | Comma-separated (`error,warn`) or repeated array; invalid values are dropped | | `limit` | `number` | `parseInt`; NaN → omitted | ## Next Steps - [File System Adapter](https://www.evlog.dev/integrate/adapters/self-hosted/fs) - Persistent local logs for Node-based runtimes - [NuxtHub Adapter](https://www.evlog.dev/integrate/adapters/self-hosted/nuxthub) - Database-backed storage for Cloudflare D1 - [Pipeline](https://www.evlog.dev/extend/drain-pipeline) - Add batching and retry to any drain - [Custom Adapters](https://www.evlog.dev/extend/custom-drains) - Build your own adapter # Framework Integrations evlog provides native integrations for every major TypeScript framework. The same core API (`log.set()`, `createError()`, `parseError()`) works identically everywhere. Only the setup differs. ::callout{color="neutral" icon="i-lucide-globe"} No HTTP framework? Use [Standalone TypeScript](https://www.evlog.dev/integrate/frameworks/standalone) for scripts, libraries, and workers, and [Cloudflare Workers](https://www.evlog.dev/integrate/frameworks/cloudflare-workers) on the edge. :: ## Overview | Framework | Import | Type | Logger Access | Status | | ----------------------------------------------------------------------------------- | -------------------- | ---------------------------- | -------------------------------------------- | ------ | | [Nuxt](https://www.evlog.dev/integrate/frameworks/nuxt) | `evlog/nuxt` | Module | `useLogger(event)` | Stable | | [Next.js](https://www.evlog.dev/integrate/frameworks/nextjs) | `evlog/next` | Factory | `useLogger()` | Stable | | [SvelteKit](https://www.evlog.dev/integrate/frameworks/sveltekit) | `evlog/sveltekit` | Hooks | `event.locals.log` / `useLogger()` | Stable | | [Nitro](https://www.evlog.dev/integrate/frameworks/nitro) | `evlog/nitro` | Module | `useLogger(event)` | Stable | | [TanStack Start](https://www.evlog.dev/integrate/frameworks/tanstack-start) | `evlog/nitro/v3` | Module | `useRequest().context.log` | Stable | | [TanStack Router](https://www.evlog.dev/integrate/frameworks/tanstack-start) | `evlog/nitro/v3` | Module | Via TanStack Start (uses Nitro v3) | Stable | | [React Router](https://www.evlog.dev/integrate/frameworks/react-router) | `evlog/react-router` | Middleware | `context.get(loggerContext)` / `useLogger()` | Stable | | [NestJS](https://www.evlog.dev/integrate/frameworks/nestjs) | `evlog/nestjs` | Module | `useLogger()` | Stable | | [Express](https://www.evlog.dev/integrate/frameworks/express) | `evlog/express` | Middleware | `req.log` / `useLogger()` | Stable | | [Hono](https://www.evlog.dev/integrate/frameworks/hono) | `evlog/hono` | Middleware | `c.get('log')` | Stable | | [Fastify](https://www.evlog.dev/integrate/frameworks/fastify) | `evlog/fastify` | Plugin | `request.log` / `useLogger()` | Stable | | [Elysia](https://www.evlog.dev/integrate/frameworks/elysia) | `evlog/elysia` | Plugin | `log` (context) / `useLogger()` | Stable | | [oRPC](https://www.evlog.dev/integrate/frameworks/orpc) | `evlog/orpc` | Handler wrapper + middleware | `context.log` / `useLogger()` | Stable | | [Cloudflare Workers](https://www.evlog.dev/integrate/frameworks/cloudflare-workers) | `evlog/workers` | Factory | `createWorkersLogger()` | Stable | | [AWS Lambda](https://www.evlog.dev/integrate/frameworks/aws-lambda) | `evlog` | Manual | `createLogger()` / `createRequestLogger()` | Guide | | [Standalone](https://www.evlog.dev/integrate/frameworks/standalone) | `evlog` | Manual | `createLogger()` / `createRequestLogger()` | Stable | | [Astro](https://www.evlog.dev/integrate/frameworks/astro) | `evlog` | Manual | `createRequestLogger()` | Guide | | [Custom](https://www.evlog.dev/extend/custom-framework) | `evlog/toolkit` | Build your own | `createMiddlewareLogger()` | Beta | ## API cheat sheet Two things differ per framework: how you **bootstrap** evlog, and how you **access the request logger**. ### Bootstrap | Pattern | Frameworks | | ------------------------------------ | ------------------------------------------------------------------------- | | `evlog(options)` middleware / plugin | Hono, Express, Fastify, Elysia, SvelteKit, React Router | | `createEvlog(options)` factory | Next.js | | `EvlogModule.forRoot()` | NestJS | | Module default export | Nuxt, Nitro v2/v3 | | Manual factory | Cloudflare Workers (`createWorkersLogger`), Standalone, AWS Lambda, Astro | ### Logger access | Pattern | Frameworks | | ------------------------------------------ | ------------------------------------------------------------------ | | `useLogger(event)` | Nuxt, Nitro | | `useLogger()` | Next.js, NestJS, Express, Fastify, Elysia, SvelteKit, React Router | | `c.get('log')` | Hono — no `useLogger()` export | | `req.log` | Express | | `request.log` | Fastify | | `event.locals.log` | SvelteKit | | `context.get(loggerContext)` | React Router | | `createRequestLogger()` / `createLogger()` | Standalone, Workers, manual setups | ::callout{color="info" icon="i-lucide-info"} Hono intentionally has no `useLogger()` — use `c.get('log')` inside handlers. See [Hono integration](https://www.evlog.dev/integrate/frameworks/hono) . :: ## Full-Stack Frameworks ::card-group :::card --- color: neutral icon: i-simple-icons-nuxtdotjs title: Nuxt to: https://www.evlog.dev/integrate/frameworks/nuxt --- Auto-imported `useLogger` , `createError` , and `parseError` . Zero config. ::: :::card --- color: neutral icon: i-simple-icons-nextdotjs title: Next.js to: https://www.evlog.dev/integrate/frameworks/nextjs --- `createEvlog()` factory with `withEvlog()` handler wrapper and client provider. ::: :::card --- color: neutral icon: i-simple-icons-svelte title: SvelteKit to: https://www.evlog.dev/integrate/frameworks/sveltekit --- Handle and handleError hooks with request-scoped logger on `event.locals.log` . ::: :::card --- color: neutral icon: i-custom-nitro title: Nitro to: https://www.evlog.dev/integrate/frameworks/nitro --- Module for both Nitro v2 and v3 with plugin-based drain and enrichment hooks. ::: :::card --- color: neutral icon: i-custom-tanstack title: TanStack Start to: https://www.evlog.dev/integrate/frameworks/tanstack-start --- Uses Nitro v3 module with async context for seamless logging in server functions. Also covers TanStack Router (full-stack mode). ::: :::card --- color: neutral icon: i-custom-reactrouter title: React Router to: https://www.evlog.dev/integrate/frameworks/react-router --- Middleware with `context.get(loggerContext)` and `useLogger()` for loaders and services. ::: :::card --- color: neutral icon: i-simple-icons-nestjs title: NestJS to: https://www.evlog.dev/integrate/frameworks/nestjs --- `EvlogModule.forRoot()` with global middleware, exception filter, and async config. ::: :: ## Server Frameworks ::card-group :::card --- color: neutral icon: i-simple-icons-express title: Express to: https://www.evlog.dev/integrate/frameworks/express --- Middleware with `req.log` and 4-argument error handler. ::: :::card --- color: neutral icon: i-simple-icons-hono title: Hono to: https://www.evlog.dev/integrate/frameworks/hono --- Middleware with typed `c.get('log')` via `EvlogVariables` . ::: :::card --- color: neutral icon: i-simple-icons-fastify title: Fastify to: https://www.evlog.dev/integrate/frameworks/fastify --- Plugin with `request.log` that shadows Fastify's built-in pino logger. ::: :::card --- color: neutral icon: i-custom-elysia title: Elysia to: https://www.evlog.dev/integrate/frameworks/elysia --- Plugin with `log` in route context via Elysia's `derive` . ::: :::card --- color: neutral icon: i-lucide-network title: oRPC to: https://www.evlog.dev/integrate/frameworks/orpc --- Handler wrapper + procedure middleware exposing `context.log` and per-procedure `operation` . ::: :::card --- color: neutral icon: i-simple-icons-cloudflare title: Cloudflare Workers to: https://www.evlog.dev/integrate/frameworks/cloudflare-workers --- Factory for creating request-scoped loggers with Cloudflare-specific context. ::: :::card --- color: neutral icon: i-custom-lambda title: AWS Lambda to: https://www.evlog.dev/integrate/frameworks/aws-lambda --- `initLogger` once per runtime; `createLogger` per invocation (SQS, events, HTTP API). ::: :::card --- color: neutral icon: i-simple-icons-typescript title: Standalone to: https://www.evlog.dev/integrate/frameworks/standalone --- For scripts, CLI tools, queues, and any TypeScript process. ::: :::card --- color: neutral icon: i-lucide-puzzle title: Custom Integration to: https://www.evlog.dev/extend/custom-framework --- Build your own middleware with the evlog toolkit API. ::: :: ::callout{color="info" icon="i-lucide-info"} All frameworks support the same features: [wide events](https://www.evlog.dev/learn/wide-events) , [structured errors](https://www.evlog.dev/learn/structured-errors) , [drain adapters](https://www.evlog.dev/integrate/adapters/overview) , [enrichers](https://www.evlog.dev/use-cases/enrichers) , [sampling](https://www.evlog.dev/learn/sampling) , and [AI SDK integration](https://www.evlog.dev/use-cases/ai-sdk/overview) . :: ## Vite Plugin For any Vite-based project, the [`evlog/vite` plugin](https://www.evlog.dev/reference/vite-plugin) adds build-time optimizations: - **Auto-initialization**: no `initLogger()` call needed - **Debug stripping**: `log.debug()` removed from production builds - **Source location**: inject `__source: 'file:line'` into log calls Works with SvelteKit, Hono (via vite-node), and any Vite-powered setup. Nuxt users get these features via the `evlog/nuxt` module options. # Nuxt evlog provides a first-class Nuxt module with auto-imported `useLogger`, `createError`, and `parseError`. Add it to your config and start logging with zero boilerplate. ::prompt --- actions: - copy - cursor - claude description: Set up evlog in my Nuxt app icon: i-simple-icons-nuxtdotjs --- Set up evlog in my Nuxt app with wide events and structured errors. - Install evlog: pnpm add evlog - Add 'evlog/nuxt' to modules in nuxt.config.ts - Set evlog.env.service to my app name - useLogger, createError, and parseError are auto-imported - Create a server/api route using useLogger(event) and log.set() to build a wide event - Throw errors with createError({ message, status, why, fix }) - Wide events are auto-emitted when each request completes Docs: {rel=""nofollow""} Adapters: {rel=""nofollow""} :: ## Quick Start ### 1. Install ::code-group ```bash [pnpm] pnpm add evlog ``` ```bash [bun] bun add evlog ``` ```bash [yarn] yarn add evlog ``` ```bash [npm] npm install evlog ``` :: ### 2. Add the module ```typescript [nuxt.config.ts] export default defineNuxtConfig({ modules: ['evlog/nuxt'], evlog: { env: { service: 'my-app', }, }, }) ``` That's it. `useLogger`, `createError`, and `parseError` are auto-imported. ## Wide Events Build up context progressively throughout a request with `useLogger(event)`. evlog emits a single wide event when the request completes. ```typescript [server/api/checkout.post.ts] export default defineEventHandler(async (event) => { const log = useLogger(event) const body = await readBody(event) log.set({ user: { id: body.userId, plan: 'enterprise' } }) const cart = await db.findCart(body.cartId) log.set({ cart: { items: cart.items.length, total: cart.total } }) const payment = await processPayment(cart) log.set({ payment: { method: payment.method, cardLast4: payment.last4 } }) return { success: true, orderId: payment.orderId } }) ``` One request, one log line with all context: ```bash [Terminal output] 10:23:45 INFO [my-app] POST /api/checkout 200 in 145ms ├─ user: id=usr_123 plan=enterprise ├─ cart: items=3 total=14999 ├─ payment: method=card cardLast4=4242 └─ requestId: a1b2c3d4-... ``` ## Error Handling `createError` produces structured errors with `why`, `fix`, and `link` fields that help both humans and AI agents understand what went wrong. ```typescript [server/api/payment/process.post.ts] export default defineEventHandler(async (event) => { const log = useLogger(event) const body = await readBody(event) log.set({ payment: { amount: body.amount } }) if (body.amount <= 0) { throw createError({ status: 400, message: 'Invalid payment amount', why: 'The amount must be a positive number', fix: 'Pass a positive integer in cents (e.g. 4999 for $49.99)', link: 'https://docs.example.com/api/payments#amount', }) } return { success: true } }) ``` ::callout{color="info" icon="i-lucide-info"} Nuxt's error handler automatically catches `EvlogError` and returns a structured JSON response with `why` , `fix` , and `link` fields. :: ## Configuration ::callout{color="info" icon="i-lucide-book-open"} See the [Configuration reference](https://www.evlog.dev/reference/configuration) for the full list of shared options ( `enabled` , `pretty` , `silent` , `sampling` , middleware options, etc.). :: All options are set in `nuxt.config.ts` under the `evlog` key: | Option | Type | Default | Description | | ----------------- | ------------------------------------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `enabled` | `boolean` | `true` | Globally enable/disable all logging. When `false`, all operations become no-ops | | `console` | `boolean` | `true` | Enable/disable browser console output | | `env.service` | `string` | `'app'` | Service name shown in logs | | `env.environment` | `string` | Auto-detected | Environment name | | `include` | `string[]` | `undefined` | Route patterns to log. Supports glob (`/api/**`) | | `exclude` | `string[]` | `undefined` | Route patterns to exclude. Exclusions take precedence | | `routes` | `Record` | `undefined` | Route-specific service configuration | | `pretty` | `boolean` | `true` in dev | Pretty print with tree formatting | | `dev` | `'evlog' | 'nitro' | 'both' | object` | `'evlog'` in pretty dev | Dev terminal presets or `{ frameworkOverlay, prettyError }` — see [Configuration — Dev terminal output](https://www.evlog.dev/reference/configuration#dev-terminal-output) | ::callout{color="info" icon="i-lucide-terminal"} **Dev terminal presets:** `'evlog'` (default) — one clean signal, evlog-only stack. `'nitro'` — wide event context + Nitro Youch stack (evlog prints Why/Fix only). `'both'` — full evlog block and Nitro overlay. With `pretty: false` , set `dev: { frameworkOverlay: false }` to suppress Nitro while logging JSON. :: \| `silent` | `boolean` | `false` | Suppress console output. Events are still built, sampled, and drained. Use for stdout-based platforms | \| `sampling.rates` | `object` | `undefined` | Head sampling rates per log level (0-100%) | \| `sampling.keep` | `array` | `undefined` | Tail sampling conditions to force-keep logs | \| `transport.enabled` | `boolean` | `false` | Enable client-to-server log transport | \| `transport.endpoint` | `string` | `'/api/_evlog/ingest'` | Transport endpoint | ## Route Filtering Use `include` and `exclude` to control which routes are logged: ```typescript [nuxt.config.ts] export default defineNuxtConfig({ modules: ['evlog/nuxt'], evlog: { include: ['/api/**', '/auth/**'], exclude: [ '/api/_nuxt_icon/**', '/api/_content/**', '/api/health', ], }, }) ``` ::callout{color="warning" icon="i-lucide-alert-triangle"} **Exclusions take precedence.** If a path matches both `include` and `exclude` , it will be excluded. :: ### Route-Based Service Names Assign different service names to different route groups: ```typescript [nuxt.config.ts] export default defineNuxtConfig({ modules: ['evlog/nuxt'], evlog: { env: { service: 'default-service' }, routes: { '/api/auth/**': { service: 'auth-service' }, '/api/payment/**': { service: 'payment-service' }, '/api/booking/**': { service: 'booking-service' }, }, }, }) ``` ## Drain & Enrichers Use Nitro plugin hooks to send logs to external services and enrich them with additional context. ### Drain Plugin ```typescript [server/plugins/evlog-drain.ts] import type { DrainContext } from 'evlog' import { createAxiomDrain } from 'evlog/axiom' import { createDrainPipeline } from 'evlog/pipeline' const pipeline = createDrainPipeline({ batch: { size: 50, intervalMs: 5000 }, retry: { maxAttempts: 3 }, }) const drain = pipeline(createAxiomDrain()) export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', drain) }) ``` ### Enricher Plugin ```typescript [server/plugins/evlog-enrich.ts] import { createUserAgentEnricher, createGeoEnricher, createRequestSizeEnricher, createTraceContextEnricher, } from 'evlog/enrichers' const enrichers = [ createUserAgentEnricher(), createGeoEnricher(), createRequestSizeEnricher(), createTraceContextEnricher(), ] export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:enrich', (ctx) => { for (const enricher of enrichers) enricher(ctx) }) }) ``` ::callout{color="neutral" icon="i-lucide-arrow-right"} See the [Adapters](https://www.evlog.dev/integrate/adapters/overview) and [Enrichers](https://www.evlog.dev/use-cases/enrichers) docs for the full list of available drains and enrichers. :: ## Sampling ### Head Sampling Randomly keep a percentage of logs per level. Runs before the request completes. ```typescript [nuxt.config.ts] export default defineNuxtConfig({ modules: ['evlog/nuxt'], evlog: { sampling: { rates: { info: 10, warn: 50, debug: 5, error: 100, }, }, }, }) ``` Each level is a percentage from 0 to 100. Levels you don't configure default to 100% (keep everything). Error defaults to 100% even when other levels are configured. ### Tail Sampling Evaluate after the request completes and force-keep logs that match specific conditions, regardless of head sampling. ```typescript [nuxt.config.ts] export default defineNuxtConfig({ modules: ['evlog/nuxt'], evlog: { sampling: { rates: { info: 10 }, keep: [ { duration: 1000 }, { status: 400 }, { path: '/api/critical/**' }, ], }, }, }) ``` ### Custom Tail Sampling For conditions beyond status, duration, and path, use the `evlog:emit:keep` hook: ```typescript [server/plugins/evlog-sampling.ts] export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:emit:keep', (ctx) => { const user = ctx.context.user as { premium?: boolean } | undefined if (user?.premium) { ctx.shouldKeep = true } }) }) ``` ::callout{color="info" icon="i-lucide-info"} Errors are always kept by default. You have to explicitly set `error: 0` to drop them. :: ## Client Transport Send browser logs to your server for processing and draining alongside server-side events. ```typescript [nuxt.config.ts] export default defineNuxtConfig({ modules: ['evlog/nuxt'], evlog: { transport: { enabled: true, endpoint: '/api/_evlog/ingest', }, }, }) ``` ### How It Works 1. Client calls `log.info({ action: 'click', button: 'submit' })` 2. Log is sent to `/api/_evlog/ingest` via POST 3. Server enriches with environment context 4. `evlog:drain` hook is called with `source: 'client'` 5. External services receive the log ### Client Identity Attach user context to every client log with `setIdentity`: ```typescript [Nuxt (auto-imported)] // After login setIdentity({ userId: 'usr_123', orgId: 'org_456' }) log.info({ action: 'checkout' }) // -> { userId: 'usr_123', orgId: 'org_456', action: 'checkout', ... } // After logout clearIdentity() ``` ### Syncing Identity with Auth Use a route middleware to keep identity in sync with your auth state: ```typescript [middleware/identity.global.ts] export default defineNuxtRouteMiddleware(() => { const { user } = useAuth() if (user.value) { setIdentity({ userId: user.value.id, email: user.value.email }) } else { clearIdentity() } }) ``` ## Production Tips Use Nuxt's `$production` override to keep full logging in development while sampling and disabling console output in production: ```typescript [nuxt.config.ts] export default defineNuxtConfig({ modules: ['evlog/nuxt'], evlog: { env: { service: 'my-app' }, }, $production: { evlog: { console: false, sampling: { rates: { info: 10, warn: 50, debug: 0 }, keep: [{ duration: 1000 }, { status: 400 }], }, }, }, }) ``` ## Next Steps Deepen your **Nuxt** integration: - [Wide Events](https://www.evlog.dev/learn/wide-events): Design comprehensive events with context layering - [Adapters](https://www.evlog.dev/integrate/adapters/overview): Send logs to Axiom, Sentry, PostHog, and more - [Sampling](https://www.evlog.dev/learn/sampling): Control log volume with head and tail sampling - [Structured Errors](https://www.evlog.dev/learn/structured-errors): Throw errors with `why`, `fix`, and `link` fields # Next.js evlog integrates with Next.js App Router via a `createEvlog()` factory that provides `withEvlog()` handler wrapper, `useLogger()`, and typed exports. One file, zero global state. ::prompt --- actions: - copy - cursor - claude description: Set up evlog in my Next.js app icon: i-simple-icons-nextdotjs --- Set up evlog in my Next.js app with wide events and structured errors. - Install evlog: pnpm add evlog - Create lib/evlog.ts with createEvlog() to export withEvlog, useLogger, createError - Set service name and optional sampling/drain config - Wrap API route handlers with withEvlog() - Use useLogger() inside handlers to build wide events with log.set() - Throw errors with createError({ message, status, why, fix }) - Wide events are auto-emitted when each request completes Docs: {rel=""nofollow""} Adapters: {rel=""nofollow""} :: ## Quick Start ### 1. Install ::code-group ```bash [pnpm] pnpm add evlog ``` ```bash [bun] bun add evlog ``` ```bash [yarn] yarn add evlog ``` ```bash [npm] npm install evlog ``` :: ### 2. Create your evlog instance ```typescript [lib/evlog.ts] import { createEvlog } from 'evlog/next' export const { withEvlog, useLogger, log, createError } = createEvlog({ service: 'my-app', }) ``` ### 3. Wrap a route handler ```typescript [app/api/hello/route.ts] import { withEvlog, useLogger } from '@/lib/evlog' export const GET = withEvlog(async () => { const log = useLogger() log.set({ action: 'hello' }) return Response.json({ message: 'Hello!' }) }) ``` ## Instrumentation Next.js supports an [`instrumentation.ts`](https://nextjs.org/docs/app/guides/instrumentation){rel=""nofollow""} file at the project root for server startup hooks and error reporting. evlog provides `createInstrumentation()` to integrate with this pattern. ::callout{color="info" icon="i-lucide-info"} These two APIs serve different purposes and can be used independently or together: - **`createEvlog()`**: per-request wide events via `withEvlog()` - **`createInstrumentation()`**: server startup (`register()`) + unhandled error reporting (`onRequestError()`) across all routes, including SSR and RSC - Both can coexist: `register()` initializes and locks the logger first, so `createEvlog()` respects it. Each can have its own `drain`. :: ### 1. Split instrumentation from route config Keep Node-only imports (`evlog/fs`, heavy adapters) out of root `instrumentation.ts`. Use `defineNodeInstrumentation` with an options object — evlog loads `createInstrumentation` on Node.js only, without a visible `import()` in your file. - Root `instrumentation.ts` → `defineNodeInstrumentation({ service, ... })` - `lib/evlog.ts` → `createEvlog()` and Node-only drains for API routes ```typescript [instrumentation.ts] import { defineNodeInstrumentation } from 'evlog/next/instrumentation' export const { register, onRequestError } = defineNodeInstrumentation({ service: 'my-app', captureOutput: true, }) ``` ```typescript [lib/evlog.ts] import { createEvlog } from 'evlog/next' import { createFsDrain } from 'evlog/fs' export const { withEvlog, useLogger, log, createError } = createEvlog({ service: 'my-app', drain: createFsDrain(), }) ``` ### 2. Wire up instrumentation.ts Next.js evaluates `instrumentation.ts` in both Node.js and Edge runtimes. `defineNodeInstrumentation` gates on `NEXT_RUNTIME === 'nodejs'` and loads the Node-only factory internally. ### Custom behavior (evlog + your code) Pass a **loader callback** when you need extra startup work alongside evlog: ```typescript [instrumentation.ts] import { defineNodeInstrumentation } from 'evlog/next/instrumentation' export const { register, onRequestError } = defineNodeInstrumentation(async () => { const { createInstrumentation } = await import('evlog/next/instrumentation/create') const { register: evlogRegister, onRequestError: evlogOnRequestError } = createInstrumentation({ service: 'my-app', captureOutput: true, }) return { async register() { await evlogRegister() // e.g. OpenTelemetry, feature flags, custom one-off init }, onRequestError: evlogOnRequestError, } }) ``` Keep `lib/evlog.ts` for `createEvlog()` and Node-only drains. Route handlers import `@/lib/evlog`. Next.js automatically calls these exports: - `register()`: Runs once when the server starts. Initializes the evlog logger with your configured drain, sampling, and options. When `captureOutput` is enabled, `stdout` and `stderr` writes are captured as structured log events. - `onRequestError()`: Called on every unhandled request error. Emits a structured error log with the error message, digest, stack trace, request path/method, and routing context (`routerKind`, `routePath`, `routeType`, `renderSource`). ::callout{color="info" icon="i-lucide-info"} `captureOutput` only activates in the Node.js runtime ( `NEXT_RUNTIME === 'nodejs'` ). It patches `process.stdout.write` and `process.stderr.write` to emit structured `log.info` / `log.error` events. When `silent` is `false` (the default), captured output is shown once as structured terminal output — the raw write is not duplicated. Set `silent: true` to keep the original passthrough alongside drain delivery. Known Next.js Edge bundler warnings are filtered by default so they are not re-emitted as evlog errors. :: ### Configuration `defineNodeInstrumentation()` and `createInstrumentation()` accept global logger options (`enabled`, `service`, `env`, `pretty`, `silent`, `sampling`, `stringify`, `drain`) plus: | Option | Type | Default | Description | | --------------- | -------------------------------- | ------- | ---------------------------------------------- | | `captureOutput` | `boolean | CaptureOutputOptions` | `false` | Capture stdout/stderr as structured log events | `CaptureOutputOptions` fields: | Field | Type | Default | Description | | -------- | --------------------- | ----------------------------- | ---------------------------------------------- | | `stdout` | `boolean` | `true` | Capture stdout writes | | `stderr` | `boolean` | `true` | Capture stderr writes | | `ignore` | `(string | RegExp)[]` | Next.js Edge bundler warnings | Skip re-emitting matching chunks as log events | ```typescript [instrumentation.ts] defineNodeInstrumentation({ captureOutput: { stderr: true, ignore: [/my-noisy-dep/, 'benign warning'], }, }) ``` ## Production Configuration A real-world `lib/evlog.ts` with enrichers, batched drain, tail sampling, and route-based service names: ::code-collapse ```typescript [lib/evlog.ts] import type { DrainContext } from 'evlog' import { createEvlog } from 'evlog/next' import { createUserAgentEnricher, createRequestSizeEnricher } from 'evlog/enrichers' import { createAxiomDrain } from 'evlog/axiom' import { createDrainPipeline } from 'evlog/pipeline' // 1. Enrichers - add derived context to every event const enrichers = [createUserAgentEnricher(), createRequestSizeEnricher()] // 2. Pipeline - batch events before sending const pipeline = createDrainPipeline({ batch: { size: 50, intervalMs: 5000 } }) // 3. Drain - send batched events to Axiom const drain = pipeline(createAxiomDrain({ dataset: 'logs', apiKey: process.env.AXIOM_API_KEY!, })) export const { withEvlog, useLogger, log, createError } = createEvlog({ service: 'my-app', // 4. Head sampling - keep 10% of info logs sampling: { rates: { info: 10 }, keep: [ { status: 400 }, // Always keep errors { duration: 1000 }, // Always keep slow requests { path: '/api/critical/**' }, // Always keep critical paths ], }, // 5. Route-based service names routes: { '/api/auth/**': { service: 'auth-service' }, '/api/payment/**': { service: 'payment-service' }, '/api/booking/**': { service: 'booking-service' }, }, // 6. Custom tail sampling - business logic keep: (ctx) => { const user = ctx.context.user as { premium?: boolean } | undefined if (user?.premium) ctx.shouldKeep = true }, // 7. Enrich every event with user agent, request size, and deployment info enrich: (ctx) => { for (const enricher of enrichers) enricher(ctx) ctx.event.deploymentId = process.env.VERCEL_DEPLOYMENT_ID ctx.event.region = process.env.VERCEL_REGION }, drain, }) ``` :: ## Wide Events Build up context progressively through your handler. One request = one wide event: ```typescript [app/api/checkout/route.ts] import { withEvlog, useLogger } from '@/lib/evlog' export const POST = withEvlog(async (request: Request) => { const log = useLogger() const body = await request.json() // Stage 1: User context log.set({ user: { id: body.userId, plan: 'enterprise' }, }) // Stage 2: Cart context log.set({ cart: { items: body.items.length, total: body.total, currency: 'USD' }, }) // Stage 3: Payment context const payment = await processPayment(body) log.set({ payment: { method: payment.method, cardLast4: payment.last4 }, }) return Response.json({ success: true, orderId: payment.orderId }) }) ``` All fields are merged into a single wide event emitted when the handler completes (or when a streaming response body finishes, so AI SDK metadata is included): ```bash [Output (Pretty)] 10:23:45.612 INFO [my-app] POST /api/checkout 200 in 145ms ├─ user: id=usr_123 plan=enterprise ├─ cart: items=3 total=14999 currency=USD ├─ payment: method=card cardLast4=4242 └─ requestId: a1b2c3d4-... ``` ## Background work (`log.fork`) Inside `withEvlog`, `useLogger()` returns a logger with **`fork`** for child wide events. See [Wide events — After emit](https://www.evlog.dev/learn/wide-events#after-emit-sealing-and-background-work). ```typescript [app/api/orders/route.ts] import { withEvlog, useLogger } from '@/lib/evlog' export const POST = withEvlog(async () => { const log = useLogger() log.fork!('enqueue', async () => { const child = useLogger() child.set({ job: 'queued' }) }) return Response.json({ ok: true }) }) ``` ## Error Handling Use `createError` for structured errors with `why`, `fix`, and `link` fields that help developers debug in both logs and API responses: ::code-collapse ```typescript [app/api/payment/process/route.ts] import { withEvlog, useLogger, createError } from '@/lib/evlog' export const POST = withEvlog(async (request: Request) => { const log = useLogger() const body = await request.json() log.set({ payment: { amount: body.amount } }) if (body.amount <= 0) { throw createError({ status: 400, message: 'Invalid payment amount', why: 'The amount must be a positive number', fix: 'Pass a positive integer in cents (e.g. 4999 for $49.99)', link: 'https://docs.example.com/api/payments#amount', }) } const result = await chargeCard(body) if (!result.success) { log.error(new Error(`Payment declined: ${result.reason}`)) throw createError({ status: 402, message: 'Payment declined', why: `Card declined by issuer: ${result.reason}`, fix: 'Try a different payment method or contact your bank', }) } return Response.json({ success: true }) }) ``` :: `withEvlog()` catches `EvlogError` and returns a structured JSON response (like Nitro does for Nuxt): ```json [Response (402)] { "name": "EvlogError", "message": "Payment declined", "status": 402, "data": { "why": "Card declined by issuer: insufficient_funds", "fix": "Try a different payment method or contact your bank" } } ``` In the terminal, the error renders inside the wide event — error block first, then request context. Colors and tree connectors render in the terminal; the example below omits ANSI for readability. ```bash [Terminal output] ERROR [app] POST /api/payment/process 402 in 12ms ├─ error: Payment declined │ at app/api/payment/process/route.ts:336 │ ❯ 336 ┃ throw createError({ message: 'Payment declined', ... }) │ Why: Card declined by issuer: insufficient_funds │ Fix: Try a different payment method or contact your bank │ stack (3 frames hidden in node_modules) └─ payment: amount=4999 ``` ### Parsing Errors on the Client Use `parseError` to extract the structured fields from any error, whether it's a fetch response, an `EvlogError`, or a plain `Error` object: ```tsx [app/components/PaymentForm.tsx] 'use client' import { parseError } from 'evlog' async function handleSubmit(formData: FormData) { try { const res = await fetch('/api/payment/process', { method: 'POST', body: JSON.stringify({ amount: Number(formData.get('amount')) }), }) if (!res.ok) throw { data: await res.json(), status: res.status } } catch (error) { const { message, status, why, fix, link } = parseError(error) // message: "Payment declined" // why: "Card declined by issuer: insufficient_funds" // fix: "Try a different payment method or contact your bank" } } ``` `parseError` normalizes any error shape into a flat `{ message, status, why?, fix?, link? }` object, so your UI code never has to dig through nested `data.data` or check for different error formats. ## Configuration ::callout{color="info" icon="i-lucide-book-open"} See the [Configuration reference](https://www.evlog.dev/reference/configuration) for the full list of shared options ( `enabled` , `pretty` , `silent` , `sampling` , middleware options, etc.). :: The `createEvlog()` factory accepts the following options: | Option | Type | Default | Description | | ---------------- | ------------------------------------ | ------------- | ------------------------------------ | | `service` | `string` | `'app'` | Service name shown in logs | | `environment` | `string` | Auto-detected | Environment name | | `include` | `string[]` | `undefined` | Route patterns to log | | `exclude` | `string[]` | `undefined` | Route patterns to exclude | | `routes` | `Record` | `undefined` | Route-specific service configuration | | `sampling.rates` | `object` | `undefined` | Head sampling rates per log level | | `sampling.keep` | `array` | `undefined` | Tail sampling conditions | | `keep` | `(ctx: TailSamplingContext) => void` | `undefined` | Custom tail sampling callback | | `drain` | `DrainFunction` | `undefined` | Drain adapter for external services | | `enrich` | `(ctx: EnrichContext) => void` | `undefined` | Event enrichment callback | ## Tail Sampling Combine rule-based and custom tail sampling to always capture what matters, even when head sampling drops most logs: ```typescript [lib/evlog.ts] export const { withEvlog, useLogger } = createEvlog({ service: 'my-app', sampling: { rates: { info: 10 }, // Only keep 10% of info logs keep: [ { status: 400 }, // Always keep 4xx/5xx { duration: 1000 }, // Always keep slow requests { path: '/api/critical/**' }, // Always keep critical paths ], }, // Custom: always keep premium user requests keep: (ctx) => { const user = ctx.context.user as { premium?: boolean } | undefined if (user?.premium) ctx.shouldKeep = true }, }) ``` The `keep` rules use OR logic: any match forces the event through regardless of head sampling. ## Middleware Set `x-request-id` and `x-evlog-start` headers so `withEvlog()` can correlate timing across the middleware -> handler chain: ```typescript [proxy.ts] import { evlogMiddleware } from 'evlog/next' export const proxy = evlogMiddleware() export const config = { matcher: ['/api/:path*'], } ``` ::callout{color="info" icon="i-lucide-info"} Older versions of Next.js use `middleware.ts` instead of `proxy.ts` . The evlog middleware works with both, so just import from `evlog/next` regardless. :: ## Server Actions `withEvlog()` also works with Server Actions. Wrap your action to get full request-scoped logging: ```typescript [app/actions/checkout.ts] 'use server' import { withEvlog, useLogger } from '@/lib/evlog' export const checkout = withEvlog(async (formData: FormData) => { const log = useLogger() log.set({ action: 'checkout', cartId: formData.get('cartId') }) // ... }) ``` ## Client Provider Wrap your root layout with `EvlogProvider` to enable client-side logging and transport: ```tsx [app/layout.tsx] import { EvlogProvider } from 'evlog/next/client' export default function Layout({ children }: { children: React.ReactNode }) { return ( {children} ) } ``` ## Client Logging Use `log` in any client component. Identity is preserved across all logs and transported to the server: ```tsx [app/components/Dashboard.tsx] 'use client' import { log, setIdentity, clearIdentity } from 'evlog/next/client' export function Dashboard({ user }: { user: { id: string } }) { // Set identity once - all subsequent logs include it useEffect(() => { setIdentity({ userId: user.id }) return () => clearIdentity() }, [user.id]) return ( ) } ``` ## HTTP drain For advanced use cases, send structured `DrainContext` events directly from the browser to a custom endpoint: ```typescript [lib/http-drain.ts] import { createHttpLogDrain } from 'evlog/http' const drain = createHttpLogDrain({ drain: { endpoint: '/api/evlog/http-ingest' }, pipeline: { batch: { size: 10, intervalMs: 5000 } }, }) drain(drainEvent) await drain.flush() ``` The server endpoint receives batched events: ```typescript [app/api/evlog/http-ingest/route.ts] export async function POST(request: Request) { const events = await request.json() // Forward to your drain pipeline, Axiom, etc. return new Response(null, { status: 204 }) } ``` ## Run Locally ```bash [Terminal] git clone https://github.com/hugorcd/evlog.git cd evlog/examples/nextjs pnpm install pnpm run dev ``` Open {rel=""nofollow""} to explore the example. ::card-group :::card --- icon: i-simple-icons-github title: Source Code to: https://github.com/hugorcd/evlog/tree/main/examples/nextjs --- Browse the complete Next.js example source on GitHub. ::: :: ## Next Steps Deepen your **Next.js** integration: - [Wide Events](https://www.evlog.dev/learn/wide-events): Design comprehensive events with context layering - [Adapters](https://www.evlog.dev/integrate/adapters/overview): Send logs to Axiom, Sentry, PostHog, and more - [Sampling](https://www.evlog.dev/learn/sampling): Control log volume with head and tail sampling - [Structured Errors](https://www.evlog.dev/learn/structured-errors): Throw errors with `why`, `fix`, and `link` fields # SvelteKit The `evlog/sveltekit` adapter provides `handle` and `handleError` hooks that auto-create a request-scoped logger accessible via `event.locals.log` and `useLogger()`, emitting a wide event when the response completes. ::prompt --- actions: - copy - cursor - claude description: Set up evlog in my SvelteKit app icon: i-simple-icons-svelte --- Set up evlog in my SvelteKit app. - Install evlog: pnpm add evlog - Add evlog/vite plugin to vite.config.ts with service name (handles auto-init, debug stripping) - Export handle and handleError from evlog/sveltekit in hooks.server.ts - Access the logger via event.locals.log or useLogger() in routes and services - Use log.set() to accumulate context, throw createError() for structured errors - Wide events are auto-emitted when each request completes Docs: {rel=""nofollow""} Adapters: {rel=""nofollow""} :: ## Quick Start ### 1. Install ::code-group ```bash [pnpm] pnpm add evlog ``` ```bash [bun] bun add evlog ``` ```bash [yarn] yarn add evlog ``` ```bash [npm] npm install evlog ``` :: ### 2. Add the Vite plugin ```typescript [vite.config.ts] import { sveltekit } from '@sveltejs/kit/vite' import evlog from 'evlog/vite' import { defineConfig } from 'vite' export default defineConfig({ plugins: [ sveltekit(), evlog({ service: 'my-api', }), ], }) ``` See the [Vite Plugin docs](https://www.evlog.dev/reference/vite-plugin) for all options. ### 3. Create hooks ```typescript [src/hooks.server.ts] import { createEvlogHooks } from 'evlog/sveltekit' export const { handle, handleError } = createEvlogHooks() ``` ### 4. Type your locals ```typescript [src/app.d.ts] import type { RequestLogger } from 'evlog' declare global { namespace App { interface Locals { log: RequestLogger } } } export {} ``` ## Wide Events Build up context progressively through your handler. One request = one wide event: ```typescript [src/routes/api/users/[id\\]/+server.ts] import { json } from '@sveltejs/kit' import type { RequestHandler } from './$types' export const GET: RequestHandler = async ({ locals, params }) => { locals.log.set({ user: { id: params.id } }) const user = await db.findUser(params.id) locals.log.set({ user: { name: user.name, plan: user.plan } }) const orders = await db.findOrders(params.id) locals.log.set({ orders: { count: orders.length, totalRevenue: sum(orders) } }) return json({ user, orders }) } ``` All fields are merged into a single wide event emitted when the request completes: ```bash [Terminal output] 14:58:15 INFO [my-api] GET /api/users/usr_123 200 in 12ms ├─ orders: count=2 totalRevenue=6298 ├─ user: id=usr_123 name=Alice plan=pro └─ requestId: 4a8ff3a8-... ``` ## useLogger() Use `useLogger()` to access the request-scoped logger from anywhere in the call stack without passing locals through your service layer: ```typescript [src/lib/services/user.ts] import { useLogger } from 'evlog/sveltekit' export async function findUser(id: string) { const log = useLogger() log.set({ user: { id } }) const user = await db.findUser(id) log.set({ user: { name: user.name, plan: user.plan } }) return user } ``` ```typescript [src/routes/api/users/[id\\]/+server.ts] import { json } from '@sveltejs/kit' import { findUser } from '$lib/services/user' import type { RequestHandler } from './$types' export const GET: RequestHandler = async ({ params }) => { const user = await findUser(params.id) return json(user) } ``` Both `event.locals.log` and `useLogger()` return the same logger instance. `useLogger()` uses `AsyncLocalStorage` to propagate the logger across async boundaries. ## Background work (`log.fork`) Use `locals.log.fork(label, fn)` for a child wide event. See [Wide events — After emit](https://www.evlog.dev/learn/wide-events#after-emit-sealing-and-background-work). ```typescript [src/routes/api/orders/+server.ts] import { useLogger } from 'evlog/sveltekit' import type { RequestHandler } from './$types' export const POST: RequestHandler = async ({ locals }) => { locals.log.fork!('process', async () => { const log = useLogger() log.set({ step: 'done' }) }) return new Response(JSON.stringify({ ok: true })) } ``` ## Error Handling Use `createError` for structured errors with `why`, `fix`, and `link` fields. The `handleError` hook captures thrown errors automatically: ```typescript [src/routes/api/checkout/+server.ts] import { json } from '@sveltejs/kit' import { createError } from 'evlog' import type { RequestHandler } from './$types' export const POST: RequestHandler = async ({ locals, request }) => { const { cartId } = await request.json() locals.log.set({ cart: { id: cartId } }) throw createError({ message: 'Payment failed', status: 402, why: 'Card declined by issuer', fix: 'Try a different payment method', link: 'https://docs.example.com/payments/declined', }) } ``` The error is captured and logged with both the custom context and structured error fields: ```bash [Terminal output] 14:58:20 ERROR [my-api] POST /api/checkout 402 in 3ms ├─ error: name=EvlogError message=Payment failed status=402 ├─ cart: id=cart_456 └─ requestId: 880a50ac-... ``` ## Configuration See the [Configuration reference](https://www.evlog.dev/reference/configuration) for all available options (`initLogger`, middleware options, sampling, silent mode, etc.). ## Drain & Enrichers Configure drain adapters and enrichers directly in the hooks options: ```typescript [src/hooks.server.ts] import { createEvlogHooks } from 'evlog/sveltekit' import { createAxiomDrain } from 'evlog/axiom' import { createUserAgentEnricher } from 'evlog/enrichers' const userAgent = createUserAgentEnricher() export const { handle, handleError } = createEvlogHooks({ drain: createAxiomDrain(), enrich: (ctx) => { userAgent(ctx) ctx.event.region = process.env.FLY_REGION }, }) ``` ### Pipeline (Batching & Retry) For production, wrap your adapter with `createDrainPipeline` to batch events and retry on failure: ```typescript [src/hooks.server.ts] import type { DrainContext } from 'evlog' import { createEvlogHooks } from 'evlog/sveltekit' import { createAxiomDrain } from 'evlog/axiom' import { createDrainPipeline } from 'evlog/pipeline' const pipeline = createDrainPipeline({ batch: { size: 50, intervalMs: 5000 }, retry: { maxAttempts: 3 }, }) const drain = pipeline(createAxiomDrain()) export const { handle, handleError } = createEvlogHooks({ drain }) ``` ::callout{color="info" icon="i-lucide-info"} Call `drain.flush()` on server shutdown to ensure all buffered events are sent. See the [Pipeline docs](https://www.evlog.dev/extend/drain-pipeline) for all options. :: ## Tail Sampling Use `keep` to force-retain specific events regardless of head sampling: ```typescript [src/hooks.server.ts] export const { handle, handleError } = createEvlogHooks({ drain: createAxiomDrain(), keep: (ctx) => { if (ctx.duration && ctx.duration > 2000) ctx.shouldKeep = true }, }) ``` ## Route Filtering Control which routes are logged with `include` and `exclude` patterns: ```typescript [src/hooks.server.ts] export const { handle, handleError } = createEvlogHooks({ include: ['/api/**'], exclude: ['/_internal/**', '/health'], routes: { '/api/auth/**': { service: 'auth-service' }, '/api/payment/**': { service: 'payment-service' }, }, }) ``` ## Run Locally ```bash [Terminal] git clone https://github.com/hugorcd/evlog.git cd evlog pnpm install pnpm example sveltekit ``` Open {rel=""nofollow""} to explore the interactive test UI. ::card-group :::card --- icon: i-simple-icons-github title: Source Code to: https://github.com/hugorcd/evlog/tree/main/examples/sveltekit --- Browse the complete SvelteKit example source on GitHub. ::: :: ## Next Steps Deepen your **SvelteKit** integration: - [Wide Events](https://www.evlog.dev/learn/wide-events): Design comprehensive events with context layering - [Adapters](https://www.evlog.dev/integrate/adapters/overview): Send logs to Axiom, Sentry, PostHog, and more - [Sampling](https://www.evlog.dev/learn/sampling): Control log volume with head and tail sampling - [Structured Errors](https://www.evlog.dev/learn/structured-errors): Throw errors with `why`, `fix`, and `link` fields # Nitro evlog provides modules for both Nitro v3 and Nitro v2 (nitropack). The module hooks into the request lifecycle, creating a request-scoped logger accessible via `useLogger(event)`, and emits a wide event when the response completes. ::prompt --- actions: - copy - cursor - claude description: Set up evlog in my Nitro app icon: i-custom-nitro --- Set up evlog in my Nitro app. - Install evlog: pnpm add evlog - Import the evlog module in nitro.config.ts (evlog/nitro for v2, evlog/nitro/v3 for v3) - Configure env.service with your app name - Use useLogger(event) in route handlers to build wide events - Use log.set() to accumulate context throughout the request - Throw errors with createError({ message, status, why, fix }) - Wide events are auto-emitted when each request completes Docs: {rel=""nofollow""} Adapters: {rel=""nofollow""} :: ## Quick Start ### 1. Install ::code-group ```bash [pnpm] pnpm add evlog ``` ```bash [bun] bun add evlog ``` ```bash [yarn] yarn add evlog ``` ```bash [npm] npm install evlog ``` :: ### 2. Add the module ::code-group ```typescript [nitro.config.ts (v3)] import { defineConfig } from 'nitro' import evlog from 'evlog/nitro/v3' export default defineConfig({ modules: [ evlog({ env: { service: 'my-app' }, }), ], }) ``` ```typescript [nitro.config.ts (v2)] import { defineNitroConfig } from 'nitropack/config' import evlog from 'evlog/nitro' export default defineNitroConfig({ modules: [ evlog({ env: { service: 'my-app' }, }), ], }) ``` :: ## Wide Events Build up context progressively throughout a request with `useLogger(event)`. evlog emits a single wide event when the request completes. ::code-group ```typescript [routes/api/checkout.post.ts (v3)] import { defineHandler } from 'nitro/h3' import { useLogger } from 'evlog/nitro/v3' export default defineHandler(async (event) => { const log = useLogger(event) const body = await readBody(event) log.set({ user: { id: body.userId } }) log.set({ cart: { items: body.items.length, total: body.total } }) return { success: true } }) ``` ```typescript [routes/api/checkout.post.ts (v2)] import { defineEventHandler, readBody } from 'h3' import { useLogger } from 'evlog/nitro' export default defineEventHandler(async (event) => { const log = useLogger(event) const body = await readBody(event) log.set({ user: { id: body.userId } }) log.set({ cart: { items: body.items.length, total: body.total } }) return { success: true } }) ``` :: One request, one log line with all context: ```bash [Terminal output] 10:23:45 INFO [my-app] POST /api/checkout 200 in 145ms ├─ user: id=usr_123 ├─ cart: items=3 total=14999 └─ requestId: a1b2c3d4-... ``` Nitro uses **`useLogger(event)`** (event-bound scope), not `AsyncLocalStorage`, so **`log.fork()` is not available** here yet. For AI SDK streaming responses, evlog defers wide-event emit until the response body finishes so `createAILogger(log)` metadata stays on the same request event. Post-emit warnings only apply when code calls `set()` after the wide event has actually emitted — for example in non-streaming handlers or background work. See [Wide events — After emit](https://www.evlog.dev/learn/wide-events#after-emit-sealing-and-background-work). ## Error Handling `createError` produces structured errors with `why`, `fix`, and `link` fields that help both humans and AI agents understand what went wrong. ::code-group ```typescript [routes/api/payment.post.ts (v3)] import { defineHandler } from 'nitro/h3' import { useLogger, createError } from 'evlog/nitro/v3' export default defineHandler(async (event) => { const log = useLogger(event) throw createError({ status: 402, message: 'Payment failed', why: 'Card declined by issuer', fix: 'Try a different payment method', }) }) ``` ```typescript [routes/api/payment.post.ts (v2)] import { defineEventHandler } from 'h3' import { useLogger } from 'evlog/nitro' import { createError } from 'evlog' export default defineEventHandler(async (event) => { const log = useLogger(event) throw createError({ status: 402, message: 'Payment failed', why: 'Card declined by issuer', fix: 'Try a different payment method', }) }) ``` :: ::callout{color="info" icon="i-lucide-info"} In Nitro v3, import `createError` from `evlog/nitro/v3` \- it wraps the Nitro error handler. In Nitro v2, import `createError` from `evlog` directly. :: ## Configuration See the [Configuration reference](https://www.evlog.dev/reference/configuration) for all available options (`enabled`, `pretty`, `silent`, `sampling`, etc.). ### Route Filtering Use `include` and `exclude` to control which routes are logged, and `routes` to assign different service names to different route groups: ::code-group ```typescript [nitro.config.ts (v3)] import { defineConfig } from 'nitro' import evlog from 'evlog/nitro/v3' export default defineConfig({ modules: [ evlog({ include: ['/api/**'], exclude: ['/api/health'], routes: { '/api/auth/**': { service: 'auth-service' }, '/api/payment/**': { service: 'payment-service' }, }, }) ], }) ``` ```typescript [nitro.config.ts (v2)] import { defineNitroConfig } from 'nitropack/config' import evlog from 'evlog/nitro' export default defineNitroConfig({ modules: [ evlog({ include: ['/api/**'], exclude: ['/api/health'], routes: { '/api/auth/**': { service: 'auth-service' }, '/api/payment/**': { service: 'payment-service' }, }, }) ], }) ``` :: ::callout{color="warning" icon="i-lucide-alert-triangle"} **Exclusions take precedence.** If a path matches both `include` and `exclude` , it will be excluded. :: ## Drain & Enrichers Use Nitro plugin hooks to send logs to external services and enrich them with additional context. ### Drain Plugin ```typescript [server/plugins/evlog-drain.ts] import type { DrainContext } from 'evlog' import { createAxiomDrain } from 'evlog/axiom' import { createDrainPipeline } from 'evlog/pipeline' const pipeline = createDrainPipeline({ batch: { size: 50, intervalMs: 5000 }, retry: { maxAttempts: 3 }, }) const drain = pipeline(createAxiomDrain()) export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', drain) }) ``` ::callout{color="info" icon="i-lucide-info"} For Nitro v3 standalone, use `definePlugin` from `nitro` instead of `defineNitroPlugin` . :: ### Enricher Plugin ```typescript [server/plugins/evlog-enrich.ts] import { createUserAgentEnricher, createGeoEnricher } from 'evlog/enrichers' const enrichers = [createUserAgentEnricher(), createGeoEnricher()] export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:enrich', (ctx) => { for (const enricher of enrichers) enricher(ctx) }) }) ``` ::callout{color="neutral" icon="i-lucide-arrow-right"} See the [Adapters](https://www.evlog.dev/integrate/adapters/overview) and [Enrichers](https://www.evlog.dev/use-cases/enrichers) docs for the full list of available drains and enrichers. :: ## Sampling ### Head Sampling Randomly keep a percentage of logs per level. Runs before the request completes. ::code-group ```typescript [nitro.config.ts (v3)] import { defineConfig } from 'nitro' import evlog from 'evlog/nitro/v3' export default defineConfig({ modules: [ evlog({ sampling: { rates: { info: 10, warn: 50, debug: 5 }, keep: [ { duration: 1000 }, { status: 400 }, ], }, }) ], }) ``` ```typescript [nitro.config.ts (v2)] import { defineNitroConfig } from 'nitropack/config' import evlog from 'evlog/nitro' export default defineNitroConfig({ modules: [ evlog({ sampling: { rates: { info: 10, warn: 50, debug: 5 }, keep: [ { duration: 1000 }, { status: 400 }, ], }, }) ], }) ``` :: Each level is a percentage from 0 to 100. Levels you don't configure default to 100% (keep everything). ### Custom Tail Sampling For conditions beyond status, duration, and path, use the `evlog:emit:keep` hook: ```typescript [server/plugins/evlog-sampling.ts] export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:emit:keep', (ctx) => { const user = ctx.context.user as { premium?: boolean } | undefined if (user?.premium) ctx.shouldKeep = true }) }) ``` ::callout{color="info" icon="i-lucide-info"} Errors are always kept by default. You have to explicitly set `error: 0` to drop them. :: ## Next Steps Deepen your **Nitro** integration: - [Wide Events](https://www.evlog.dev/learn/wide-events): Design comprehensive events with context layering - [Adapters](https://www.evlog.dev/integrate/adapters/overview): Send logs to Axiom, Sentry, PostHog, and more - [Sampling](https://www.evlog.dev/learn/sampling): Control log volume with head and tail sampling - [Structured Errors](https://www.evlog.dev/learn/structured-errors): Throw errors with `why`, `fix`, and `link` fields # TanStack Start TanStack Start uses [Nitro v3](https://www.evlog.dev/integrate/frameworks/nitro) as its server layer, so evlog integrates via the `evlog/nitro/v3` module. The same plugin-based hooks system applies. ::callout{color="info" icon="i-lucide-info"} **TanStack Router vs TanStack Start** : TanStack Router is a client-side router and doesn't need server-side logging. This page covers **TanStack Start** , the full-stack framework. If you're using TanStack Router in SPA mode, see [Client Logging](https://www.evlog.dev/use-cases/client-logging) instead. :: ::prompt --- actions: - copy - cursor - claude description: Set up evlog in my TanStack Start app icon: i-custom-tanstack --- Set up evlog in my TanStack Start app. - Install evlog: pnpm add evlog - Create nitro.config.ts with evlog/nitro/v3 module and experimental.asyncContext enabled - Configure env.service with your app name - Add evlogErrorHandler middleware to the root route for structured error responses - Access the logger via useRequest().context.log in route handlers - Use log.set() to accumulate context, throw createError() for structured errors Docs: {rel=""nofollow""} Adapters: {rel=""nofollow""} :: ## Quick Start Starting from a TanStack Start project created with `npm create @tanstack/start@latest`: ### 1. Install ::code-group ```bash [pnpm] pnpm add evlog ``` ```bash [bun] bun add evlog ``` ```bash [yarn] yarn add evlog ``` ```bash [npm] npm install evlog ``` :: ### 2. Add `nitro.config.ts` Create a `nitro.config.ts` at the project root to register the evlog module. Your `vite.config.ts` already has the `nitro()` plugin from the CLI, so no changes are needed there. ```typescript [nitro.config.ts] import { defineConfig } from 'nitro' import evlog from 'evlog/nitro/v3' export default defineConfig({ experimental: { asyncContext: true, }, modules: [ evlog({ env: { service: 'my-app' }, }), ], }) ``` Enabling `asyncContext` lets you access the request-scoped logger from anywhere in the call stack via `useRequest()`. ### 3. Error handling middleware TanStack Start has its own error handling layer that runs before Nitro's. To ensure `throw createError()` returns a proper JSON response with `why`, `fix`, and `link`, add the `evlogErrorHandler` middleware to your root route: ```typescript [src/routes/__root.tsx] import { createRootRoute } from '@tanstack/react-router' import { createMiddleware } from '@tanstack/react-start' import { evlogErrorHandler } from 'evlog/nitro/v3' export const Route = createRootRoute({ server: { middleware: [createMiddleware().server(evlogErrorHandler)], }, // ... head, shellComponent, etc. }) ``` That's it. evlog automatically captures every request as a wide event with method, path, status, and duration. ::callout{color="info" icon="i-custom-vite"} **Using Vite?** TanStack Start is Vite-based. The [`evlog/vite`](https://www.evlog.dev/reference/vite-plugin) plugin strips `log.debug()` from production builds and injects source locations, add it to your `vite.config.ts` alongside the TanStack Start plugin. :: ## Wide Events With `experimental.asyncContext: true`, use `useRequest()` from `nitro/context` to access the request-scoped logger and build up context progressively: ```typescript [src/routes/api/hello.ts] import { createFileRoute } from '@tanstack/react-router' import { useRequest } from 'nitro/context' import type { RequestLogger } from 'evlog' export const Route = createFileRoute('/api/hello')({ server: { handlers: { GET: async () => { const req = useRequest() const log = req.context.log as RequestLogger log.set({ user: { id: 'user_123', plan: 'pro' } }) log.set({ action: 'fetch_profile' }) log.set({ cache: { hit: true, ttl: 3600 } }) return Response.json({ ok: true }) }, }, }, }) ``` All fields are merged into a single wide event emitted when the request completes: ```bash [Terminal output] 14:58:15 INFO [my-app] GET /api/hello 200 in 52ms ├─ cache: hit=true ttl=3600 ├─ action: fetch_profile ├─ user: id=user_123 plan=pro └─ requestId: 4a8ff3a8-... ``` ::callout{color="info" icon="i-lucide-info"} `useRequest()` is an experimental Nitro v3 feature powered by `AsyncLocalStorage` . It works on Node.js and Bun runtimes. :: ## Error Handling Use `createError` for structured errors with `why`, `fix`, and `link` fields: ::code-collapse ```typescript [src/routes/api/checkout.ts] import { createFileRoute } from '@tanstack/react-router' import { useRequest } from 'nitro/context' import { createError } from 'evlog' import type { RequestLogger } from 'evlog' export const Route = createFileRoute('/api/checkout')({ server: { handlers: { POST: async ({ request }) => { const req = useRequest() const log = req.context.log as RequestLogger const body = await request.json() log.set({ user: { id: body.userId, plan: body.plan } }) log.set({ cart: { items: body.items, total: body.total } }) const result = await chargeCard(body) if (!result.success) { throw createError({ message: 'Payment failed', status: 402, why: 'Card declined by issuer', fix: 'Try a different payment method', link: 'https://docs.example.com/payments/declined', }) } return Response.json({ success: true, orderId: result.orderId }) }, }, }, }) ``` :: The error is captured and logged with both the custom context and structured error fields: ```bash [Terminal output] 14:58:20 ERROR [my-app] POST /api/checkout 402 in 104ms ├─ error: name=EvlogError message=Payment failed status=402 ├─ cart: items=3 total=9999 ├─ user: id=user_123 plan=pro └─ requestId: 880a50ac-... ``` ### Parsing Errors on the Client Use `parseError` to extract the structured fields from any error response: ```tsx [src/routes/checkout.tsx] import { parseError } from 'evlog' try { const res = await fetch('/api/checkout', { method: 'POST', body: JSON.stringify({ userId: 'user_123' }), }) if (!res.ok) throw { data: await res.json(), status: res.status } } catch (error) { const { message, status, why, fix, link } = parseError(error) } ``` ## Configuration See the [Configuration reference](https://www.evlog.dev/reference/configuration) for all available options (`initLogger`, middleware options, sampling, silent mode, etc.). ## Route Filtering Control which routes are logged with `include` and `exclude` in the module options: ```typescript [nitro.config.ts] import { defineConfig } from 'nitro' import evlog from 'evlog/nitro/v3' export default defineConfig({ experimental: { asyncContext: true }, modules: [ evlog({ env: { service: 'my-app' }, include: ['/api/**'], exclude: ['/_internal/**', '/health'], routes: { '/api/auth/**': { service: 'auth-service' }, '/api/payment/**': { service: 'payment-service' }, }, }), ], }) ``` ## Drain & Enrichers Since TanStack Start uses Nitro v3, configure drains and enrichers via Nitro plugins. Create a `server/plugins/` directory and register hooks: ```typescript [server/plugins/evlog-drain.ts] import { definePlugin } from 'nitro' import { createAxiomDrain } from 'evlog/axiom' export default definePlugin((nitroApp) => { const axiom = createAxiomDrain() nitroApp.hooks.hook('evlog:drain', axiom) }) ``` ```typescript [server/plugins/evlog-enrich.ts] import { definePlugin } from 'nitro' import { createUserAgentEnricher, createRequestSizeEnricher } from 'evlog/enrichers' export default definePlugin((nitroApp) => { const enrichers = [createUserAgentEnricher(), createRequestSizeEnricher()] nitroApp.hooks.hook('evlog:enrich', (ctx) => { for (const enricher of enrichers) enricher(ctx) }) }) ``` ::callout{color="info" icon="i-lucide-info"} See the [Adapters](https://www.evlog.dev/integrate/adapters/overview) and [Enrichers](https://www.evlog.dev/use-cases/enrichers) docs for all available drain adapters and enrichers. :: ### Pipeline (Batching & Retry) For production, wrap your adapter with `createDrainPipeline` to batch events and retry on failure: ```typescript [server/plugins/evlog-drain.ts] import { definePlugin } from 'nitro' import type { DrainContext } from 'evlog' import { createAxiomDrain } from 'evlog/axiom' import { createDrainPipeline } from 'evlog/pipeline' export default definePlugin((nitroApp) => { const pipeline = createDrainPipeline({ batch: { size: 50, intervalMs: 5000 }, retry: { maxAttempts: 3 }, }) const drain = pipeline(createAxiomDrain()) nitroApp.hooks.hook('evlog:drain', drain) }) ``` ::callout{color="info" icon="i-lucide-info"} Call `drain.flush()` on server shutdown to ensure all buffered events are sent. See the [Pipeline docs](https://www.evlog.dev/extend/drain-pipeline) for all options. :: ## Tail Sampling Use the `evlog:emit:keep` hook to force-retain specific events regardless of head sampling: ```typescript [server/plugins/evlog-keep.ts] import { definePlugin } from 'nitro' export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:emit:keep', (ctx) => { if (ctx.duration && ctx.duration > 2000) ctx.shouldKeep = true if (ctx.status && ctx.status >= 500) ctx.shouldKeep = true }) }) ``` ## Run Locally ```bash [Terminal] git clone https://github.com/hugorcd/evlog.git cd evlog/examples/tanstack-start pnpm install pnpm run dev ``` Open {rel=""nofollow""} and navigate to the evlog Demo page to test the API endpoints. ::card-group :::card --- icon: i-simple-icons-github title: Source Code to: https://github.com/hugorcd/evlog/tree/main/examples/tanstack-start --- Browse the complete TanStack Start example source on GitHub. ::: :: ## Next Steps Deepen your **TanStack Start** integration: - [Wide Events](https://www.evlog.dev/learn/wide-events): Design comprehensive events with context layering - [Adapters](https://www.evlog.dev/integrate/adapters/overview): Send logs to Axiom, Sentry, PostHog, and more - [Sampling](https://www.evlog.dev/learn/sampling): Control log volume with head and tail sampling - [Structured Errors](https://www.evlog.dev/learn/structured-errors): Throw errors with `why`, `fix`, and `link` fields # NestJS The `evlog/nestjs` module provides `EvlogModule.forRoot()` which registers a global middleware, creating a request-scoped logger accessible via `useLogger()` or `req.log`, emitting a wide event when the response completes. ::prompt --- actions: - copy - cursor - claude description: Set up evlog in my NestJS app icon: i-simple-icons-nestjs --- Set up evlog in my NestJS app. - Install evlog: pnpm add evlog - Import EvlogModule from 'evlog/nestjs' and add EvlogModule.forRoot() to AppModule imports - The global middleware auto-creates a request-scoped logger for every request - Use useLogger() in any controller or service to access the logger - Use log.set() to accumulate context, throw createError() for structured errors - Optionally pass drain, enrich, and keep callbacks to forRoot() Docs: {rel=""nofollow""} Adapters: {rel=""nofollow""} :: ## Quick Start ### 1. Install ::code-group ```bash [pnpm] pnpm add evlog @nestjs/common @nestjs/core @nestjs/platform-express ``` ```bash [bun] bun add evlog @nestjs/common @nestjs/core @nestjs/platform-express ``` ```bash [yarn] yarn add evlog @nestjs/common @nestjs/core @nestjs/platform-express ``` ```bash [npm] npm install evlog @nestjs/common @nestjs/core @nestjs/platform-express ``` :: ### 2. Register the module ```typescript [src/app.module.ts] import { Module } from '@nestjs/common' import { EvlogModule } from 'evlog/nestjs' @Module({ imports: [ EvlogModule.forRoot(), ], }) export class AppModule {} ``` ### 3. Bootstrap with evlog ```typescript [src/main.ts] import 'reflect-metadata' import { NestFactory } from '@nestjs/core' import { initLogger } from 'evlog' import { AppModule } from './app.module' initLogger({ env: { service: 'my-api' }, }) const app = await NestFactory.create(AppModule) await app.listen(3000) ``` `EvlogModule.forRoot()` registers as a global module, so the middleware is automatically applied to all routes. ## Wide Events Build up context progressively through your controllers and services. One request = one wide event: ```typescript [src/users.controller.ts] import { Controller, Get, Param } from '@nestjs/common' import { useLogger } from 'evlog/nestjs' @Controller('users') export class UsersController { @Get(':id') async findOne(@Param('id') id: string) { const log = useLogger() log.set({ user: { id } }) const user = await db.findUser(id) log.set({ user: { name: user.name, plan: user.plan } }) const orders = await db.findOrders(id) log.set({ orders: { count: orders.length, totalRevenue: sum(orders) } }) return { user, orders } } } ``` All fields are merged into a single wide event emitted when the request completes: ```bash [Terminal output] 14:58:15 INFO [my-api] GET /users/usr_123 200 in 12ms ├─ orders: count=2 totalRevenue=6298 ├─ user: id=usr_123 name=Alice plan=pro └─ requestId: 4a8ff3a8-... ``` ## useLogger() Use `useLogger()` to access the request-scoped logger from anywhere in the call stack without injecting the request object through your service layer: ```typescript [src/users.service.ts] import { useLogger } from 'evlog/nestjs' export class UsersService { async findUser(id: string) { const log = useLogger() log.set({ user: { id } }) const user = await db.findUser(id) log.set({ user: { name: user.name, plan: user.plan } }) return user } } ``` ```typescript [src/users.controller.ts] @Controller('users') export class UsersController { @Get(':id') findOne(@Param('id') id: string) { return this.usersService.findUser(id) } } ``` Both `req.log` and `useLogger()` return the same logger instance. `useLogger()` uses `AsyncLocalStorage` to propagate the logger across async boundaries. ## Background work (`log.fork`) Use `req.log.fork(label, fn)` (or the logger from `useLogger()` in the same request) for child wide events. See [Wide events — After emit](https://www.evlog.dev/learn/wide-events#after-emit-sealing-and-background-work). ```typescript [src/orders.controller.ts] import { useLogger } from 'evlog/nestjs' @Post() create(@Req() req: Express.Request) { req.log.fork!('enqueue', async () => { const log = useLogger() log.set({ queued: true }) }) return { ok: true } } ``` ## Error Handling Use `createError` for structured errors with `why`, `fix`, and `link` fields. Create a NestJS exception filter to log and format errors: ```typescript [src/evlog-exception.filter.ts] import { Catch } from '@nestjs/common' import type { ExceptionFilter, ArgumentsHost } from '@nestjs/common' import { parseError } from 'evlog' import { useLogger } from 'evlog/nestjs' @Catch() export class EvlogExceptionFilter implements ExceptionFilter { catch(exception: unknown, host: ArgumentsHost) { const response = host.switchToHttp().getResponse() const error = exception instanceof Error ? exception : new Error(String(exception)) try { useLogger().error(error) } catch { // Outside an evlog request scope — log is unavailable } const parsed = parseError(error) response.status(parsed.status).json({ message: parsed.message, why: parsed.why, fix: parsed.fix, link: parsed.link, }) } } ``` Apply it to your controllers: ```typescript [src/checkout.controller.ts] import { Controller, Get, UseFilters } from '@nestjs/common' import { createError } from 'evlog' import { EvlogExceptionFilter } from './evlog-exception.filter' @Controller() @UseFilters(new EvlogExceptionFilter()) export class CheckoutController { @Get('checkout') checkout() { throw createError({ message: 'Payment failed', status: 402, why: 'Card declined by issuer', fix: 'Try a different payment method', link: 'https://docs.example.com/payments/declined', }) } } ``` The error is captured and logged with both the custom context and structured error fields: ```bash [Terminal output] 14:58:20 ERROR [my-api] GET /checkout 402 in 3ms ├─ error: name=EvlogError message=Payment failed status=402 └─ requestId: 880a50ac-... ``` ## Configuration See the [Configuration reference](https://www.evlog.dev/reference/configuration) for all available options (`initLogger`, middleware options, sampling, silent mode, etc.). ## Drain & Enrichers Configure drain adapters and enrichers in `EvlogModule.forRoot()`: ```typescript [src/app.module.ts] import { Module } from '@nestjs/common' import { EvlogModule } from 'evlog/nestjs' import { createAxiomDrain } from 'evlog/axiom' import { createUserAgentEnricher } from 'evlog/enrichers' const userAgent = createUserAgentEnricher() @Module({ imports: [ EvlogModule.forRoot({ drain: createAxiomDrain(), enrich: (ctx) => { userAgent(ctx) ctx.event.region = process.env.FLY_REGION }, }), ], }) export class AppModule {} ``` ### Async Configuration Use `forRootAsync()` when options depend on other providers (e.g. `ConfigService`): ```typescript [src/app.module.ts] import { Module } from '@nestjs/common' import { ConfigModule, ConfigService } from '@nestjs/config' import { EvlogModule } from 'evlog/nestjs' import { createAxiomDrain } from 'evlog/axiom' @Module({ imports: [ ConfigModule.forRoot(), EvlogModule.forRootAsync({ imports: [ConfigModule], inject: [ConfigService], useFactory: (config: ConfigService) => ({ drain: createAxiomDrain({ apiKey: config.get('AXIOM_API_KEY') }), }), }), ], }) export class AppModule {} ``` ### Pipeline (Batching & Retry) For production, wrap your adapter with `createDrainPipeline` to batch events and retry on failure: ```typescript [src/app.module.ts] import type { DrainContext } from 'evlog' import { createAxiomDrain } from 'evlog/axiom' import { createDrainPipeline } from 'evlog/pipeline' const pipeline = createDrainPipeline({ batch: { size: 50, intervalMs: 5000 }, retry: { maxAttempts: 3 }, }) const drain = pipeline(createAxiomDrain()) EvlogModule.forRoot({ drain }) ``` ::callout{color="info" icon="i-lucide-info"} Call `drain.flush()` on server shutdown to ensure all buffered events are sent. See the [Pipeline docs](https://www.evlog.dev/extend/drain-pipeline) for all options. :: ## Tail Sampling Use `keep` to force-retain specific events regardless of head sampling: ```typescript [src/app.module.ts] EvlogModule.forRoot({ drain: createAxiomDrain(), keep: (ctx) => { if (ctx.duration && ctx.duration > 2000) ctx.shouldKeep = true }, }) ``` ## Route Filtering Control which routes are logged with `include` and `exclude` patterns: ```typescript [src/app.module.ts] EvlogModule.forRoot({ include: ['/api/**'], exclude: ['/_internal/**', '/health'], routes: { '/api/auth/**': { service: 'auth-service' }, '/api/payment/**': { service: 'payment-service' }, }, }) ``` ## Run Locally ```bash [Terminal] git clone https://github.com/hugorcd/evlog.git cd evlog pnpm install pnpm example nestjs ``` Open {rel=""nofollow""} to explore the interactive test UI. ::card-group :::card --- icon: i-simple-icons-github title: Source Code to: https://github.com/hugorcd/evlog/tree/main/examples/nestjs --- Browse the complete NestJS example source on GitHub. ::: :: ## Next Steps Deepen your **NestJS** integration: - [Wide Events](https://www.evlog.dev/learn/wide-events): Design comprehensive events with context layering - [Adapters](https://www.evlog.dev/integrate/adapters/overview): Send logs to Axiom, Sentry, PostHog, and more - [Sampling](https://www.evlog.dev/learn/sampling): Control log volume with head and tail sampling - [Structured Errors](https://www.evlog.dev/learn/structured-errors): Throw errors with `why`, `fix`, and `link` fields # Express The `evlog/express` middleware auto-creates a request-scoped logger on `req.log` and emits a wide event when the response finishes. ::prompt --- actions: - copy - cursor - claude description: Set up evlog in my Express app icon: i-simple-icons-express --- Set up evlog in my Express app. - Install evlog: pnpm add evlog - Call initLogger({ env: { service: 'my-api' } }) at startup - Alternatively, use evlog/vite plugin in vite.config.ts for auto-init (replaces initLogger) - Import evlog middleware from 'evlog/express' and add app.use(evlog()) - Access the logger via req.log in routes or useLogger() anywhere in the call stack - Use log.set() to accumulate context, throw createError() for structured errors - Optionally pass drain, enrich, include, and keep options to evlog() Docs: {rel=""nofollow""} Adapters: {rel=""nofollow""} :: ## Quick Start ### 1. Install ::code-group ```bash [pnpm] pnpm add evlog express ``` ```bash [bun] bun add evlog express ``` ```bash [yarn] yarn add evlog express ``` ```bash [npm] npm install evlog express ``` :: ### 2. Initialize and register the middleware ```typescript [src/index.ts] import express from 'express' import { initLogger } from 'evlog' import { evlog } from 'evlog/express' initLogger({ env: { service: 'my-api' }, }) const app = express() app.use(evlog()) app.get('/health', (req, res) => { req.log.set({ route: 'health' }) res.json({ ok: true }) }) app.listen(3000) ``` ::callout{color="info" icon="i-custom-vite"} **Using Vite?** The [`evlog/vite` plugin](https://www.evlog.dev/reference/vite-plugin) replaces the `initLogger()` call with compile-time auto-initialization, strips `log.debug()` from production builds, and injects source locations. :: The logger is available on `req.log` with full TypeScript support via module augmentation, so no extra type annotations are needed. ## Wide Events Build up context progressively through your handler. One request = one wide event: ```typescript [src/index.ts] app.get('/users/:id', async (req, res) => { const userId = req.params.id req.log.set({ user: { id: userId } }) const user = await db.findUser(userId) req.log.set({ user: { name: user.name, plan: user.plan } }) const orders = await db.findOrders(userId) req.log.set({ orders: { count: orders.length, totalRevenue: sum(orders) } }) res.json({ user, orders }) }) ``` All fields are merged into a single wide event emitted when the response finishes: ```bash [Terminal output] 14:58:15 INFO [my-api] GET /users/usr_123 200 in 12ms ├─ orders: count=2 totalRevenue=6298 ├─ user: id=usr_123 name=Alice plan=pro └─ requestId: 4a8ff3a8-... ``` ## useLogger() Use `useLogger()` to access the request-scoped logger from anywhere in the call stack without passing `req` through your service layer: ```typescript [src/services/user.ts] import { useLogger } from 'evlog/express' export async function findUser(id: string) { const log = useLogger() log.set({ user: { id } }) const user = await db.findUser(id) log.set({ user: { name: user.name, plan: user.plan } }) return user } ``` ```typescript [src/index.ts] import { findUser } from './services/user' app.get('/users/:id', async (req, res) => { const user = await findUser(req.params.id) res.json(user) }) ``` Both `req.log` and `useLogger()` return the same logger instance. `useLogger()` uses `AsyncLocalStorage` to propagate the logger across async boundaries. ## Background work (`log.fork`) Fire-and-forget async work that finishes **after** the response can no longer update the request wide event (the logger is sealed after emit). Use **`req.log.fork(label, fn)`** so `useLogger()` inside `fn` targets a **child** logger that emits its own event with `operation` and `_parentRequestId`. See [Wide events — After emit](https://www.evlog.dev/learn/wide-events#after-emit-sealing-and-background-work). ```typescript [src/index.ts] import { evlog, useLogger } from 'evlog/express' app.use(evlog()) app.post('/orders', (req, res) => { req.log.set({ orderId: 'ord_1' }) req.log.fork!('fulfill_order', async () => { const log = useLogger() log.set({ step: 'inventory_ok' }) }) res.json({ ok: true }) }) ``` ## Error Handling Use `createError` for structured errors with `why`, `fix`, and `link` fields. Express uses a 4-argument error handler middleware: ```typescript [src/index.ts] import { createError, parseError } from 'evlog' app.get('/checkout', () => { throw createError({ message: 'Payment failed', status: 402, why: 'Card declined by issuer', fix: 'Try a different payment method', link: 'https://docs.example.com/payments/declined', }) }) app.use((err, req, res, next) => { req.log.error(err) const parsed = parseError(err) res.status(parsed.status).json({ message: parsed.message, why: parsed.why, fix: parsed.fix, link: parsed.link, }) }) ``` The error is captured and logged with both the custom context and structured error fields: ```bash [Terminal output] 14:58:20 ERROR [my-api] GET /checkout 402 in 3ms ├─ error: name=EvlogError message=Payment failed status=402 └─ requestId: 880a50ac-... ``` ## Configuration See the [Configuration reference](https://www.evlog.dev/reference/configuration) for all available options (`initLogger`, middleware options, sampling, silent mode, etc.). ## Drain & Enrichers Configure drain adapters and enrichers directly in the middleware options: ```typescript [src/index.ts] import { createAxiomDrain } from 'evlog/axiom' import { createUserAgentEnricher } from 'evlog/enrichers' const userAgent = createUserAgentEnricher() app.use(evlog({ drain: createAxiomDrain(), enrich: (ctx) => { userAgent(ctx) ctx.event.region = process.env.FLY_REGION }, })) ``` ### Pipeline (Batching & Retry) For production, wrap your adapter with `createDrainPipeline` to batch events and retry on failure: ```typescript [src/index.ts] import type { DrainContext } from 'evlog' import { createAxiomDrain } from 'evlog/axiom' import { createDrainPipeline } from 'evlog/pipeline' const pipeline = createDrainPipeline({ batch: { size: 50, intervalMs: 5000 }, retry: { maxAttempts: 3 }, }) const drain = pipeline(createAxiomDrain()) app.use(evlog({ drain })) ``` ::callout{color="info" icon="i-lucide-info"} Call `drain.flush()` on server shutdown to ensure all buffered events are sent. See the [Pipeline docs](https://www.evlog.dev/extend/drain-pipeline) for all options. :: ## Tail Sampling Use `keep` to force-retain specific events regardless of head sampling: ```typescript [src/index.ts] app.use(evlog({ drain: createAxiomDrain(), keep: (ctx) => { if (ctx.duration && ctx.duration > 2000) ctx.shouldKeep = true }, })) ``` ## Route Filtering Control which routes are logged with `include` and `exclude` patterns: ```typescript [src/index.ts] app.use(evlog({ include: ['/api/**'], exclude: ['/_internal/**', '/health'], routes: { '/api/auth/**': { service: 'auth-service' }, '/api/payment/**': { service: 'payment-service' }, }, })) ``` ## Client-Side Logging Use `evlog/http` to send structured logs from any frontend to your Express server. This works with any client framework (React, Vue, Svelte, vanilla JS). ### Browser setup ```typescript [client.ts] import { initLogger, log } from 'evlog' import { createHttpLogDrain } from 'evlog/http' const drain = createHttpLogDrain({ drain: { endpoint: '/v1/ingest' }, }) initLogger({ drain }) log.info({ action: 'page_view', path: location.pathname }) ``` ### Ingest endpoint Add a POST route to receive batched `DrainContext[]` from the browser: ```typescript [src/index.ts] import type { DrainContext } from 'evlog' app.post('/v1/ingest', express.json(), (req, res) => { const batch = req.body as DrainContext[] for (const ctx of batch) { console.log('[BROWSER]', JSON.stringify(ctx.event)) } res.sendStatus(204) }) ``` ::callout{color="neutral" icon="i-lucide-globe"} See the full [HTTP drain](https://www.evlog.dev/extend/drain-pipeline#http-drain-browser-to-server) adapter docs for batching, retry, sendBeacon fallback, and authentication options. :: ## Run Locally ```bash [Terminal] git clone https://github.com/hugorcd/evlog.git cd evlog pnpm install pnpm example express ``` Open {rel=""nofollow""} to explore the interactive test UI. ::card-group :::card --- icon: i-simple-icons-github title: Source Code to: https://github.com/hugorcd/evlog/tree/main/examples/express --- Browse the complete Express example source on GitHub. ::: :: ## Next Steps Deepen your **Express** integration: - [Wide Events](https://www.evlog.dev/learn/wide-events): Design comprehensive events with context layering - [Adapters](https://www.evlog.dev/integrate/adapters/overview): Send logs to Axiom, Sentry, PostHog, and more - [Sampling](https://www.evlog.dev/learn/sampling): Control log volume with head and tail sampling - [Structured Errors](https://www.evlog.dev/learn/structured-errors): Throw errors with `why`, `fix`, and `link` fields # Hono The `evlog/hono` middleware auto-creates a request-scoped logger accessible via `c.get('log')` — or `useLogger()` anywhere deeper in the call stack — and emits a wide event when the response completes. ::prompt --- actions: - copy - cursor - claude description: Set up evlog in my Hono app icon: i-simple-icons-hono --- Set up evlog in my Hono app. - Install evlog: pnpm add evlog - Call initLogger({ env: { service: 'my-api' } }) at startup - Alternatively, use evlog/vite plugin in vite.config.ts for auto-init (replaces initLogger) - Import evlog middleware and EvlogVariables type from 'evlog/hono' - Add app.use(evlog()) and type the app with Hono :evlog-variables - Access the logger via c.get('log') in route handlers, or useLogger() from 'evlog/hono' in nested functions - Use log.set() to accumulate context throughout the request - Optionally pass drain, enrich, include, and keep options to evlog() Docs: {rel=""nofollow""} Adapters: {rel=""nofollow""} :: ## Quick Start ### 1. Install ::code-group ```bash [pnpm] pnpm add evlog hono @hono/node-server ``` ```bash [bun] bun add evlog hono @hono/node-server ``` ```bash [yarn] yarn add evlog hono @hono/node-server ``` ```bash [npm] npm install evlog hono @hono/node-server ``` :: ### 2. Initialize and register the middleware ```typescript [src/index.ts] import { serve } from '@hono/node-server' import { Hono } from 'hono' import { initLogger } from 'evlog' import { evlog, type EvlogVariables } from 'evlog/hono' initLogger({ env: { service: 'my-api' }, }) const app = new Hono() app.use(evlog()) app.get('/health', (c) => { c.get('log').set({ route: 'health' }) return c.json({ ok: true }) }) serve({ fetch: app.fetch, port: 3000 }) ``` ::callout{color="info" icon="i-custom-vite"} **Using Vite?** The [`evlog/vite` plugin](https://www.evlog.dev/reference/vite-plugin) replaces the `initLogger()` call with compile-time auto-initialization, strips `log.debug()` from production builds, and injects source locations. :: The `EvlogVariables` type gives you typed access to `c.get('log')` across all route handlers. ## Wide Events Build up context progressively through your handler. One request = one wide event: ```typescript [src/index.ts] app.get('/users/:id', async (c) => { const log = c.get('log') const userId = c.req.param('id') log.set({ user: { id: userId } }) const user = await db.findUser(userId) log.set({ user: { name: user.name, plan: user.plan } }) const orders = await db.findOrders(userId) log.set({ orders: { count: orders.length, totalRevenue: sum(orders) } }) return c.json({ user, orders }) }) ``` All fields are merged into a single wide event emitted when the request completes: ```bash [Terminal output] 14:58:15 INFO [my-api] GET /users/usr_123 200 in 12ms ├─ orders: count=2 totalRevenue=6298 ├─ user: id=usr_123 name=Alice plan=pro └─ requestId: 4a8ff3a8-... ``` ## Accessing the logger deeper in the stack `c.get('log')` is the idiomatic accessor inside a route handler. Once you are a few layers down — a service, a repository — threading `c` everywhere gets noisy. `useLogger()` resolves the same request logger without it: ```typescript [src/services/payment.ts] import { useLogger } from 'evlog/hono' export async function chargeCard(amount: number) { const log = useLogger() log.set({ payment: { amount } }) } ``` Both return the same logger — pick whichever reads better at the call site. ::callout{color="info" icon="i-lucide-info"} `useLogger()` is backed by `AsyncLocalStorage` . On Cloudflare Workers this requires the `nodejs_compat` (or `nodejs_als` ) compatibility flag in `wrangler.toml` . `c.get('log')` works with or without it, so deployments that cannot enable the flag keep using it. :: ### Background work **`log.fork()`** runs work under a child logger that emits its own wide event, correlated to the request via `_parentRequestId`: ```typescript [src/index.ts] app.post('/api/orders', (c) => { c.get('log').fork('send-receipt', async () => { await sendReceipt() useLogger().set({ email: { sent: true } }) }) return c.json({ ok: true }) }) ``` If you schedule async work after the response without forking, post-emit **`[evlog]` warnings** help you notice stale `set()` calls. See [Wide events — After emit](https://www.evlog.dev/learn/wide-events#after-emit-sealing-and-background-work). ## Error Handling Use `createError` for structured errors with `why`, `fix`, and `link` fields: ```typescript [src/index.ts] import { createError, parseError } from 'evlog' app.get('/checkout', (c) => { const log = c.get('log') log.set({ cart: { items: 3, total: 9999 } }) throw createError({ message: 'Payment failed', status: 402, why: 'Card declined by issuer', fix: 'Try a different payment method', link: 'https://docs.example.com/payments/declined', }) }) ``` Handle errors globally with `app.onError` to return structured JSON responses: ```typescript [src/index.ts] 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, ) }) ``` `parseError()` types `status` as a `number`, while Hono’s `c.json()` second argument expects `ContentfulStatusCode`. The cast matches what you already return at runtime and satisfies TypeScript. The error is captured and logged with both the custom context and structured error fields: ```bash [Terminal output] 14:58:20 ERROR [my-api] GET /checkout 402 in 3ms ├─ error: name=EvlogError message=Payment failed status=402 ├─ cart: items=3 total=9999 └─ requestId: 880a50ac-... ``` ## Configuration See the [Configuration reference](https://www.evlog.dev/reference/configuration) for all available options (`initLogger`, middleware options, sampling, silent mode, etc.). ## Drain & Enrichers Configure drain adapters and enrichers directly in the middleware options: ```typescript [src/index.ts] import { createAxiomDrain } from 'evlog/axiom' import { createUserAgentEnricher } from 'evlog/enrichers' const userAgent = createUserAgentEnricher() app.use(evlog({ drain: createAxiomDrain(), enrich: (ctx) => { userAgent(ctx) ctx.event.region = process.env.FLY_REGION }, })) ``` ### Pipeline (Batching & Retry) For production, wrap your adapter with `createDrainPipeline` to batch events and retry on failure: ```typescript [src/index.ts] import type { DrainContext } from 'evlog' import { createAxiomDrain } from 'evlog/axiom' import { createDrainPipeline } from 'evlog/pipeline' const pipeline = createDrainPipeline({ batch: { size: 50, intervalMs: 5000 }, retry: { maxAttempts: 3 }, }) const drain = pipeline(createAxiomDrain()) app.use(evlog({ drain })) ``` ::callout{color="info" icon="i-lucide-info"} Call `drain.flush()` on server shutdown to ensure all buffered events are sent. See the [Pipeline docs](https://www.evlog.dev/extend/drain-pipeline) for all options. :: ## Tail Sampling Use `keep` to force-retain specific events regardless of head sampling: ```typescript [src/index.ts] app.use(evlog({ drain: createAxiomDrain(), keep: (ctx) => { if (ctx.duration && ctx.duration > 2000) ctx.shouldKeep = true }, })) ``` ## Route Filtering Control which routes are logged with `include` and `exclude` patterns: ```typescript [src/index.ts] app.use(evlog({ include: ['/api/**'], exclude: ['/_internal/**', '/health'], routes: { '/api/auth/**': { service: 'auth-service' }, '/api/payment/**': { service: 'payment-service' }, }, })) ``` ## Client-Side Logging Use `evlog/http` to send structured logs from any frontend to your Hono server. This works with any client framework (React, Vue, Svelte, vanilla JS). ### Browser setup ```typescript [client.ts] import { initLogger, log } from 'evlog' import { createHttpLogDrain } from 'evlog/http' const drain = createHttpLogDrain({ drain: { endpoint: '/v1/ingest' }, }) initLogger({ drain }) log.info({ action: 'page_view', path: location.pathname }) ``` ### Ingest endpoint Add a POST route to receive batched `DrainContext[]` from the browser: ```typescript [src/index.ts] import type { DrainContext } from 'evlog' app.post('/v1/ingest', async (c) => { const batch = await c.req.json() for (const ctx of batch) { console.log('[BROWSER]', JSON.stringify(ctx.event)) } return c.body(null, 204) }) ``` ::callout{color="neutral" icon="i-lucide-globe"} See the full [HTTP drain](https://www.evlog.dev/extend/drain-pipeline) adapter docs for batching, retry, sendBeacon fallback, and authentication options. :: ## Run Locally ```bash [Terminal] git clone https://github.com/hugorcd/evlog.git cd evlog pnpm install pnpm example hono ``` Open {rel=""nofollow""} to explore the interactive test UI. ::card-group :::card --- icon: i-simple-icons-github title: Source Code to: https://github.com/hugorcd/evlog/tree/main/examples/hono --- Browse the complete Hono example source on GitHub. ::: :: ## Next Steps Deepen your **Hono** integration: - [Wide Events](https://www.evlog.dev/learn/wide-events): Design comprehensive events with context layering - [Adapters](https://www.evlog.dev/integrate/adapters/overview): Send logs to Axiom, Sentry, PostHog, and more - [Sampling](https://www.evlog.dev/learn/sampling): Control log volume with head and tail sampling - [Structured Errors](https://www.evlog.dev/learn/structured-errors): Throw errors with `why`, `fix`, and `link` fields # Fastify The `evlog/fastify` plugin auto-creates a request-scoped logger accessible via `request.log` and `useLogger()`, emitting a wide event when the response completes. ::prompt --- actions: - copy - cursor - claude description: Set up evlog in my Fastify app icon: i-simple-icons-fastify --- Set up evlog in my Fastify app. - Install evlog: pnpm add evlog - Call initLogger({ env: { service: 'my-api' } }) at startup - Alternatively, use evlog/vite plugin in vite.config.ts for auto-init (replaces initLogger) - Import evlog from 'evlog/fastify' and register with app.register(evlog) - Access the logger via request.log in route handlers or useLogger() anywhere - Use log.set() to accumulate context throughout the request - Optionally pass drain, enrich, include, and keep options when registering Docs: {rel=""nofollow""} Adapters: {rel=""nofollow""} :: ## Quick Start ### 1. Install ::code-group ```bash [pnpm] pnpm add evlog fastify ``` ```bash [bun] bun add evlog fastify ``` ```bash [yarn] yarn add evlog fastify ``` ```bash [npm] npm install evlog fastify ``` :: ### 2. Initialize and register the plugin ```typescript [src/index.ts] import Fastify from 'fastify' import { initLogger } from 'evlog' import { evlog } from 'evlog/fastify' initLogger({ env: { service: 'my-api' }, }) const app = Fastify({ logger: false }) await app.register(evlog) app.get('/health', async (request) => { request.log.set({ route: 'health' }) return { ok: true } }) await app.listen({ port: 3000 }) ``` ::callout{color="info" icon="i-custom-vite"} **Using Vite?** The [`evlog/vite` plugin](https://www.evlog.dev/reference/vite-plugin) replaces the `initLogger()` call with compile-time auto-initialization, strips `log.debug()` from production builds, and injects source locations. :: `request.log` is the evlog wide-event logger and shadows Fastify's built-in pino logger on the request. The pino logger remains accessible via `fastify.log` for server-level structured logging. ## Wide Events Build up context progressively through your handler. One request = one wide event: ```typescript [src/index.ts] app.get('/users/:id', async (request) => { const { id } = request.params as { id: string } request.log.set({ user: { id } }) const user = await db.findUser(id) request.log.set({ user: { name: user.name, plan: user.plan } }) const orders = await db.findOrders(id) request.log.set({ orders: { count: orders.length, totalRevenue: sum(orders) } }) return { user, orders } }) ``` All fields are merged into a single wide event emitted when the request completes: ```bash [Terminal output] 14:58:15 INFO [my-api] GET /users/usr_123 200 in 12ms ├─ orders: count=2 totalRevenue=6298 ├─ user: id=usr_123 name=Alice plan=pro └─ requestId: 4a8ff3a8-... ``` ## useLogger() Use `useLogger()` to access the request-scoped logger from anywhere in the call stack without passing the request object through your service layer: ```typescript [src/services/user.ts] import { useLogger } from 'evlog/fastify' export async function findUser(id: string) { const log = useLogger() log.set({ user: { id } }) const user = await db.findUser(id) log.set({ user: { name: user.name, plan: user.plan } }) return user } ``` ```typescript [src/index.ts] import { findUser } from './services/user' app.get('/users/:id', async (request) => { const { id } = request.params as { id: string } const user = await findUser(id) return user }) ``` Both `request.log` and `useLogger()` return the same logger instance. `useLogger()` uses `AsyncLocalStorage` to propagate the logger across async boundaries. ## Background work (`log.fork`) Use `request.log.fork(label, fn)` for async work that should emit a **separate** child wide event after the response. See [Wide events — After emit](https://www.evlog.dev/learn/wide-events#after-emit-sealing-and-background-work). ```typescript [src/index.ts] import { evlog, useLogger } from 'evlog/fastify' app.post('/orders', async (request, reply) => { request.log.fork!('fulfill', async () => { const log = useLogger() log.set({ step: 'ok' }) }) return { ok: true } }) ``` ## Error Handling Use `createError` for structured errors with `why`, `fix`, and `link` fields. Fastify captures thrown errors via `onError`: ```typescript [src/index.ts] import { createError, parseError } from 'evlog' app.get('/checkout', async (_request, reply) => { throw createError({ message: 'Payment failed', status: 402, why: 'Card declined by issuer', fix: 'Try a different payment method', link: 'https://docs.example.com/payments/declined', }) }) app.setErrorHandler((error, _request, reply) => { const parsed = parseError(error) reply.status(parsed.status).send({ message: parsed.message, why: parsed.why, fix: parsed.fix, link: parsed.link, }) }) ``` The error is captured and logged with both the custom context and structured error fields: ```bash [Terminal output] 14:58:20 ERROR [my-api] GET /checkout 402 in 3ms ├─ error: name=EvlogError message=Payment failed status=402 └─ requestId: 880a50ac-... ``` ## Configuration See the [Configuration reference](https://www.evlog.dev/reference/configuration) for all available options (`initLogger`, middleware options, sampling, silent mode, etc.). ## Drain & Enrichers Configure drain adapters and enrichers directly in the plugin options: ```typescript [src/index.ts] import { createAxiomDrain } from 'evlog/axiom' import { createUserAgentEnricher } from 'evlog/enrichers' const userAgent = createUserAgentEnricher() await app.register(evlog, { drain: createAxiomDrain(), enrich: (ctx) => { userAgent(ctx) ctx.event.region = process.env.FLY_REGION }, }) ``` ### Pipeline (Batching & Retry) For production, wrap your adapter with `createDrainPipeline` to batch events and retry on failure: ```typescript [src/index.ts] import type { DrainContext } from 'evlog' import { createAxiomDrain } from 'evlog/axiom' import { createDrainPipeline } from 'evlog/pipeline' const pipeline = createDrainPipeline({ batch: { size: 50, intervalMs: 5000 }, retry: { maxAttempts: 3 }, }) const drain = pipeline(createAxiomDrain()) await app.register(evlog, { drain }) ``` ::callout{color="info" icon="i-lucide-info"} Call `drain.flush()` on server shutdown to ensure all buffered events are sent. See the [Pipeline docs](https://www.evlog.dev/extend/drain-pipeline) for all options. :: ## Tail Sampling Use `keep` to force-retain specific events regardless of head sampling: ```typescript [src/index.ts] await app.register(evlog, { drain: createAxiomDrain(), keep: (ctx) => { if (ctx.duration && ctx.duration > 2000) ctx.shouldKeep = true }, }) ``` ## Route Filtering Control which routes are logged with `include` and `exclude` patterns: ```typescript [src/index.ts] await app.register(evlog, { include: ['/api/**'], exclude: ['/_internal/**', '/health'], routes: { '/api/auth/**': { service: 'auth-service' }, '/api/payment/**': { service: 'payment-service' }, }, }) ``` ## Run Locally ```bash [Terminal] git clone https://github.com/hugorcd/evlog.git cd evlog pnpm install pnpm example fastify ``` Open {rel=""nofollow""} to explore the interactive test UI. ::card-group :::card --- icon: i-simple-icons-github title: Source Code to: https://github.com/hugorcd/evlog/tree/main/examples/fastify --- Browse the complete Fastify example source on GitHub. ::: :: ## Next Steps - [Wide Events](https://www.evlog.dev/learn/wide-events): Design comprehensive events with context layering - [Adapters](https://www.evlog.dev/integrate/adapters/overview): Send logs to Axiom, Sentry, PostHog, and more - [Sampling](https://www.evlog.dev/learn/sampling): Control log volume with head and tail sampling - [Structured Errors](https://www.evlog.dev/learn/structured-errors): Throw errors with `why`, `fix`, and `link` fields # Elysia The `evlog/elysia` plugin auto-creates a request-scoped logger accessible via `log` in route context and `useLogger()`, emitting a wide event when the response completes. ::prompt --- actions: - copy - cursor - claude description: Set up evlog in my Elysia app icon: i-custom-elysia --- Set up evlog in my Elysia app. - Install evlog: pnpm add evlog - Call initLogger({ env: { service: 'my-api' } }) at startup - Alternatively, use evlog/vite plugin in vite.config.ts for auto-init (replaces initLogger) - Import evlog from 'evlog/elysia' and add .use(evlog()) to your Elysia app - Access the logger via the log property in route context destructuring - Use useLogger() from 'evlog/elysia' to access the logger from anywhere - Optionally pass drain, enrich, include, and keep options to evlog() Docs: {rel=""nofollow""} Adapters: {rel=""nofollow""} :: ## Quick Start ### 1. Install ::code-group ```bash [pnpm] pnpm add evlog elysia ``` ```bash [bun] bun add evlog elysia ``` ```bash [yarn] yarn add evlog elysia ``` ```bash [npm] npm install evlog elysia ``` :: ### 2. Initialize and register the plugin ```typescript [src/index.ts] import { Elysia } from 'elysia' import { initLogger } from 'evlog' import { evlog } from 'evlog/elysia' initLogger({ env: { service: 'my-api' }, }) const app = new Elysia() .use(evlog()) .get('/health', ({ log }) => { log.set({ route: 'health' }) return { ok: true } }) .listen(3000) ``` ::callout{color="info" icon="i-custom-vite"} **Using Vite?** The [`evlog/vite` plugin](https://www.evlog.dev/reference/vite-plugin) replaces the `initLogger()` call with compile-time auto-initialization, strips `log.debug()` from production builds, and injects source locations. :: The `log` property is automatically available in all route handlers via Elysia's `derive`. ## Wide Events Build up context progressively through your handler. One request = one wide event: ```typescript [src/index.ts] app.get('/users/:id', async ({ log, params }) => { const userId = params.id log.set({ user: { id: userId } }) const user = await db.findUser(userId) log.set({ user: { name: user.name, plan: user.plan } }) const orders = await db.findOrders(userId) log.set({ orders: { count: orders.length, totalRevenue: sum(orders) } }) return { user, orders } }) ``` All fields are merged into a single wide event emitted when the request completes: ```bash [Terminal output] 14:58:15 INFO [my-api] GET /users/usr_123 200 in 12ms ├─ orders: count=2 totalRevenue=6298 ├─ user: id=usr_123 name=Alice plan=pro └─ requestId: 4a8ff3a8-... ``` ## useLogger() Use `useLogger()` to access the request-scoped logger from anywhere in the call stack without passing the context through your service layer: ```typescript [src/services/user.ts] import { useLogger } from 'evlog/elysia' export async function findUser(id: string) { const log = useLogger() log.set({ user: { id } }) const user = await db.findUser(id) log.set({ user: { name: user.name, plan: user.plan } }) return user } ``` ```typescript [src/index.ts] import { findUser } from './services/user' app.get('/users/:id', async ({ params }) => { const user = await findUser(params.id) return user }) ``` Both `log` in context and `useLogger()` return the same logger instance. `useLogger()` uses `AsyncLocalStorage` to propagate the logger across async boundaries. ## Background work (`log.fork`) Use `log.fork(label, fn)` from the route context for a child wide event. See [Wide events — After emit](https://www.evlog.dev/learn/wide-events#after-emit-sealing-and-background-work). ```typescript [src/index.ts] import { evlog, useLogger } from 'evlog/elysia' app .use(evlog()) .post('/orders', ({ log }) => { log.fork!('ship', async () => { const l = useLogger() l.set({ shipped: true }) }) return { ok: true } }) ``` ## Error Handling Use `createError` for structured errors with `why`, `fix`, and `link` fields. Elysia captures thrown errors via `onError`: ```typescript [src/index.ts] import { createError, parseError } from 'evlog' app .use(evlog()) .get('/checkout', ({ log }) => { log.set({ cart: { items: 3, total: 9999 } }) throw createError({ message: 'Payment failed', status: 402, why: 'Card declined by issuer', fix: 'Try a different payment method', link: 'https://docs.example.com/payments/declined', }) }) .onError(({ error, set }) => { const parsed = parseError(error) set.status = parsed.status return { message: parsed.message, why: parsed.why, fix: parsed.fix, link: parsed.link, } }) ``` The error is captured and logged with both the custom context and structured error fields: ```bash [Terminal output] 14:58:20 ERROR [my-api] GET /checkout 402 in 3ms ├─ error: name=EvlogError message=Payment failed status=402 ├─ cart: items=3 total=9999 └─ requestId: 880a50ac-... ``` ## Configuration See the [Configuration reference](https://www.evlog.dev/reference/configuration) for all available options (`initLogger`, middleware options, sampling, silent mode, etc.). ## Drain & Enrichers Configure drain adapters and enrichers directly in the plugin options: ```typescript [src/index.ts] import { createAxiomDrain } from 'evlog/axiom' import { createUserAgentEnricher } from 'evlog/enrichers' const userAgent = createUserAgentEnricher() app.use(evlog({ drain: createAxiomDrain(), enrich: (ctx) => { userAgent(ctx) ctx.event.region = process.env.FLY_REGION }, })) ``` ### Pipeline (Batching & Retry) For production, wrap your adapter with `createDrainPipeline` to batch events and retry on failure: ```typescript [src/index.ts] import type { DrainContext } from 'evlog' import { createAxiomDrain } from 'evlog/axiom' import { createDrainPipeline } from 'evlog/pipeline' const pipeline = createDrainPipeline({ batch: { size: 50, intervalMs: 5000 }, retry: { maxAttempts: 3 }, }) const drain = pipeline(createAxiomDrain()) app.use(evlog({ drain })) ``` ::callout{color="info" icon="i-lucide-info"} Call `drain.flush()` on server shutdown to ensure all buffered events are sent. See the [Pipeline docs](https://www.evlog.dev/extend/drain-pipeline) for all options. :: ## Tail Sampling Use `keep` to force-retain specific events regardless of head sampling: ```typescript [src/index.ts] app.use(evlog({ drain: createAxiomDrain(), keep: (ctx) => { if (ctx.duration && ctx.duration > 2000) ctx.shouldKeep = true }, })) ``` ## Route Filtering Control which routes are logged with `include` and `exclude` patterns: ```typescript [src/index.ts] app.use(evlog({ include: ['/api/**'], exclude: ['/_internal/**', '/health'], routes: { '/api/auth/**': { service: 'auth-service' }, '/api/payment/**': { service: 'payment-service' }, }, })) ``` ## Client-Side Logging Use `evlog/http` to send structured logs from any frontend to your Elysia server. This works with any client framework (React, Vue, Svelte, vanilla JS). ### Browser setup ```typescript [client.ts] import { initLogger, log } from 'evlog' import { createHttpLogDrain } from 'evlog/http' const drain = createHttpLogDrain({ drain: { endpoint: '/v1/ingest' }, }) initLogger({ drain }) log.info({ action: 'page_view', path: location.pathname }) ``` ### Ingest endpoint Add a POST route to receive batched `DrainContext[]` from the browser: ```typescript [src/index.ts] import type { DrainContext } from 'evlog' app.post('/v1/ingest', async ({ body }) => { const batch = body as DrainContext[] for (const ctx of batch) { console.log('[BROWSER]', JSON.stringify(ctx.event)) } return new Response(null, { status: 204 }) }) ``` ::callout{color="neutral" icon="i-lucide-globe"} See the full [HTTP drain](https://www.evlog.dev/extend/drain-pipeline) adapter docs for batching, retry, sendBeacon fallback, and authentication options. :: ## Run Locally ```bash [Terminal] git clone https://github.com/hugorcd/evlog.git cd evlog pnpm install pnpm example elysia ``` Open {rel=""nofollow""} to explore the interactive test UI. ::card-group :::card --- icon: i-custom-elysia title: Source Code to: https://github.com/hugorcd/evlog/tree/main/examples/elysia --- Browse the complete Elysia example source on GitHub. ::: :: ## Next Steps - [Wide Events](https://www.evlog.dev/learn/wide-events): Design comprehensive events with context layering - [Adapters](https://www.evlog.dev/integrate/adapters/overview): Send logs to Axiom, Sentry, PostHog, and more - [Sampling](https://www.evlog.dev/learn/sampling): Control log volume with head and tail sampling - [Structured Errors](https://www.evlog.dev/learn/structured-errors): Throw errors with `why`, `fix`, and `link` fields # React Router The `evlog/react-router` middleware auto-creates a request-scoped logger accessible via `context.get(loggerContext)` or `useLogger()` and emits a wide event when the response completes. ::callout{color="info" icon="i-lucide-info"} React Router has three [modes](https://reactrouter.com/start/modes){rel=""nofollow""} : **Framework** , **Data** , and **Declarative** . The `evlog/react-router` middleware requires the middleware API, which is available in **Framework** and **Data** modes only. Declarative mode does not support middleware: use `evlog/client` for console logging and `evlog/http` if you need a batched HTTP drain to your server. :: ::prompt --- actions: - copy - cursor - claude description: Set up evlog in my React Router app icon: i-custom-reactrouter --- Set up evlog in my React Router app. - Install evlog: pnpm add evlog - Call initLogger({ env: { service: 'my-api' } }) at startup - Alternatively, use evlog/vite plugin in vite.config.ts for auto-init (replaces initLogger) - Enable middleware in react-router.config.ts: future: { v8\_middleware: true } - Import evlog middleware and loggerContext from 'evlog/react-router' - Add evlog() to root route's middleware array - Access logger via context.get(loggerContext) in loaders/actions - Or use useLogger() from services without passing context - Optionally pass drain, enrich, include, and keep options to evlog() Docs: {rel=""nofollow""} Adapters: {rel=""nofollow""} :: ## Quick Start ### 1. Install ::code-group ```bash [pnpm] pnpm add evlog react-router @react-router/node @react-router/serve ``` ```bash [bun] bun add evlog react-router @react-router/node @react-router/serve ``` ```bash [yarn] yarn add evlog react-router @react-router/node @react-router/serve ``` ```bash [npm] npm install evlog react-router @react-router/node @react-router/serve ``` :: ### 2. Enable middleware ```typescript [react-router.config.ts] import type { Config } from '@react-router/dev/config' export default { future: { v8_middleware: true, }, } satisfies Config ``` ### 3. Initialize and register the middleware ```typescript [app/root.tsx] import { Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router' import { initLogger } from 'evlog' import { evlog } from 'evlog/react-router' initLogger({ env: { service: 'my-api' }, }) export const middleware: Route.MiddlewareFunction[] = [ evlog(), ] export default function Root() { return ( ) } ``` ### 4. Use the logger in loaders ```typescript [app/routes/health.tsx] import { loggerContext } from 'evlog/react-router' export async function loader({ context }: Route.LoaderArgs) { const log = context.get(loggerContext) log.set({ route: 'health' }) return { ok: true } } ``` ::callout{color="info" icon="i-custom-vite"} **Using Vite?** The `evlog/vite` [plugin](https://www.evlog.dev/reference/vite-plugin) replaces the `initLogger()` call with compile-time auto-initialization, strips `log.debug()` from production builds, and injects source locations. :: The `loggerContext` provides typed access to the evlog logger in any loader or action via `context.get(loggerContext)`. ## Wide Events Build up context progressively through your loader. One request = one wide event: ```typescript [app/routes/users.$id.tsx] import { loggerContext } from 'evlog/react-router' export async function loader({ params, context }: Route.LoaderArgs) { const log = context.get(loggerContext) const userId = params.id log.set({ user: { id: userId } }) const user = await db.findUser(userId) log.set({ user: { name: user.name, plan: user.plan } }) const orders = await db.findOrders(userId) log.set({ orders: { count: orders.length, totalRevenue: sum(orders) } }) return { user, orders } } ``` All fields are merged into a single wide event emitted when the request completes: ```bash [Terminal output] 14:58:15 INFO [my-api] GET /users/usr_123 200 in 12ms ├─ orders: count=2 totalRevenue=6298 ├─ user: id=usr_123 name=Alice plan=pro └─ requestId: 4a8ff3a8-... ``` ## useLogger() Access the logger from any server-side function without passing context: ```typescript [app/services/user.server.ts] import { useLogger } from 'evlog/react-router' export async function findUser(userId: string) { const log = useLogger() log.set({ db: { query: 'findUser', userId } }) return await db.users.find(userId) } ``` Then call the service from your loader: `useLogger()` returns the same logger instance: ```typescript [app/routes/users.$id.tsx] import { loggerContext } from 'evlog/react-router' import { findUser } from '~/services/user.server' export async function loader({ params, context }: Route.LoaderArgs) { const log = context.get(loggerContext) log.set({ user: { id: params.id } }) const user = await findUser(params.id!) return { user } } ``` ## Background work (`log.fork`) The logger from `loggerContext` supports `fork` for child wide events. See [Wide events — After emit](https://www.evlog.dev/learn/wide-events#after-emit-sealing-and-background-work). ```typescript [app/routes/orders.tsx] import { loggerContext } from 'evlog/react-router' import { useLogger } from 'evlog/react-router' import type { Route } from './+types/orders' export async function action({ context }: Route.ActionArgs) { const log = context.get(loggerContext) log.fork!('background', async () => { const child = useLogger() child.set({ step: 'complete' }) }) return { ok: true } } ``` ## Error Handling Use `createError` for structured errors with `why`, `fix`, and `link` fields: ```typescript [app/routes/checkout.tsx] import { loggerContext } from 'evlog/react-router' import { createError } from 'evlog' export async function loader({ context }: Route.LoaderArgs) { const log = context.get(loggerContext) log.set({ cart: { items: 3, total: 9999 } }) throw createError({ message: 'Payment failed', status: 402, why: 'Card declined by issuer', fix: 'Try a different payment method', link: 'https://docs.example.com/payments/declined', }) } ``` The error is captured and logged with both the custom context and structured error fields: ```bash [Terminal output] 14:58:20 ERROR [my-api] GET /checkout 402 in 3ms ├─ error: name=EvlogError message=Payment failed status=402 ├─ cart: items=3 total=9999 └─ requestId: 880a50ac-... ``` ## Configuration See the [Configuration reference](https://www.evlog.dev/reference/configuration) for all available options (`initLogger`, middleware options, sampling, silent mode, etc.). ## Drain & Enrichers Configure drain adapters and enrichers directly in the middleware options: ```typescript [app/root.tsx] import { createAxiomDrain } from 'evlog/axiom' import { createUserAgentEnricher } from 'evlog/enrichers' const userAgent = createUserAgentEnricher() export const middleware: Route.MiddlewareFunction[] = [ evlog({ drain: createAxiomDrain(), enrich: (ctx) => { userAgent(ctx) ctx.event.region = process.env.FLY_REGION }, }), ] ``` ### Pipeline (Batching & Retry) For production, wrap your adapter with `createDrainPipeline` to batch events and retry on failure: ```typescript [app/root.tsx] import type { DrainContext } from 'evlog' import { createAxiomDrain } from 'evlog/axiom' import { createDrainPipeline } from 'evlog/pipeline' const pipeline = createDrainPipeline({ batch: { size: 50, intervalMs: 5000 }, retry: { maxAttempts: 3 }, }) const drain = pipeline(createAxiomDrain()) export const middleware: Route.MiddlewareFunction[] = [ evlog({ drain }), ] ``` ::callout{color="info" icon="i-lucide-info"} Call `drain.flush()` on server shutdown to ensure all buffered events are sent. See the [Pipeline docs](https://www.evlog.dev/extend/drain-pipeline) for all options. :: ## Tail Sampling Use `keep` to force-retain specific events regardless of head sampling: ```typescript [app/root.tsx] export const middleware: Route.MiddlewareFunction[] = [ evlog({ drain: createAxiomDrain(), keep: (ctx) => { if (ctx.duration && ctx.duration > 2000) ctx.shouldKeep = true }, }), ] ``` ## Route Filtering Control which routes are logged with `include` and `exclude` patterns: ```typescript [app/root.tsx] export const middleware: Route.MiddlewareFunction[] = [ evlog({ include: ['/api/**'], exclude: ['/_internal/**', '/health'], routes: { '/api/auth/**': { service: 'auth-service' }, '/api/payment/**': { service: 'payment-service' }, }, }), ] ``` ## Run Locally ```bash [Terminal] git clone https://github.com/hugorcd/evlog.git cd evlog pnpm install pnpm example react-router ``` Open {rel=""nofollow""} to explore the interactive test UI. ::card-group :::card --- icon: i-simple-icons-github title: Source Code to: https://github.com/hugorcd/evlog/tree/main/examples/react-router --- Browse the complete React Router example source on GitHub. ::: :: ## Next Steps - [Wide Events](https://www.evlog.dev/learn/wide-events): Design comprehensive events with context layering - [Adapters](https://www.evlog.dev/integrate/adapters/overview): Send logs to Axiom, Sentry, PostHog, and more - [Sampling](https://www.evlog.dev/learn/sampling): Control log volume with head and tail sampling - [Structured Errors](https://www.evlog.dev/learn/structured-errors): Throw errors with `why`, `fix`, and `link` fields # Cloudflare Workers The `evlog/workers` adapter instruments Cloudflare Workers and Durable Objects with request-scoped loggers carrying Cloudflare-specific context. Use **`withEvlog`** to get the same middleware pipeline as every other framework integration — route filtering, redaction, enrich, tail sampling, plugins, drains, and automatic emit. Reach for `defineWorkerFetch` or `createWorkersLogger` when you'd rather own the emit yourself. ::prompt --- actions: - copy - cursor - claude description: Set up evlog in my Cloudflare Worker icon: i-simple-icons-cloudflare --- Set up evlog in my Cloudflare Worker. - Install evlog: pnpm add evlog - Import initWorkersLogger and withEvlog from 'evlog/workers' - Call initWorkersLogger({ env: { service: 'my-worker' } }) at the top level - Wrap the fetch handler with **withEvlog** (recommended) — it emits the wide event for you and accepts include/exclude, routes, redact, enrich, keep, plugins and drain - Use log.set() to accumulate context throughout the request - Only use defineWorkerFetch or createWorkersLogger if you want to call log.emit() yourself Docs: {rel=""nofollow""} Adapters: {rel=""nofollow""} :: ## Quick Start ### 1. Install ::code-group ```bash [pnpm] pnpm add evlog ``` ```bash [bun] bun add evlog ``` ```bash [yarn] yarn add evlog ``` ```bash [npm] npm install evlog ``` :: ### 2. Wrap your fetch handler ```typescript [src/worker.ts] import { initWorkersLogger, withEvlog } from 'evlog/workers' initWorkersLogger({ env: { service: 'my-worker' }, }) export default withEvlog(async (request, _env, _ctx, log) => { log.set({ action: 'handle_request' }) // ... your handler logic return Response.json({ ok: true }) }) ``` `withEvlog` emits one wide event per request when the handler returns — no manual `log.emit()`. It reads `ExecutionContext` off the third argument, so async **`drain`** calls (PostHog, Axiom, …) are registered with `waitUntil` and stay alive after the response is returned. Streaming responses defer the emit until the body completes. `requestId` comes from `x-request-id` when the caller sends one, falling back to `cf-ray`. `method`, `path`, `cf-ray`, `traceparent` and the safe subset of `request.cf` are captured automatically. ### Options `withEvlog` accepts the same options as every other framework integration: ```typescript [src/worker.ts] import { initWorkersLogger, withEvlog } from 'evlog/workers' import { createAxiomDrain } from 'evlog/axiom' initWorkersLogger({ env: { service: 'my-worker' } }) export default withEvlog( async (request, env, ctx, log) => { log.set({ route: 'checkout' }) return Response.json({ ok: true }) }, { drain: createAxiomDrain(), exclude: ['/health'], routes: { '/api/**': { service: 'api' } }, redact: true, enrich: (ctx) => { ctx.event.colo = ctx.event.colo ?? 'unknown' }, keep: (ctx) => { if (ctx.duration > 1000) ctx.shouldKeep = true }, }, ) ``` ### Emitting manually Prefer to own the emit? `defineWorkerFetch` wires `ExecutionContext` for you but leaves `log.emit()` to you: ```typescript [src/worker.ts] import { defineWorkerFetch, initWorkersLogger } from 'evlog/workers' initWorkersLogger({ env: { service: 'my-worker' } }) export default defineWorkerFetch(async (request, _env, _ctx, log) => { log.set({ action: 'handle_request' }) log.emit() return Response.json({ ok: true }) }) ``` ::callout{color="info" icon="i-lucide-info"} `defineWorkerFetch` and `createWorkersLogger` are the low-level path: they create the logger and leave the lifecycle to you, so `include` / `exclude` , `routes` , `redact` , `enrich` , `keep` and `plugins` do **not** apply. Use `withEvlog` to get them. :: ## Wide Events Build up context progressively, then emit at the end: ```typescript [src/worker.ts] import { defineWorkerFetch, initWorkersLogger } from 'evlog/workers' initWorkersLogger({ env: { service: 'my-worker' }, }) export default defineWorkerFetch(async (request, env, _ctx, log) => { const url = new URL(request.url) log.set({ route: url.pathname }) const user = await env.DB.prepare('SELECT * FROM users WHERE id = ?').bind(url.searchParams.get('userId')).first() log.set({ user: { id: user.id, plan: user.plan } }) const orders = await env.DB.prepare('SELECT COUNT(*) as count FROM orders WHERE user_id = ?').bind(user.id).first() log.set({ orders: { count: orders.count } }) log.emit() return Response.json({ user, orders }) }) ``` ```bash [Terminal output] 14:58:15 INFO [my-worker] GET /api/users 200 in 12ms ├─ orders: count=5 ├─ user: id=usr_123 plan=pro ├─ route: /api/users └─ requestId: 4a8ff3a8-... ``` ## Error Handling Use `createError` for structured errors and handle them with try/catch: ::code-collapse ```typescript [src/worker.ts] import { createError, parseError } from 'evlog' import { defineWorkerFetch, initWorkersLogger } from 'evlog/workers' initWorkersLogger({ env: { service: 'my-worker' } }) export default defineWorkerFetch(async (request, env, _ctx, log) => { try { const body = await request.json() log.set({ payment: { amount: body.amount } }) if (body.amount <= 0) { throw createError({ status: 400, message: 'Invalid payment amount', why: 'The amount must be a positive number', fix: 'Pass a positive integer in cents', }) } log.emit() return Response.json({ success: true }) } catch (error) { log.error(error instanceof Error ? error : new Error(String(error))) log.emit() const parsed = parseError(error) return Response.json({ message: parsed.message, why: parsed.why, fix: parsed.fix, }, { status: parsed.status }) } }) ``` :: ## Configuration See the [Configuration reference](https://www.evlog.dev/reference/configuration) for all available options (`initLogger`, middleware options, sampling, silent mode, etc.). ## Drain & Enrichers Configure drain and enrichers via `initWorkersLogger` options: ```typescript [src/worker.ts] import { initWorkersLogger, createWorkersLogger } from 'evlog/workers' import { createAxiomDrain } from 'evlog/axiom' import { createUserAgentEnricher } from 'evlog/enrichers' import { createDrainPipeline } from 'evlog/pipeline' import type { DrainContext } from 'evlog' const pipeline = createDrainPipeline({ batch: { size: 50, intervalMs: 5000 }, }) const drain = pipeline(createAxiomDrain()) const userAgent = createUserAgentEnricher() initWorkersLogger({ env: { service: 'my-worker' }, drain, enrich: (ctx) => { userAgent(ctx) }, }) ``` ::callout{color="info" icon="i-lucide-info"} See the [Adapters](https://www.evlog.dev/integrate/adapters/overview) and [Enrichers](https://www.evlog.dev/use-cases/enrichers) docs for all available drain adapters and enrichers. :: ## Wrangler Configuration Disable Cloudflare's default invocation logs to avoid duplicates when using evlog: ```toml [wrangler.toml] [observability] enabled = false ``` ## Run Locally ```bash [Terminal] wrangler dev ``` ## Next Steps - [Wide Events](https://www.evlog.dev/learn/wide-events): Design comprehensive events with context layering - [Adapters](https://www.evlog.dev/integrate/adapters/overview): Send logs to Axiom, Sentry, PostHog, and more - [Sampling](https://www.evlog.dev/learn/sampling): Control log volume with head and tail sampling - [Structured Errors](https://www.evlog.dev/learn/structured-errors): Throw errors with `why`, `fix`, and `link` fields # Standalone TypeScript For scripts, CLI tools, queue workers, cron jobs, and any TypeScript process that doesn't use a web framework, evlog provides `createLogger` and `createRequestLogger` from the core package. ::callout{color="neutral" icon="i-lucide-globe"} For scripts, queue workers, cron, and CLIs, this page is the reference. On Cloudflare Workers, prefer [Cloudflare Workers](https://www.evlog.dev/integrate/frameworks/cloudflare-workers) ( `createWorkersLogger` ). :: ::prompt --- actions: - copy - cursor - claude description: Set up evlog in my TypeScript project icon: i-simple-icons-typescript --- Set up evlog in my TypeScript project for scripts, workers, or CLI tools. - Install evlog: pnpm add evlog - Import initLogger and createLogger (or createRequestLogger) from 'evlog' - Call initLogger({ env: { service: 'my-script' } }) once at startup - Create a logger per logical operation with createLogger({ jobId, source }) - Use log.set() to accumulate context as the operation progresses - Call log.emit() manually when the operation completes Docs: {rel=""nofollow""} Adapters: {rel=""nofollow""} :: ## Quick Start ### 1. Install ::code-group ```bash [pnpm] pnpm add evlog ``` ```bash [bun] bun add evlog ``` ```bash [yarn] yarn add evlog ``` ```bash [npm] npm install evlog ``` :: ### 2. Initialize and create loggers ```typescript [scripts/sync-job.ts] import type { DrainContext } from 'evlog' import { initLogger, log, createLogger } from 'evlog' import { createAxiomDrain } from 'evlog/axiom' import { createDrainPipeline } from 'evlog/pipeline' const pipeline = createDrainPipeline({ batch: { size: 10 } }) const drain = pipeline(createAxiomDrain()) initLogger({ env: { service: 'my-script', environment: 'production' }, drain, }) // Every log is automatically drained log.info({ action: 'sync_started' }) const syncLog = createLogger({ jobId: 'sync-001', source: 'postgres', target: 's3' }) syncLog.set({ recordsSynced: 150 }) syncLog.emit() // drained automatically // Flush remaining events before exit await drain.flush() ``` ::callout{color="info" icon="i-lucide-info"} Always call `drain.flush()` before the process exits to ensure all buffered events are sent. :: ::callout{color="info" icon="i-custom-vite"} **Using vite-node?** The [`evlog/vite` plugin](https://www.evlog.dev/reference/vite-plugin) replaces the `initLogger()` call with compile-time auto-initialization, strips `log.debug()` from production builds, and injects source locations. :: ## createLogger vs createRequestLogger evlog provides two manual logger constructors: **`createLogger(context)`** - For non-HTTP contexts (scripts, CLI, queues): ```typescript [scripts/job.ts] import { createLogger } from 'evlog' const log = createLogger({ jobId: 'migrate-001', source: 'postgres' }) log.set({ recordsProcessed: 500 }) log.emit() ``` **`createRequestLogger(requestMeta)`** - For HTTP-like contexts where you want method/path/status tracking: ```typescript [scripts/webhook-handler.ts] import { createRequestLogger } from 'evlog' const log = createRequestLogger({ method: 'POST', path: '/webhook/stripe', }) log.set({ event: 'invoice.paid', customerId: 'cus_123' }) log.emit() ``` Both require manual `log.emit()` calls since there is no automatic lifecycle to hook into. ## Wide Events Build up context progressively, then emit: ```typescript [scripts/migrate-users.ts] import { initLogger, createLogger } from 'evlog' initLogger({ env: { service: 'migrate' }, }) const log = createLogger({ task: 'user-migration' }) const users = await db.query('SELECT * FROM legacy_users') log.set({ found: users.length }) let migrated = 0 for (const user of users) { await newDb.upsert({ id: user.id, email: user.email, plan: user.plan }) migrated++ } log.set({ migrated, status: 'complete' }) log.emit() ``` ```bash [Terminal output] 14:58:15 INFO [migrate] user-migration ├─ migrated: 1250 ├─ found: 1250 ├─ status: complete └─ task: user-migration ``` ## Error Handling Use `createError` for structured errors: ```typescript [scripts/sync-job.ts] import { createError, parseError } from 'evlog' try { const result = await externalApi.sync() if (!result.ok) { throw createError({ message: 'Sync failed', why: `API returned ${result.status}`, fix: 'Check the API status page and retry', }) } } catch (error) { log.error(error instanceof Error ? error : new Error(String(error))) log.emit() const { message, why, fix } = parseError(error) console.error(`${message}\nWhy: ${why}\nFix: ${fix}`) process.exit(1) } ``` ## Configuration See the [Configuration reference](https://www.evlog.dev/reference/configuration) for all available options (`initLogger`, middleware options, sampling, silent mode, etc.). ## Drain & Enrichers Configure drain in `initLogger`: ```typescript [scripts/init-logger.ts] import type { DrainContext } from 'evlog' import { initLogger } from 'evlog' import { createAxiomDrain } from 'evlog/axiom' import { createDrainPipeline } from 'evlog/pipeline' const pipeline = createDrainPipeline({ batch: { size: 50, intervalMs: 5000 }, retry: { maxAttempts: 3 }, }) const drain = pipeline(createAxiomDrain()) initLogger({ env: { service: 'my-script' }, drain, }) ``` ::callout{color="info" icon="i-lucide-info"} See the [Adapters](https://www.evlog.dev/integrate/adapters/overview) docs for all available drain adapters (Axiom, OTLP, PostHog, Sentry, Better Stack). :: ::callout{color="neutral" icon="i-lucide-arrow-right"} See the full [bun-script example](https://github.com/hugorcd/evlog/tree/main/examples/bun-script){rel=""nofollow""} for a complete working script. :: ## Next Steps - [Wide Events](https://www.evlog.dev/learn/wide-events): Design comprehensive events with context layering - [Adapters](https://www.evlog.dev/integrate/adapters/overview): Send logs to Axiom, Sentry, PostHog, and more - [Sampling](https://www.evlog.dev/learn/sampling): Control log volume with head and tail sampling - [Structured Errors](https://www.evlog.dev/learn/structured-errors): Throw errors with `why`, `fix`, and `link` fields # Astro Astro doesn't have a dedicated evlog integration. Instead, use the core `evlog` package with Astro's middleware to create request-scoped loggers manually. ::prompt --- actions: - copy - cursor - claude description: Set up evlog in my Astro app icon: i-simple-icons-astro --- Set up evlog in my Astro app. - Install evlog: pnpm add evlog - Import initLogger and createRequestLogger from 'evlog' - Call initLogger({ env: { service: 'my-app' } }) in Astro middleware - Create a request logger with createRequestLogger({ method, path }) per request - Use log.set() in API routes and middleware to accumulate context - Call log.emit() before returning the response (no auto-emit lifecycle) Docs: {rel=""nofollow""} Adapters: {rel=""nofollow""} :: ::callout{color="info" icon="i-lucide-info"} This is a guide-level integration. It uses the generic `createRequestLogger` API rather than a framework-specific module. :: ::callout{color="warning" icon="i-lucide-cloud"} On **Cloudflare Workers** (including Astro with `@astrojs/cloudflare`), set `waitUntil` on `createRequestLogger` to your `ExecutionContext#waitUntil` (properly bound), or use [`defineWorkerFetch`](https://www.evlog.dev/integrate/frameworks/cloudflare-workers) / [`createWorkersLogger`](https://www.evlog.dev/integrate/frameworks/cloudflare-workers) with `{ executionCtx }` on a **Worker `fetch` entry**. Otherwise async drains may never finish after the response is returned. For Astro **middleware** (not the raw Worker handler), there is no `defineWorkerFetch`; you still pass `waitUntil` from the adapter-exposed context. The exact way to read `ctx` from Astro middleware depends on your adapter version — check the [Cloudflare adapter docs](https://docs.astro.build/en/guides/integrations-guide/cloudflare/){rel=""nofollow""}. :: ## Quick Start ### 1. Install ::code-group ```bash [pnpm] pnpm add evlog ``` ```bash [bun] bun add evlog ``` ```bash [yarn] yarn add evlog ``` ```bash [npm] npm install evlog ``` :: ### 2. Create a middleware ```typescript [src/middleware.ts] import { defineMiddleware } from 'astro:middleware' import { initLogger, createRequestLogger } from 'evlog' initLogger({ env: { service: 'my-astro-app' }, }) export const onRequest = defineMiddleware(async ({ request, locals }, next) => { const url = new URL(request.url) const log = createRequestLogger({ method: request.method, path: url.pathname, }) locals.log = log try { const response = await next() log.emit() return response } catch (error) { log.error(error instanceof Error ? error : new Error(String(error))) log.emit() throw error } }) ``` ### 3. Type your locals ```typescript [src/env.d.ts] /// import type { RequestLogger } from 'evlog' declare namespace App { interface Locals { log: RequestLogger } } ``` ## Wide Events Access the logger from `Astro.locals` in your pages and API routes: ```typescript [src/pages/api/users/[id\\].ts] import type { APIRoute } from 'astro' export const GET: APIRoute = async ({ params, locals }) => { locals.log.set({ user: { id: params.id } }) const user = await db.findUser(params.id) locals.log.set({ user: { name: user.name, plan: user.plan } }) return new Response(JSON.stringify(user), { headers: { 'Content-Type': 'application/json' }, }) } ``` ```bash [Terminal output] 14:58:15 INFO [my-astro-app] GET /api/users/usr_123 ├─ user: id=usr_123 name=Alice plan=pro └─ requestId: 4a8ff3a8-... ``` ## Error Handling Use `createError` for structured errors: ```typescript [src/pages/api/checkout.ts] import type { APIRoute } from 'astro' import { createError, parseError } from 'evlog' export const POST: APIRoute = async ({ request, locals }) => { const body = await request.json() locals.log.set({ cart: { items: body.items } }) if (!body.paymentMethod) { const error = createError({ status: 400, message: 'Missing payment method', why: 'No payment method was provided', fix: 'Include a paymentMethod field in the request body', }) locals.log.error(error) const parsed = parseError(error) return new Response(JSON.stringify(parsed), { status: parsed.status }) } return new Response(JSON.stringify({ success: true })) } ``` ## Configuration See the [Configuration reference](https://www.evlog.dev/reference/configuration) for all available options (`initLogger`, middleware options, sampling, silent mode, etc.). ## Drain Configure drain in `initLogger` inside your middleware: ```typescript [src/middleware.ts] import { initLogger, createRequestLogger } from 'evlog' import { createAxiomDrain } from 'evlog/axiom' import { createDrainPipeline } from 'evlog/pipeline' import type { DrainContext } from 'evlog' const pipeline = createDrainPipeline({ batch: { size: 50, intervalMs: 5000 }, }) const drain = pipeline(createAxiomDrain()) initLogger({ env: { service: 'my-astro-app' }, drain, }) ``` ::callout{color="info" icon="i-lucide-info"} See the [Adapters](https://www.evlog.dev/integrate/adapters/overview) docs for all available drain adapters. :: ## Next Steps - [Wide Events](https://www.evlog.dev/learn/wide-events): Design comprehensive events with context layering - [Adapters](https://www.evlog.dev/integrate/adapters/overview): Send logs to Axiom, Sentry, PostHog, and more - [Sampling](https://www.evlog.dev/learn/sampling): Control log volume with head and tail sampling - [Structured Errors](https://www.evlog.dev/learn/structured-errors): Throw errors with `why`, `fix`, and `link` fields # oRPC `evlog/orpc` ships two primitives: `withEvlog(handler)` wraps any oRPC handler (`RPCHandler`, `OpenAPIHandler`) so each request becomes one wide event, and `evlog()` is a procedure middleware that exposes `context.log` and tags the wide event with the procedure path as `operation`. ::callout{color="info" icon="i-lucide-info"} **oRPC v2:** The oRPC maintainers have [announced first-party evlog support in v2](https://github.com/middleapi/orpc/discussions/1293#discussioncomment-17032656){rel=""nofollow""} (edge-ready). Until v2 ships, `evlog/orpc` is the integration path for oRPC v1 — and it remains the entrypoint for evlog's full pipeline (drains, enrichers, tail sampling, structured errors) regardless of how oRPC wires it in later. :: ::prompt --- actions: - copy - cursor - claude description: Set up evlog in my oRPC app icon: i-lucide-network --- Set up evlog in my oRPC app. - Install evlog: pnpm add evlog - Call initLogger({ env: { service: 'my-rpc' } }) at startup - Wrap your RPCHandler / OpenAPIHandler with withEvlog() from 'evlog/orpc' - Add os.use(evlog()) on your base procedure for typed context.log + per-procedure operation - Declare EvlogOrpcContext on your base context to type context.log - Throw evlog errors (createError or defineErrorCatalog) directly from procedures — evlog/orpc bridges them to ORPCError - Pass drain, enrich, include, and keep options to withEvlog() Docs: {rel=""nofollow""} Adapters: {rel=""nofollow""} :: ## Quick Start ### 1. Install ::code-group ```bash [pnpm] pnpm add evlog @orpc/server ``` ```bash [bun] bun add evlog @orpc/server ``` ```bash [yarn] yarn add evlog @orpc/server ``` ```bash [npm] npm install evlog @orpc/server ``` :: ### 2. Initialize and wire the wrappers ```typescript [server/orpc.ts] 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().use(evlog()) const router = { health: base.handler(({ context }) => { context.log.set({ route: 'health' }) 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 }) } ``` ::callout{color="info" icon="i-custom-vite"} **Using Vite?** The [`evlog/vite` plugin](https://www.evlog.dev/reference/vite-plugin) replaces the `initLogger()` call with compile-time auto-initialization, strips `log.debug()` from production builds, and injects source locations. :: `EvlogOrpcContext` declares `log: RequestLogger` on the procedure context so `context.log` is fully typed in every procedure that descends from `base`. ## Wide Events Build up context progressively through your handler. One request = one wide event: ```typescript [server/orpc.ts] const getUser = base .input(z.object({ id: z.string() })) .handler(async ({ input, context }) => { context.log.set({ user: { id: input.id } }) const user = await db.findUser(input.id) context.log.set({ user: { name: user.name, plan: user.plan } }) const orders = await db.findOrders(input.id) context.log.set({ orders: { count: orders.length, totalRevenue: sum(orders) } }) return { user, orders } }) ``` All fields are merged into a single wide event emitted when the request completes. The `operation` field is filled automatically from the procedure path (nested routers like `users.profile.get` surface as `operation: 'users.profile.get'`): ```bash [Terminal output] 14:58:15 INFO [my-rpc] POST /rpc/getUser 200 in 12ms ├─ operation: getUser ├─ orders: count=2 totalRevenue=6298 ├─ user: id=usr_123 name=Alice plan=pro └─ requestId: 4a8ff3a8-... ``` ## useLogger() Use `useLogger()` to access the request-scoped logger from anywhere in the call stack without passing the context through your service layer: ```typescript [server/services/user.ts] import { useLogger } from 'evlog/orpc' export async function findUser(id: string) { const log = useLogger() log.set({ user: { id } }) const user = await db.findUser(id) log.set({ user: { name: user.name, plan: user.plan } }) return user } ``` ```typescript [server/orpc.ts] import { findUser } from './services/user' const getUser = base .input(z.object({ id: z.string() })) .handler(async ({ input }) => findUser(input.id)) ``` Both `context.log` and `useLogger()` return the same logger instance. `useLogger()` uses `AsyncLocalStorage` to propagate the logger across async boundaries. ## Error Handling Use `createError` for structured errors with `why`, `fix`, and `link` fields. The `evlog()` middleware catches the throw, records it on the wide event, and bridges it to an `ORPCError` so the wire response carries your `code`, `status`, `message`, and the human-guidance fields: ```typescript [server/orpc.ts] import { createError } from 'evlog' const checkout = base .handler(({ context }) => { context.log.set({ cart: { items: 3, total: 9999 } }) throw createError({ message: 'Payment failed', code: 'PAYMENT_DECLINED', status: 402, why: 'Card declined by issuer', fix: 'Try a different payment method', link: 'https://docs.example.com/payments/declined', }) }) ``` The error is captured and logged with both the custom context and structured error fields: ```bash [Terminal output] 14:58:20 ERROR [my-rpc] POST /rpc/checkout 402 in 3ms ├─ operation: checkout ├─ error: name=EvlogError code=PAYMENT_DECLINED status=402 message=Payment failed ├─ cart: items=3 total=9999 └─ requestId: 880a50ac-... ``` Wire response returned to the client: ```json [HTTP 402] { "defined": false, "code": "PAYMENT_DECLINED", "status": 402, "message": "Payment failed", "data": { "why": "Card declined by issuer", "fix": "Try a different payment method", "link": "https://docs.example.com/payments/declined" } } ``` ::callout{color="info" icon="i-lucide-info"} oRPC's error envelope is `{ defined, code, status, message, data }` — clients deserialize errors as a typed union via `safe()` from `@orpc/client` . evlog follows the protocol, so `why` / `fix` / `link` live under `data` instead of at the response root. The authoring API ( `createError` / [`defineErrorCatalog`](https://www.evlog.dev/learn/structured-errors#error-catalogs) ) is identical to the rest of evlog. :: ## Configuration See the [Configuration reference](https://www.evlog.dev/reference/configuration) for all available options (`initLogger`, middleware options, sampling, silent mode, etc.). ## Drain & Enrichers Configure drain adapters and enrichers directly in the `withEvlog()` options: ```typescript [server/orpc.ts] import { createAxiomDrain } from 'evlog/axiom' import { createUserAgentEnricher } from 'evlog/enrichers' const userAgent = createUserAgentEnricher() const handler = withEvlog(new RPCHandler(router), { drain: createAxiomDrain(), enrich: (ctx) => { userAgent(ctx) ctx.event.region = process.env.FLY_REGION }, }) ``` ### Pipeline (Batching & Retry) For production, wrap your adapter with `createDrainPipeline` to batch events and retry on failure: ```typescript [server/orpc.ts] import type { DrainContext } from 'evlog' import { createAxiomDrain } from 'evlog/axiom' import { createDrainPipeline } from 'evlog/pipeline' const pipeline = createDrainPipeline({ batch: { size: 50, intervalMs: 5000 }, retry: { maxAttempts: 3 }, }) const drain = pipeline(createAxiomDrain()) const handler = withEvlog(new RPCHandler(router), { drain }) ``` ::callout{color="info" icon="i-lucide-info"} Call `drain.flush()` on server shutdown to ensure all buffered events are sent. See the [Pipeline docs](https://www.evlog.dev/extend/drain-pipeline) for all options. :: ## Tail Sampling Use `keep` to force-retain specific events regardless of head sampling: ```typescript [server/orpc.ts] const handler = withEvlog(new RPCHandler(router), { drain: createAxiomDrain(), keep: (ctx) => { if (ctx.duration && ctx.duration > 2000) ctx.shouldKeep = true }, }) ``` ## Route Filtering `include` / `exclude` match against the HTTP path (the request URL), not the procedure name: ```typescript [server/orpc.ts] const handler = withEvlog(new RPCHandler(router), { include: ['/rpc/**'], exclude: ['/rpc/_internal/**', '/health'], routes: { '/rpc/auth/**': { service: 'auth-service' }, '/rpc/payment/**': { service: 'payment-service' }, }, }) ``` When a route is filtered out, the wrapper still injects a no-op `context.log` so procedures never crash on missing fields — the wide event simply isn't emitted and drain/enrich aren't called. ## Run Locally ```bash [Terminal] git clone https://github.com/hugorcd/evlog.git cd evlog pnpm install pnpm example orpc ``` Open {rel=""nofollow""} to explore the interactive test UI. ::card-group :::card --- icon: i-simple-icons-github title: Source Code to: https://github.com/hugorcd/evlog/tree/main/examples/orpc --- Browse the complete oRPC example source on GitHub. ::: :: ## Next Steps Deepen your **oRPC** integration: - [Wide Events](https://www.evlog.dev/learn/wide-events): Design comprehensive events with context layering - [Adapters](https://www.evlog.dev/integrate/adapters/overview): Send logs to Axiom, Sentry, PostHog, and more - [Sampling](https://www.evlog.dev/learn/sampling): Control log volume with head and tail sampling - [Structured Errors](https://www.evlog.dev/learn/structured-errors): Throw errors with `why`, `fix`, and `link` fields # AWS Lambda AWS Lambda has **no HTTP middleware lifecycle** like Nuxt or Express, so evlog behaves like [standalone TypeScript](https://www.evlog.dev/integrate/frameworks/standalone): call `initLogger()` once, create a logger **per invocation** (or per SQS message) with `createLogger()`, then call `log.emit()` when work finishes. ::prompt --- actions: - copy - cursor - claude description: Set up evlog in my AWS Lambda function icon: i-custom-lambda --- Set up evlog in an AWS Lambda function (e.g. SQS consumer). - Install evlog: pnpm add evlog - Call initLogger({ env: { service: 'my-fn' } }) once at module load (cold start) - In the handler, create a new createLogger({ messageId, ... }) per invocation or per message - Use log.set() to accumulate context; call log.emit() when done - Avoid a single module-level logger instance reused across invocations (Lambda reuses runtimes) Docs: {rel=""nofollow""} Adapters: {rel=""nofollow""} :: ## Why not one global `createLogger`? Lambda **execution environments are reused**: the same process can handle many invocations in sequence. Module-level variables persist, so **one shared logger instance** can leak fields from a previous invocation into the next. **Do this:** `initLogger()` once at the top level (configuration only), and **`createLogger()` inside the handler** (or inside the loop over SQS records) for each unit of work. **Dependency injection** (passing `log` into functions) is optional—it helps tests and clarity—but what matters is **one logger per invocation**, not whether you use DI. ## Quick Start ### 1. Install ::code-group ```bash [pnpm] pnpm add evlog ``` ```bash [bun] bun add evlog ``` ```bash [yarn] yarn add evlog ``` ```bash [npm] npm install evlog ``` :: ### 2. Initialize once, log per invocation ```typescript [src/handler.ts] import type { SQSEvent } from 'aws-lambda' import { initLogger, createLogger } from 'evlog' initLogger({ env: { service: 'sqs-consumer', environment: process.env.NODE_ENV }, }) export async function handler(event: SQSEvent) { for (const record of event.Records) { const log = createLogger({ messageId: record.messageId, approximateReceiveCount: record.attributes?.ApproximateReceiveCount, }) try { log.set({ queue: { name: record.eventSourceARN } }) // … parse record.body and process the message log.set({ status: 'ok' }) } catch (error) { log.error(error instanceof Error ? error : new Error(String(error))) log.set({ status: 'error' }) throw error } finally { log.emit() } } } ``` If you process the whole batch as one logical unit, use a **single** `createLogger()` per handler invocation with batch metadata instead of one logger per record. ## Stdout and `silent` Many teams ingest Lambda logs from **CloudWatch** via stdout. If you use a **drain adapter** (OTLP, Datadog, Axiom, etc.) and want JSON or platform-specific formatting without duplicate console noise, set `silent: true` in production—see [Configuration](https://www.evlog.dev/reference/configuration#silent-mode). ```typescript [src/handler.ts] import { createAxiomDrain } from 'evlog/axiom' import { initLogger } from 'evlog' initLogger({ env: { service: 'sqs-consumer' }, silent: process.env.NODE_ENV === 'production', drain: createAxiomDrain(), }) ``` ::callout{color="warning" icon="i-lucide-alert-triangle"} If `silent` is enabled without a `drain` , events may not be visible anywhere. See the configuration docs for details. :: ## Error handling Use `createError` where you want structured fields (`why`, `fix`, `link`). Map failures to your Lambda return or rethrow so SQS retry/DLQ behavior stays correct—evlog does not replace AWS error semantics. ```typescript [src/handler.ts] import { createError } from 'evlog' throw createError({ message: 'Invalid payload', status: 400, why: 'Required field missing', fix: 'Include orderId in the message body', }) ``` ## Related - [Standalone TypeScript](https://www.evlog.dev/integrate/frameworks/standalone): same `initLogger` + `createLogger` + `emit()` model - [Configuration](https://www.evlog.dev/reference/configuration): `silent`, `env.region` (`AWS_REGION`), drains - [Wide Events](https://www.evlog.dev/learn/wide-events): designing one comprehensive event per unit of work # Use Cases Use Cases are **recipes**, not features. Each one solves a specific problem with the same evlog primitives you already know — wide events, structured errors, drains, enrichers. They live as their own section because they have enough surface area (multiple pages each, dedicated examples) to deserve direct navigation, but they're not a separate runtime — same logger, same drain pipeline, same types. | You want to… | See | | -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | | Send browser logs to your server with batching, retries, and `sendBeacon` fallback | [Client Logging](https://www.evlog.dev/use-cases/client-logging) | | Capture every AI SDK call with token usage, tool calls, streaming metrics, and cost | [AI SDK](https://www.evlog.dev/use-cases/ai-sdk/overview) | | Identify the authenticated user (and their org / role) on every wide event | [Better Auth](https://www.evlog.dev/use-cases/better-auth/overview) | | Build a tamper-evident audit trail with hash chains, denials, redaction-aware diffs | [Audit Logs](https://www.evlog.dev/use-cases/audit/overview) | | Instrument CLIs and automation with one wide event per run, consent, and disclosure | [Telemetry](https://www.evlog.dev/use-cases/telemetry/overview) | | Export wide events from [eve](https://eve.dev){rel=""nofollow""} agent turns (tokens, tools, drains) | [eve](https://www.evlog.dev/use-cases/eve) | | Add derived context (User-Agent, geo, request size, trace context) to every event | [Enrichers](https://www.evlog.dev/use-cases/enrichers) | ## How they relate ```text your app code │ ▼ ┌──────────────────────────────────────────────────────────────────┐ │ evlog logger │ │ │ │ ┌─────────────────┐ ┌─────────────────┐ ┌──────────────┐ │ │ │ Client logs │ │ AI SDK wrapper │ │ Audit logger │ │ │ │ (browser → API) │ │ (token / tools) │ │ (hash chain) │ │ │ └─────────────────┘ └─────────────────┘ └──────────────┘ │ │ │ │ │ │ │ └─────────────────────┼────────────────────┘ │ │ ▼ │ │ enrich → drain │ │ (User-Agent, geo, trace, …) │ └──────────────────────────────────────────────────────────────────┘ ``` Every use case is opt-in. Adopt one, two, or all five — they coexist in the same logger, the same drain pipeline, and the same enrich chain. ## Where to go next - Building a new use case from scratch? Start with [Wide Events](https://www.evlog.dev/learn/wide-events) for the conceptual model. - Need to send the resulting events somewhere? Pick an [adapter](https://www.evlog.dev/integrate/adapters/overview). - Want to write your own use case as a reusable enricher / plugin? See [Custom Enrichers](https://www.evlog.dev/extend/custom-enrichers) or [Plugins](https://www.evlog.dev/extend/plugins). # Client Logging Server logs tell you what happened on the backend. Client logs complete the picture: user interactions, page views, frontend errors, and performance signals that never reach the server unless you capture them. :client-server-beacon ::prompt --- actions: - copy - cursor - claude description: Ship browser logs to my server icon: i-lucide-monitor --- Ship browser logs to my server with evlog client logging. - Detect my framework (Nuxt, Next.js, SvelteKit, etc.) and pick the matching client entrypoint - Call initLog({ service: 'web' }) once at app start - Use log.info / log.warn / log.error in components, composables, and event handlers - Set user identity with setIdentity({ userId, email }) after login, clearIdentity() on logout - Enable transport in the framework config so logs POST to /api/\_evlog/ingest in batches - On the server, evlog auto-runs the drain pipeline on transported events with source: 'client' Docs: {rel=""nofollow""} HTTP transport: {rel=""nofollow""} :: ## Quick Start evlog provides a client-side logging API that works in any browser environment: ::code-group ```typescript [app/plugins/logger.client.ts (Nuxt)] import { initLog, log } from 'evlog/client' export default defineNuxtPlugin(() => { initLog({ service: 'web' }) log.info({ action: 'app_init', path: window.location.pathname }) }) ``` ```typescript [app/providers.tsx (React / Next.js)] 'use client' import { useEffect } from 'react' import { initLog, log } from 'evlog/client' export function LogProvider({ children }: { children: React.ReactNode }) { useEffect(() => { initLog({ service: 'web' }) log.info({ action: 'app_init', path: window.location.pathname }) }, []) return <>{children} } ``` ```typescript [src/app.ts (Any frontend)] import { initLog, log } from 'evlog/client' initLog({ service: 'web' }) log.info({ action: 'app_init', path: window.location.pathname }) ``` :: The `log` object works anywhere in your client code: components, composables, event handlers. ## Minimum level (`minLevel`) Use `initLog({ minLevel: 'warn' })` to keep the browser console quiet (warnings and errors only). Severity order: `debug` < `info` < `warn` < `error`. Default is `'debug'` (all levels). For a **debug toggle** without reloading, call `setMinLevel('debug')` or `setMinLevel('warn')` from `evlog/client` when the user opts in or out of verbose logs. `minLevel` applies to both console output and [server transport](https://www.evlog.dev/#sending-logs-to-the-server) payloads. ## Two Call Signatures The `log` API accepts two forms depending on the context. ### Object Form (structured context) Pass an object to capture structured data, just like server-side `log.set()`: ```typescript [pages/products.vue] log.info({ action: 'page_view', path: '/products', referrer: document.referrer }) ``` ```bash [Browser console] [web] info { action: 'page_view', path: '/products', referrer: 'https://google.com' } ``` ### Tag + Message Form (quick logs) Pass a tag and a message for quick, readable logs: ```typescript [composables/useAuth.ts] log.info('auth', 'User logged in') ``` ```bash [Browser console] [auth] User logged in ``` ### Available Levels Both forms support four levels: `log.info()`, `log.warn()`, `log.error()`, and `log.debug()`. In the browser, `log.debug()` is emitted with `console.log` (not `console.debug`) so lines stay visible with the default DevTools **Info** filter; the structured event still has `level: 'debug'`. ## Identity Context Track which user generated a log with `setIdentity()`: ```typescript [composables/useAuth.ts] import { setIdentity, clearIdentity, log } from 'evlog/client' // After login setIdentity({ userId: 'usr_123', plan: 'pro' }) log.info({ action: 'dashboard_view' }) // → { userId: 'usr_123', plan: 'pro', action: 'dashboard_view', ... } // After logout clearIdentity() ``` Identity fields are automatically merged into every log event until cleared. This lets you correlate browser events to specific users in your observability tools. ## Configuration `initLog()` accepts the following options: | Option | Default | Description | | ----------- | ---------- | ----------------------------------------------------- | | `enabled` | `true` | Enable or disable all client logging | | `console` | `true` | Output logs to the browser console | | `pretty` | `true` | Use colored, formatted console output | | `minLevel` | `'debug'` | Minimum severity: `debug` < `info` < `warn` < `error` | | `service` | `'client'` | Service name included in every log event | | `transport` | - | Send logs to a server endpoint (see below) | ```typescript [app/plugins/logger.client.ts] initLog({ service: 'web', transport: { enabled: true, endpoint: '/api/_evlog/ingest', // default endpoint }, }) ``` ::callout{color="info" icon="i-lucide-info"} `enabled` , `console` , and `pretty` all default to `true` . You only need to set them if you want to change the defaults. :: ## Sending Logs to the Server By default, client logs only appear in the browser console. To persist them, you have two options: ### Built-in Transport The simplest approach is to enable the built-in transport in `initLog()`. Each log is sent individually via `fetch` with `keepalive: true`. Good for low-volume apps. ::code-group ```typescript [app/plugins/logger.client.ts (Nuxt)] import { initLog } from 'evlog/client' export default defineNuxtPlugin(() => { initLog({ service: 'web', transport: { enabled: true, endpoint: '/api/_evlog/ingest', }, }) }) ``` ```tsx [app/layout.tsx (Next.js)] import { EvlogProvider } from 'evlog/next/client' export default function Layout({ children }: { children: React.ReactNode }) { return ( {children} ) } ``` ```typescript [src/app.ts (Any frontend)] import { initLog } from 'evlog/client' initLog({ service: 'web', transport: { enabled: true, endpoint: '/api/_evlog/ingest', }, }) ``` :: ::callout{color="info" icon="i-lucide-info"} In Nuxt with the evlog module, the server ingest endpoint is auto-registered. For other frameworks, you need to create the endpoint yourself. See the [HTTP drain](https://www.evlog.dev/extend/drain-pipeline#server-endpoint) docs for Express and Hono examples. :: ### HTTP drain pipeline For higher volume or when you need batching, retries, and page-exit flushing, use the HTTP drain (`evlog/http`). This works with any frontend and has no framework dependency. ::code-group ```typescript [app/plugins/logger.client.ts (Nuxt)] import { initLogger, log } from 'evlog' import { createHttpLogDrain } from 'evlog/http' export default defineNuxtPlugin(() => { const drain = createHttpLogDrain({ drain: { endpoint: '/api/_evlog/ingest' }, pipeline: { batch: { size: 25, intervalMs: 2000 }, retry: { maxAttempts: 2 }, }, }) initLogger({ drain }) log.info({ action: 'app_init' }) }) ``` ```typescript [src/app.ts (Any frontend)] import { initLogger, log } from 'evlog' import { createHttpLogDrain } from 'evlog/http' const drain = createHttpLogDrain({ drain: { endpoint: 'https://logs.example.com/v1/ingest' }, pipeline: { batch: { size: 25, intervalMs: 2000 }, retry: { maxAttempts: 2 }, }, }) initLogger({ drain }) log.info({ action: 'app_init' }) ``` :: The HTTP drain automatically: - **Batches** events by size and time interval - **Retries** failed sends with exponential backoff - **Flushes** buffered events via `sendBeacon` when the page becomes hidden (tab switch, navigation, close) ::callout{color="neutral" icon="i-lucide-arrow-right"} See the [HTTP drain](https://www.evlog.dev/extend/drain-pipeline) adapter docs for full configuration reference, authentication, and server endpoint examples. :: ## Next Steps - [HTTP drain](https://www.evlog.dev/extend/drain-pipeline) - Batching, retry, and sendBeacon fallback - [Pipeline](https://www.evlog.dev/extend/drain-pipeline) - Advanced pipeline configuration - [Structured Errors](https://www.evlog.dev/learn/structured-errors) - Surface client errors with actionable context # AI SDK Integration `evlog/ai` gives you full AI observability by wrapping your model with middleware. Token usage, tool calls, streaming performance, cache hits, reasoning tokens, and cost estimation — all captured into the wide event automatically. ::prompt --- actions: - copy - cursor - claude description: Add AI observability with evlog icon: i-simple-icons-vercel --- Add AI observability to my app with evlog. - Install the AI SDK: pnpm add ai - Import createAILogger from 'evlog/ai' - Create an AI logger with createAILogger(log) where log is your request logger - Wrap your model with ai.wrap('anthropic/claude-sonnet-4.6') and pass it to generateText, streamText, etc. - Token usage, tool calls, streaming metrics, and errors are captured automatically into the wide event - For deeper observability (tool execution timing, total generation wall time), add createEvlogIntegration(ai) to telemetry.integrations - For embedding calls, use ai.captureEmbed({ usage, model, dimensions, count }) after embed() or embedMany() - For cost estimation, pass a cost map: createAILogger(log, { cost: { 'claude-sonnet-4.6': { input: 3, output: 15 } } }) - Works with all frameworks: Nuxt, Express, Hono, Fastify, NestJS, Elysia, standalone Docs: {rel=""nofollow""} Adapters: {rel=""nofollow""} :: ## Install Add the AI SDK as a dependency: ::code-group ```bash [pnpm] pnpm add ai ``` ```bash [bun] bun add ai ``` ```bash [yarn] yarn add ai ``` ```bash [npm] npm install ai ``` :: ## Quick Start :ai-sdk-wide-event Two lines to add, one param to change: ::code-group ```typescript [Before] export default defineEventHandler(async (event) => { const result = streamText({ model: 'anthropic/claude-sonnet-4.6', messages, }) return result.toTextStreamResponse() }) ``` ```typescript [After] import { useLogger } from 'evlog' import { createAILogger } from 'evlog/ai' export default defineEventHandler(async (event) => { const log = useLogger(event) const ai = createAILogger(log) const result = streamText({ model: ai.wrap('anthropic/claude-sonnet-4.6'), messages, }) return result.toTextStreamResponse() }) ``` :: Your wide event now includes: ```json [Wide Event] { "method": "POST", "path": "/api/chat", "status": 200, "duration": "4.5s", "durationMs": 4512, "ai": { "calls": 1, "model": "claude-sonnet-4.6", "provider": "anthropic", "inputTokens": 3312, "outputTokens": 814, "totalTokens": 4126, "reasoningTokens": 225, "finishReason": "stop", "msToFirstChunk": 234, "msToFinish": 4500, "tokensPerSecond": 180 } } ``` ## How It Works `createAILogger(log, options?)` returns an `AILogger` with the following methods: | Method | Description | | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `wrap(model)` | Wraps a language model with middleware. Accepts a model string (e.g. `'anthropic/claude-sonnet-4.6'`) or a `LanguageModelV3` / `LanguageModelV4` object. Works with `generateText`, `streamText`, and `ToolLoopAgent`. | | `captureEmbed(result)` | Manually captures token usage, model info, and dimensions from `embed()` or `embedMany()` results. | | `getMetadata()` | Returns a snapshot of the current execution metadata. See [Access Metadata](https://www.evlog.dev/use-cases/ai-sdk/metadata). | | `getEstimatedCost()` | Returns the current estimated cost in dollars when a `cost` map is configured. | | `onUpdate(callback)` | Subscribe to metadata updates. Fires on every step, embed, error, and integration finish. | The middleware intercepts calls at the provider level. It does not touch your callbacks, prompts, or responses. Captured data flows through the normal evlog pipeline (sampling, enrichers, drains) and lands in Axiom, Better Stack, or wherever you drain to. ## Where to next ::card-group :::card --- icon: i-lucide-code title: Usage Patterns to: https://www.evlog.dev/use-cases/ai-sdk/usage --- `streamText` , `generateText` , multi-step agents, RAG, multiple models — every common pattern, ready to copy. ::: :::card --- icon: i-lucide-sliders title: Options to: https://www.evlog.dev/use-cases/ai-sdk/options --- Capture tool inputs (with redaction and truncation), enable cost estimation, and handle errors. ::: :::card --- icon: i-lucide-database title: Access Metadata to: https://www.evlog.dev/use-cases/ai-sdk/metadata --- Read the captured `ai` data inside your handler — persist it, bill against it, or stream it to the client. ::: :::card --- icon: i-lucide-activity title: Deeper Telemetry to: https://www.evlog.dev/use-cases/ai-sdk/telemetry --- Add tool execution timing and total wall time with `createEvlogIntegration` . Compose with other middlewares. ::: :: ## Works With All Frameworks `evlog/ai` works with any framework that evlog supports: ::code-group ```typescript [Nuxt] import { useLogger } from 'evlog' import { createAILogger } from 'evlog/ai' const log = useLogger(event) const ai = createAILogger(log) ``` ```typescript [Next.js] import { withEvlog, useLogger } from '@/lib/evlog' import { createAILogger } from 'evlog/ai' export const POST = withEvlog(async () => { const log = useLogger() const ai = createAILogger(log) // ... }) ``` ```typescript [Express] import { createAILogger } from 'evlog/ai' app.post('/api/chat', (req, res) => { const ai = createAILogger(req.log) // ... }) ``` ```typescript [Hono] import { createAILogger } from 'evlog/ai' app.post('/api/chat', (c) => { const ai = createAILogger(c.get('log')) // ... }) ``` ```typescript [Fastify] import { createAILogger } from 'evlog/ai' app.post('/api/chat', async (request) => { const ai = createAILogger(request.log) // ... }) ``` ```typescript [NestJS] import { useLogger } from 'evlog/nestjs' import { createAILogger } from 'evlog/ai' const log = useLogger() const ai = createAILogger(log) ``` ```typescript [Standalone] import { createLogger } from 'evlog' import { createAILogger } from 'evlog/ai' const log = createLogger() const ai = createAILogger(log) // ... log.emit() ``` :: # Usage Patterns Every pattern below uses the same `createAILogger(log)` setup. Wrap the model with `ai.wrap()` and the middleware accumulates tokens, tools, and timing on the wide event automatically. On Next.js, Nuxt/Nitro, SvelteKit, Hono, React Router, and oRPC, evlog defers wide-event emit for streaming responses (for example `text/event-stream` and AI SDK UI streams) until the body finishes, so late `ai` metadata stays on the same request event. ## streamText The most common pattern — streaming chat with full observability: ```typescript [server/api/chat.post.ts] import { streamText } from 'ai' import { createAILogger } from 'evlog/ai' export default defineEventHandler(async (event) => { const log = useLogger(event) const ai = createAILogger(log) const { messages } = await readBody(event) log.set({ action: 'chat', messagesCount: messages.length }) const result = streamText({ model: ai.wrap('anthropic/claude-sonnet-4.6'), messages, onFinish: ({ text }) => { saveConversation(text) }, }) return result.toTextStreamResponse() }) ``` The middleware never touches your `onFinish` callback — your code runs as usual. ## generateText Synchronous generation. The middleware captures the result automatically: ```typescript [server/api/summarize.post.ts] import { generateText } from 'ai' import { createAILogger } from 'evlog/ai' export default defineEventHandler(async (event) => { const log = useLogger(event) const ai = createAILogger(log) const result = await generateText({ model: ai.wrap('anthropic/claude-sonnet-4.6'), prompt: 'Summarize this document', }) return { text: result.text } }) ``` ## Multi-step Agents The middleware fires for each step automatically. Steps, tool calls, and tokens are accumulated across the agent loop: ```typescript [server/api/agent.post.ts] import { ToolLoopAgent, createAgentUIStreamResponse, stepCountIs } from 'ai' import { useLogger } from 'evlog' import { createAILogger } from 'evlog/ai' export default defineEventHandler(async (event) => { const log = useLogger(event) const { messages } = await readBody(event) const ai = createAILogger(log, { toolInputs: { maxLength: 500 }, }) const agent = new ToolLoopAgent({ model: ai.wrap('anthropic/claude-sonnet-4.6'), tools: { searchWeb, queryDatabase }, stopWhen: stepCountIs(5), }) return createAgentUIStreamResponse({ agent, uiMessages: messages, }) }) ``` Wide event after a 3-step agent run: ```json [Wide Event] { "ai": { "calls": 3, "steps": 3, "model": "claude-sonnet-4.6", "provider": "anthropic", "inputTokens": 4500, "outputTokens": 1200, "totalTokens": 5700, "finishReason": "stop", "toolCalls": [ { "name": "searchWeb", "input": { "query": "TypeScript 6.0 features" } }, { "name": "queryDatabase", "input": { "sql": "SELECT * FROM docs WHERE topic = 'typescript'" } }, { "name": "searchWeb", "input": { "query": "TypeScript 6.0 release date" } } ], "responseId": "msg_01XFDUDYJgAACzvnptvVoYEL", "stepsUsage": [ { "model": "claude-sonnet-4.6", "inputTokens": 1200, "outputTokens": 300, "toolCalls": ["searchWeb"] }, { "model": "claude-sonnet-4.6", "inputTokens": 1500, "outputTokens": 400, "toolCalls": ["queryDatabase", "searchWeb"] }, { "model": "claude-sonnet-4.6", "inputTokens": 1800, "outputTokens": 500 } ], "msToFirstChunk": 312, "msToFinish": 8200, "tokensPerSecond": 146 } } ``` ::tip Pair this with [`createEvlogIntegration`](https://www.evlog.dev/use-cases/ai-sdk/telemetry) to also capture per-tool execution timing and the agent's total wall time. :: ## RAG (embed + generate) Embedding models use a different type that cannot be wrapped with middleware. Use `captureEmbed` instead: ```typescript [server/api/rag.post.ts] import { embed, generateText } from 'ai' import { useLogger } from 'evlog' import { createAILogger } from 'evlog/ai' export default defineEventHandler(async (event) => { const log = useLogger(event) const ai = createAILogger(log) const { embedding, usage } = await embed({ model: openai.embedding('text-embedding-3-small'), value: query, }) ai.captureEmbed({ usage, model: 'text-embedding-3-small', dimensions: 1536, }) const docs = await findSimilar(embedding) const result = await generateText({ model: ai.wrap('anthropic/claude-sonnet-4.6'), prompt: buildPrompt(docs), }) return { text: result.text } }) ``` For `embedMany`, pass the batch count: ```typescript const { embeddings, usage } = await embedMany({ model: openai.embedding('text-embedding-3-small'), values: documents, }) ai.captureEmbed({ usage, model: 'text-embedding-3-small', count: documents.length }) ``` ## Multiple Models Wrap each model separately — they share the same accumulator. When more than one model is used, the wide event includes both `model` (last model) and `models` (all unique models): ::code-group ```typescript [server/api/chat.post.ts] const ai = createAILogger(log) const fast = ai.wrap('anthropic/claude-haiku-4.5') const smart = ai.wrap('anthropic/claude-sonnet-4.6') const classification = await generateText({ model: fast, prompt: classifyPrompt }) const response = await generateText({ model: smart, prompt: detailedPrompt }) ``` ```json [Wide Event] { "ai": { "calls": 2, "model": "claude-sonnet-4.6", "models": ["claude-haiku-4.5", "claude-sonnet-4.6"], "provider": "anthropic", "inputTokens": 450, "outputTokens": 300, "totalTokens": 750 } } ``` :: ## Model Object Support `wrap()` accepts model objects from provider SDKs — both `LanguageModelV3` (AI SDK v6) and `LanguageModelV4` (AI SDK v7): ```typescript [server/api/chat.post.ts] import { anthropic } from '@ai-sdk/anthropic' const model = ai.wrap(anthropic('claude-sonnet-4.6')) ``` # Options `createAILogger(log, options?)` accepts a single options bag. Every option is opt-in — defaults stay safe and quiet. | Option | Type | Default | Description | | ------------ | ----------------------------- | ----------- | ------------------------------------------------------------------------------------------------ | | `toolInputs` | `boolean | ToolInputsOptions` | `false` | Capture tool call inputs alongside their names (off by default to avoid leaking sensitive data). | | `cost` | `Record` | `undefined` | Pricing map. Keys are model IDs, values are `{ input, output }` in dollars per 1M tokens. | ## Tool Inputs By default, `ai.toolCalls` is a `string[]` of tool names. Enable `toolInputs` to capture inputs too — useful for debugging agent behaviour or auditing what data the model reached for. ::warning Tool inputs can be large and may contain sensitive data (SQL, API keys, customer PII). Use `maxLength` and `transform` rather than enabling raw capture in production. :: ### Capture everything ```typescript const ai = createAILogger(log, { toolInputs: true }) ``` ### Truncate long inputs ```typescript const ai = createAILogger(log, { toolInputs: { maxLength: 200 } }) ``` ### Redact sensitive fields ```typescript const ai = createAILogger(log, { toolInputs: { maxLength: 500, transform: (input, toolName) => { if (toolName === 'queryDB') return { sql: '***' } return input }, }, }) ``` | Sub-option | Type | Description | | ----------- | ------------------------------ | ---------------------------------------------------------------------------------- | | `maxLength` | `number` | Truncate stringified inputs exceeding this character length (appends `…`). | | `transform` | `(input, toolName) => unknown` | Custom transform applied before `maxLength`. Use to redact fields or reshape data. | When `toolInputs` is enabled, `ai.toolCalls` becomes an `Array<{ name, input }>` instead of a plain string array. ## Cost Estimation Pass a `cost` map to compute estimated dollar cost per call. The middleware multiplies token usage by the per-million rates and sets `ai.estimatedCost` on the wide event. ```typescript const ai = createAILogger(log, { cost: { 'claude-sonnet-4.6': { input: 3, output: 15 }, 'gpt-4o': { input: 2.5, output: 10 }, }, }) ``` Read the result from your handler with [`ai.getEstimatedCost()`](https://www.evlog.dev/use-cases/ai-sdk/metadata) — useful for billing dashboards or warning users before expensive calls. ::tip Keep your `cost` map in one file alongside model selection so renaming a model in production also updates pricing. Avoid hardcoding per-route maps. :: ## Error Handling If a model call fails, the middleware captures the error into the wide event before re-throwing: ```json [Wide Event] { "ai": { "calls": 1, "model": "claude-sonnet-4.6", "provider": "anthropic", "finishReason": "error", "error": "API rate limit exceeded" } } ``` Stream errors (e.g. content filter) are also captured from the stream's error chunks. Your error-handling code (`try/catch`, route-level error handlers) keeps working as usual — the middleware only observes. # Access Metadata The wide event already contains the full `ai` metadata, but you often want the same data inside your handler — to persist it, surface it to end-users, bill against it, or stream incremental progress to the client. `AILogger` exposes three methods for that, with no need to touch internal state. ## `getMetadata()` — final snapshot Returns a structured `AIMetadata` object that mirrors the `ai` field on the wide event. Safe to call at any point, including after the run completes or inside the AI SDK's `onFinish`: ```typescript [server/api/chat.post.ts] import { useLogger } from 'evlog' import { createAILogger } from 'evlog/ai' import { generateText } from 'ai' export default defineEventHandler(async (event) => { const log = useLogger(event) const ai = createAILogger(log, { cost: { 'claude-sonnet-4.6': { input: 3, output: 15 } }, }) await generateText({ model: ai.wrap('anthropic/claude-sonnet-4.6'), prompt: 'Summarize this document', }) const metadata = ai.getMetadata() await db.aiRuns.insert({ userId: event.context.userId, model: metadata.model, inputTokens: metadata.inputTokens, outputTokens: metadata.outputTokens, estimatedCost: metadata.estimatedCost, finishReason: metadata.finishReason, responseId: metadata.responseId, }) return { ok: true } }) ``` The snapshot is a fresh copy: mutating it never affects the underlying state or subsequent calls. ## `getEstimatedCost()` — quick cost check Convenience for `getMetadata().estimatedCost`. Returns the cost in dollars, or `undefined` if no `cost` map was provided or the model is not in the map. ```typescript const ai = createAILogger(log, { cost: { 'claude-sonnet-4.6': { input: 3, output: 15 } }, }) await generateText({ model: ai.wrap('anthropic/claude-sonnet-4.6'), prompt }) const cost = ai.getEstimatedCost() console.log(`This call cost $${cost?.toFixed(4)}`) ``` ## `onUpdate(callback)` — incremental updates Subscribe to metadata updates. The callback fires every time the underlying state flushes: - Once per step in multi-step agent runs - Once per `captureEmbed` call - On model errors - On `createEvlogIntegration`'s `onEnd` (v7) or `onFinish` (v6) Each invocation receives a fresh snapshot. Returns an unsubscribe function. Subscriber errors are isolated and never break the AI flow. ```typescript [server/api/agent.post.ts] import { ToolLoopAgent, createAgentUIStreamResponse, stepCountIs } from 'ai' import { useLogger } from 'evlog' import { createAILogger } from 'evlog/ai' export default defineEventHandler(async (event) => { const log = useLogger(event) const { messages } = await readBody(event) const ai = createAILogger(log) ai.onUpdate((metadata) => { pushToClient(event, { type: 'ai-progress', step: metadata.steps, tokens: metadata.totalTokens, cost: metadata.estimatedCost, }) }) const agent = new ToolLoopAgent({ model: ai.wrap('anthropic/claude-sonnet-4.6'), tools: { searchWeb, queryDatabase }, stopWhen: stepCountIs(5), }) return createAgentUIStreamResponse({ agent, uiMessages: messages }) }) ``` For one-off cleanup: ```typescript const off = ai.onUpdate((metadata) => { /* ... */ }) // later off() ``` ## `AIMetadata` shape `AIMetadata` is a public type alias for the snapshot returned by `getMetadata()` and passed to `onUpdate` listeners. It has the same shape as the `ai` field on the wide event. ```typescript import type { AIMetadata, AIMetadataListener } from 'evlog/ai' function handleProgress(metadata: AIMetadata) { console.log(`${metadata.calls} calls, $${metadata.estimatedCost ?? 0}`) } const listener: AIMetadataListener = handleProgress ai.onUpdate(listener) ``` ## Captured Data Reference Every field that may show up under `ai.*`: | Wide event field | Source | Description | | --------------------- | ------------------------------ | --------------------------------------------------------------------------------------------- | | `ai.calls` | Call count | Number of AI calls in this request | | `ai.model` | `response.modelId` | Model that served the response | | `ai.models` | All model IDs | Array of all models used (only when > 1) | | `ai.provider` | `model.provider` | Provider (`anthropic`, `openai`, `google`, etc.) | | `ai.inputTokens` | `usage.inputTokens.total` | Total input tokens across all calls | | `ai.outputTokens` | `usage.outputTokens.total` | Total output tokens across all calls | | `ai.totalTokens` | Computed | `inputTokens + outputTokens` | | `ai.cacheReadTokens` | `usage.inputTokens.cacheRead` | Tokens served from prompt cache | | `ai.cacheWriteTokens` | `usage.inputTokens.cacheWrite` | Tokens written to prompt cache | | `ai.reasoningTokens` | `usage.outputTokens.reasoning` | Reasoning tokens (extended thinking) | | `ai.finishReason` | `finishReason.unified` | Why generation ended (`stop`, `tool-calls`, etc.) | | `ai.toolCalls` | Content / stream chunks | `string[]` of tool names by default, or `Array<{ name, input }>` when `toolInputs` is enabled | | `ai.responseId` | `response.id` | Provider-assigned response ID (e.g. Anthropic's `msg_...`) | | `ai.steps` | Step count | Number of LLM calls (only when > 1) | | `ai.stepsUsage` | Per-step accumulation | Per-step token and tool call breakdown (only when > 1 step) | | `ai.msToFirstChunk` | Stream timing | Time to first text chunk (streaming only) | | `ai.msToFinish` | Stream timing | Total stream duration (streaming only) | | `ai.tokensPerSecond` | Computed | Output tokens per second (streaming only) | | `ai.error` | Error capture | Error message if a model call fails | | `ai.tools` | Telemetry integration | Per-tool `{ name, durationMs, success, error? }` (requires `createEvlogIntegration`) | | `ai.totalDurationMs` | Telemetry integration | Total generation wall time (requires `createEvlogIntegration`) | | `ai.embedding` | `captureEmbed` | `{ model?, tokens, dimensions?, count? }` — embedding metadata | | `ai.estimatedCost` | Computed | Estimated cost in dollars (requires `cost` option) | # Deeper Telemetry `createAILogger` covers tokens, model info, and streaming metrics. For deeper observability — per-tool execution timing, success/failure tracking, and total generation wall time — add `createEvlogIntegration()` on top. It implements the AI SDK telemetry interface (v6 `TelemetryIntegration`, v7 `Telemetry`) and captures data middleware alone cannot see. On AI SDK v7, the integration also auto-captures embeddings via `onEmbedEnd`, records stream aborts via `onAbort`, and surfaces unrecoverable errors via `onError`. ## Combined with middleware (recommended) When passed an `AILogger`, the integration shares its accumulator. Both paths write to the same `ai.*` field: ```typescript [server/api/agent.post.ts] import { generateText } from 'ai' import { createAILogger, createEvlogIntegration } from 'evlog/ai' export default defineEventHandler(async (event) => { const log = useLogger(event) const ai = createAILogger(log) const result = await generateText({ model: ai.wrap('anthropic/claude-sonnet-4.6'), tools: { getWeather, searchDB }, telemetry: { integrations: [createEvlogIntegration(ai)], }, }) return { text: result.text } }) ``` Your wide event now includes per-tool timing: ```json [Wide Event] { "ai": { "calls": 2, "steps": 2, "model": "claude-sonnet-4.6", "provider": "anthropic", "inputTokens": 3500, "outputTokens": 800, "totalTokens": 4300, "toolCalls": ["getWeather", "searchDB"], "tools": [ { "name": "getWeather", "durationMs": 150, "success": true }, { "name": "searchDB", "durationMs": 45, "success": true } ], "totalDurationMs": 2340, "msToFirstChunk": 180, "msToFinish": 2100, "tokensPerSecond": 380 } } ``` ## Standalone (without middleware) If your model is already wrapped (e.g. by another middleware), pass the request logger directly: ```typescript [server/api/chat.post.ts] import { createEvlogIntegration } from 'evlog/ai' const integration = createEvlogIntegration(log) const result = await generateText({ model: somePreWrappedModel, telemetry: { integrations: [integration], }, }) ``` ## What the integration captures | Data | Source | Description | | -------------------------- | --------------------------------------------------- | ---------------------------------------------------------------------- | | `ai.tools[]` | `onToolExecutionEnd` (v7) / `onToolCallFinish` (v6) | Per-tool `name`, `durationMs`, `success`, and `error` (if failed) | | `ai.totalDurationMs` | `onStart` → `onEnd` (v7) / `onFinish` (v6) | Total wall time from generation start to completion | | `ai.embedding` | `onEmbedEnd` (v7) | Auto-captured from `embed()` / `embedMany()` when telemetry is enabled | | `ai.finishReason: 'abort'` | `onAbort` (v7) | Set when a streaming generation is aborted | | `ai.error` | `onAbort` / `onError` (v7) | Abort reason or unrecoverable generation error | The middleware captures tokens, model info, and streaming metrics. The integration captures tool execution timing. Together, they give you complete AI observability. ## Composability `ai.wrap()` works with models that are already wrapped by other tools. If you use supermemory, guardrails middleware, or any other model wrapper, pass the wrapped model to `ai.wrap()`: ```typescript [server/api/chat.post.ts] import { createAILogger } from 'evlog/ai' import { withSupermemory } from '@supermemory/tools/ai-sdk' import { createGateway } from 'ai' const gateway = createGateway({ ... }) const ai = createAILogger(log) const base = gateway('anthropic/claude-sonnet-4.6') const model = ai.wrap(withSupermemory(base, 'your-org-id', { mode: 'full' })) ``` For explicit middleware composition, use `createAIMiddleware` to get the raw middleware and compose it yourself via `wrapLanguageModel`: ```typescript [server/api/chat.post.ts] import { createAIMiddleware } from 'evlog/ai' import { wrapLanguageModel } from 'ai' const model = wrapLanguageModel({ model: base, middleware: [createAIMiddleware(log, { toolInputs: true }), otherMiddleware], }) ``` `createAIMiddleware` returns the same middleware that `createAILogger` uses internally. The difference: `createAIMiddleware` does not include `captureEmbed` (embedding models don't use middleware). Use `createAILogger` for the full API, `createAIMiddleware` when you need explicit middleware ordering. # Better Auth Integration `evlog/better-auth` turns anonymous wide events into identified ones. Every request automatically includes who made it — no manual `log.set({ user })` needed. ## Prerequisites Use [Better Auth](https://better-auth.com/){rel=""nofollow""} as a **direct dependency** in your app. `evlog` does not bundle Better Auth. The integration is tested against Better Auth `>=1.6.9` (same major as [the playground](https://github.com/HugoRCD/evlog/tree/main/apps/playground){rel=""nofollow""}). ::code-group ```bash [pnpm] pnpm add better-auth ``` ```bash [bun] bun add better-auth ``` ```bash [yarn] yarn add better-auth ``` ```bash [npm] npm install better-auth ``` :: ::prompt --- actions: - copy - cursor - claude description: Add Better Auth user identification icon: i-simple-icons-betterauth --- Add Better Auth user identification to my app with evlog. - Import createAuthMiddleware from 'evlog/better-auth' - Call createAuthMiddleware(auth) to get an identify function - Call identify(log, headers, path) in your middleware/hook to auto-identify users on every request - Safe by default — only extracts whitelisted fields, never logs passwords or tokens - Supports include/exclude route patterns, lifecycle hooks, and Better Auth plugin fields - Works with all frameworks: Nuxt, Next.js, Express, Hono, Fastify, NestJS, Elysia, standalone Docs: {rel=""nofollow""} Adapters: {rel=""nofollow""} :: ## Quick Start One middleware, all requests identified: ::code-group ```typescript [server/middleware/auth-identify.ts (Nuxt)] import { createAuthMiddleware } from 'evlog/better-auth' const identify = createAuthMiddleware(auth, { exclude: ['/api/auth/**'], }) export default defineEventHandler(async (event) => { if (!event.context.log) return await identify(event.context.log, event.headers, event.path) }) ``` ```typescript [app/api/checkout/route.ts (Next.js)] import { withEvlog, useLogger } from '@/lib/evlog' import { createAuthMiddleware } from 'evlog/better-auth' import { auth } from '@/lib/auth' const identify = createAuthMiddleware(auth) export const POST = withEvlog(async (request: Request) => { const log = useLogger() await identify(log, request.headers) log.set({ action: 'checkout' }) return Response.json({ success: true }) }) ``` ```typescript [src/index.ts (Express)] import { createAuthMiddleware } from 'evlog/better-auth' const identify = createAuthMiddleware(auth, { exclude: ['/api/auth/**'], }) app.use(async (req, res, next) => { await identify(req.log, req.headers, req.path) next() }) ``` ```typescript [src/index.ts (Hono)] import { createAuthMiddleware } from 'evlog/better-auth' const identify = createAuthMiddleware(auth, { exclude: ['/api/auth/**'], }) app.use(async (c, next) => { await identify(c.get('log'), c.req.raw.headers, c.req.path) await next() }) ``` ```typescript [src/index.ts (Fastify)] import { createAuthMiddleware } from 'evlog/better-auth' const identify = createAuthMiddleware(auth, { exclude: ['/api/auth/**'], }) app.addHook('onRequest', async (request) => { await identify(request.log, request.headers, request.url) }) ``` ```typescript [src/index.ts (Elysia)] import { createAuthMiddleware } from 'evlog/better-auth' const identify = createAuthMiddleware(auth, { exclude: ['/api/auth/**'], }) app.derive(async ({ log, request }) => { await identify(log, request.headers, new URL(request.url).pathname) return {} }) ``` ```typescript [src/auth-identify.middleware.ts (NestJS)] import { createAuthMiddleware } from 'evlog/better-auth' import { useLogger } from 'evlog/nestjs' const identify = createAuthMiddleware(auth, { exclude: ['/api/auth/**'], }) @Injectable() export class AuthIdentifyMiddleware implements NestMiddleware { async use(req: Request, res: Response, next: NextFunction) { await identify(useLogger(), req.headers, req.path) next() } } ``` ```typescript [scripts/sync-job.ts (Standalone)] import { identifyUser } from 'evlog/better-auth' import { createLogger } from 'evlog' const log = createLogger() const session = await auth.api.getSession({ headers }) if (session) identifyUser(log, session) log.emit() ``` :: Your wide event now includes the user: ::code-group ```json [Before — anonymous] { "level": "info", "method": "POST", "path": "/api/checkout", "status": 200, "duration": "120ms", "requestId": "a5669202-7765-4f59-b6f0-b9f40ce71599", "cart": { "items": 3, "total": 9999 } } ``` ```json [After — identified] { "level": "info", "method": "POST", "path": "/api/checkout", "status": 200, "duration": "120ms", "requestId": "a5669202-7765-4f59-b6f0-b9f40ce71599", "userId": "QBX9tPjJQExWawAbNll75", "user": { "id": "QBX9tPjJQExWawAbNll75", "name": "Hugo Richard", "email": "hugo@example.com", "emailVerified": true, "createdAt": "2024-01-15T10:00:00.000Z" }, "session": { "id": "Xhmh6TxKJQrVKFX0Y0II", "expiresAt": "2024-01-22T10:00:00.000Z", "ipAddress": "192.168.1.42", "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", "createdAt": "2024-01-15T10:00:00.000Z" }, "auth": { "resolvedIn": 12, "identified": true }, "cart": { "items": 3, "total": 9999 } } ``` :: ## How It Works :better-auth-identify The integration resolves the Better Auth session from request cookies, extracts a safe whitelist of user and session fields, sets them on the logger, then fires the `onIdentify` (or `onAnonymous`) hook. Auth routes are skipped by default. Resolution timing is captured on `auth.resolvedIn`, so you can chart auth latency alongside the rest of the wide event. ## Where to next ::card-group :::card --- icon: i-lucide-user-check title: Identify User to: https://www.evlog.dev/use-cases/better-auth/identify-user --- The core building block — extract safe fields, mask emails, capture plugin data (organizations, roles, 2FA). ::: :::card --- icon: i-lucide-shield title: Middleware to: https://www.evlog.dev/use-cases/better-auth/middleware --- Filter routes with `include` / `exclude` , react to identification with lifecycle hooks, and tune behaviour per app. ::: :::card --- icon: i-lucide-monitor title: Client Sync to: https://www.evlog.dev/use-cases/better-auth/client-sync --- Mirror the user identity into client-side logs with `setIdentity` and the Better Auth client. ::: :::card --- icon: i-lucide-gauge title: Performance to: https://www.evlog.dev/use-cases/better-auth/performance --- Watch session resolution time, enable session caching, and combine with the AI SDK integration. ::: :: ## Public API | Export | Description | | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | `identifyUser(log, session)` | Core helper — extracts safe fields from a session and sets them on the logger. Returns `true` if identified. | | `createAuthMiddleware(auth)` | Returns an async `(log, headers, path?) => Promise` function with route filtering, timing, and hooks. | | `createAuthIdentifier(auth)` | Nitro `request` hook factory for standalone Nitro apps. See [Performance](https://www.evlog.dev/use-cases/better-auth/performance#standalone-nitro). | | `maskEmail(email)` | Mask an email: `hugo@example.com` → `h***@example.com`. | # identifyUser `identifyUser` is the core building block. Take a `RequestLogger` and a Better Auth session, extract safe fields, and call `log.set()`. Returns `true` if the user was identified, `false` otherwise. ```typescript [server/api/checkout.post.ts] import { identifyUser } from 'evlog/better-auth' const session = await auth.api.getSession({ headers: event.headers }) if (session) { const identified = identifyUser(log, session) if (identified) { log.set({ subscription: 'premium' }) } } ``` ::tip **Safe by default.** Only whitelisted fields are extracted — passwords, tokens, and secrets are never written to the logger. :: ## Options | Option | Type | Default | Description | | ----------- | -------------------------------------- | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | | `maskEmail` | `boolean` | `false` | Mask emails as `h***@example.com`. | | `session` | `boolean` | `true` | Include session metadata (`session.id`, `session.expiresAt`, `session.ipAddress`, `session.userAgent`). | | `fields` | `string[]` | `['id', 'name', 'email', 'image', 'emailVerified', 'createdAt']` | User fields to extract. | | `extend` | `(session) => Record` | `undefined` | Add custom fields from Better Auth plugins (organizations, roles, etc.). | ```typescript [server/api/checkout.post.ts] identifyUser(log, session, { maskEmail: true, fields: ['id', 'name'], session: false, }) ``` ## Mask emails Emails are PII. In environments where the audit/log trail might be reviewed by support or third parties, mask them: ```typescript identifyUser(log, session, { maskEmail: true }) ``` The `maskEmail` helper is also exported on its own: ```typescript import { maskEmail } from 'evlog/better-auth' maskEmail('hugo@example.com') // 'h***@example.com' ``` ## Capture plugin fields Better Auth ships with plugins (organizations, 2FA, roles, admin) that add fields to the session. Use `extend` to surface them on the wide event: ```typescript [server/middleware/auth-identify.ts] import { createAuthMiddleware } from 'evlog/better-auth' const identify = createAuthMiddleware(auth, { extend: (session) => ({ organization: session.user.activeOrganization, role: session.user.role, }), }) ``` Wide event with plugin fields: ```json [Wide Event] { "userId": "QBX9tPjJQExWawAbNll75", "user": { "id": "QBX9tPjJQExWawAbNll75", "name": "Hugo Richard" }, "organization": { "id": "org_42", "name": "Acme" }, "role": "admin" } ``` ::tip Keep `extend` deterministic — it runs on every request. Avoid heavy computations or extra database calls inside it; query the data Better Auth already loaded into the session. :: ## Captured fields | Field | Source | Description | | -------------------- | ---------------------------- | ------------------------------------------------------------- | | `userId` | `session.user.id` | Top-level user ID (used by PostHog adapter as `distinct_id`). | | `user.id` | `session.user.id` | User ID. | | `user.name` | `session.user.name` | Display name. | | `user.email` | `session.user.email` | Email (maskable with `maskEmail: true`). | | `user.image` | `session.user.image` | Avatar URL. | | `user.emailVerified` | `session.user.emailVerified` | Email verification status. | | `user.createdAt` | `session.user.createdAt` | Account creation date (ISO string). | | `session.id` | `session.session.id` | Session ID. | | `session.expiresAt` | `session.session.expiresAt` | Session expiry (ISO string). | | `session.ipAddress` | `session.session.ipAddress` | Client IP from the session. | | `session.userAgent` | `session.session.userAgent` | User agent string from the session. | | `session.createdAt` | `session.session.createdAt` | Session creation date (ISO string). | | `auth.resolvedIn` | Measured | Session resolution time in ms. | | `auth.identified` | Computed | Whether the request was identified. | # createAuthMiddleware `createAuthMiddleware` wraps `identifyUser` with the things you need on every request: route filtering, session resolution timing, lifecycle hooks, and silent error handling. Call it once at startup, then use the returned function in your framework's middleware/hook system. ```typescript [server/middleware/auth-identify.ts] import { createAuthMiddleware } from 'evlog/better-auth' const identify = createAuthMiddleware(auth, { exclude: ['/api/auth/**', '/api/public/**'], include: ['/api/**'], maskEmail: true, }) ``` The function signature is `(log, headers, path?) => Promise`. It resolves the session, calls `identifyUser`, captures timing into `auth.resolvedIn`, fires lifecycle hooks, and silently catches errors so session resolution never breaks a request. ## Options Inherits all [`identifyUser` options](https://www.evlog.dev/use-cases/better-auth/identify-user#options), plus: | Option | Type | Default | Description | | ------------- | ------------------------ | ------------------ | ------------------------------------------ | | `exclude` | `string[]` | `['/api/auth/**']` | Route patterns to skip (glob). | | `include` | `string[]` | `undefined` | If set, only matching routes are resolved. | | `onIdentify` | `(log, session) => void` | `undefined` | Called after successful identification. | | `onAnonymous` | `(log) => void` | `undefined` | Called when no session is found. | ## Route Filtering Skip Better Auth's own routes and any public endpoints to avoid wasted database queries: ```typescript const identify = createAuthMiddleware(auth, { exclude: [ '/api/auth/**', // Better Auth itself '/api/public/**', // Public endpoints '/api/health', // Health checks ], }) ``` For high-traffic apps, flip the model — only resolve sessions on routes that need them: ```typescript const identify = createAuthMiddleware(auth, { include: ['/api/dashboard/**', '/api/account/**'], }) ``` `include` and `exclude` use glob patterns (`*`, `**`). Provide both if you need granular control — `exclude` wins over `include`. ## Lifecycle Hooks Use `onIdentify` to react to user identification — for example, force-keep logs for premium users via tail sampling: ```typescript [server/middleware/auth-identify.ts] const identify = createAuthMiddleware(auth, { onIdentify: (log, session) => { if (session.user.plan === 'enterprise') { log.set({ _forceKeep: true }) } }, onAnonymous: (log) => { log.set({ anonymous: true }) }, }) ``` Hooks fire after the session is resolved and `identifyUser` has set its fields. They run on every request that passes the `include`/`exclude` filter, so keep them fast and side-effect-free. ::tip Common patterns for `onIdentify`: - Force-keep audit logs for admins or high-value plans. - Tag the request with feature flags or tenant info loaded from the session. - Increment a per-user counter for billing. :: ## Error Handling The middleware catches every error from `getSession` and logs nothing — your request keeps flowing whether the auth backend is up or down. The wide event still includes `auth.resolvedIn` and `auth.identified: false` so you can alert on session resolution health from your dashboards. # Client Identity Sync The middleware identifies users on the server. To get the same identity on **client-side logs** (clicks, navigation, errors caught in the browser), watch the Better Auth session and forward the user to evlog's client identity store. ## Vue / Nuxt ```typescript [composables/useAuthIdentity.ts] import { authClient } from '~/lib/auth-client' export function useAuthIdentity() { const session = authClient.useSession() watch(() => session.value?.data?.user, (user) => { if (user) { setIdentity({ userId: user.id, userName: user.name }) } else { clearIdentity() } }, { immediate: true }) } ``` Call it once in your root layout: ```vue [app.vue] ``` ## React ```tsx [hooks/useAuthIdentity.tsx] import { useEffect } from 'react' import { setIdentity, clearIdentity } from 'evlog/http' import { authClient } from '@/lib/auth-client' export function useAuthIdentity() { const { data } = authClient.useSession() useEffect(() => { if (data?.user) { setIdentity({ userId: data.user.id, userName: data.user.name }) } else { clearIdentity() } }, [data?.user?.id]) } ``` Wire it up at the root of your app (in `_app.tsx`, the root layout, or a top-level provider). ## Svelte ```typescript [src/lib/auth-identity.ts] import { setIdentity, clearIdentity } from 'evlog/http' import { authClient } from '$lib/auth-client' export function setupAuthIdentity() { const session = authClient.useSession() session.subscribe(({ data }) => { if (data?.user) { setIdentity({ userId: data.user.id, userName: data.user.name }) } else { clearIdentity() } }) } ``` Run `setupAuthIdentity()` once when the app boots. ## Output Client-side logs now include the user identity: ```json [Client Log] { "level": "info", "tag": "checkout", "message": "User clicked checkout", "userId": "QBX9tPjJQExWawAbNll75", "userName": "Hugo Richard" } ``` ::tip `setIdentity` is part of evlog's [client logging](https://www.evlog.dev/use-cases/client-logging) layer. The same fields are picked up by the HTTP transport when client logs are forwarded to your server, so a single user shows up identified across browser **and** API logs. :: # Performance & Composition `getSession()` costs a database query on every request. The integration measures it for you and exposes the timing as `auth.resolvedIn` so you can spot regressions before users do. ## Watch session resolution time ```json [Wide Event — slow session resolution] { "auth": { "resolvedIn": 245, "identified": true }, "duration": "312ms" } ``` When `auth.resolvedIn` is high relative to `duration`, your auth backend is the bottleneck. ## Tune for high traffic 1. **Enable [cookie caching](https://www.better-auth.com/docs/guides/optimizing-for-performance#cookie-cache){rel=""nofollow""}** in Better Auth so session lookups don't hit the database every time. 2. **Use `exclude`** on `createAuthMiddleware` to skip public routes that don't need user context. 3. **Use `include`** to limit resolution to specific route patterns instead of the entire app. A common P95 target after caching: `auth.resolvedIn < 5ms`. ## Standalone Nitro `createAuthIdentifier` is a factory that creates a Nitro `request` hook. Designed for **standalone Nitro** apps where the evlog Nitro module handles hook ordering. ::note For **Nuxt** , use `createAuthMiddleware` in a server middleware instead — Nitro plugin hook ordering can cause the logger to not be available yet in the `request` hook. :: ```typescript [server/plugins/evlog-auth.ts] import { createAuthIdentifier } from 'evlog/better-auth' import { auth } from './lib/auth' export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('request', createAuthIdentifier(auth, { exclude: ['/api/auth/**', '/api/public/**'], })) }) ``` It accepts the same options as [`createAuthMiddleware`](https://www.evlog.dev/use-cases/better-auth/middleware#options). ## Combine with the AI SDK When you also use [`evlog/ai`](https://www.evlog.dev/use-cases/ai-sdk/overview), your wide events include both user identity **and** AI metrics in a single event: ```json [Wide Event — AI + User] { "method": "POST", "path": "/api/chat", "status": 200, "duration": "4.5s", "durationMs": 4512, "userId": "QBX9tPjJQExWawAbNll75", "user": { "id": "QBX9tPjJQExWawAbNll75", "name": "Hugo Richard", "email": "hugo@example.com" }, "auth": { "resolvedIn": 8, "identified": true }, "ai": { "calls": 1, "model": "claude-sonnet-4.6", "provider": "anthropic", "inputTokens": 3312, "outputTokens": 814, "totalTokens": 4126, "msToFirstChunk": 234, "msToFinish": 4500, "tokensPerSecond": 180 } } ``` This is the power of wide events — one event per request, all context in one place: who made the request, what they did, how the AI responded, and how it performed. # Audit Logs evlog's audit layer is **not a parallel system**. Audit events are wide events with a reserved `audit` field. Every existing primitive — drains, enrichers, redact, tail-sampling — applies as is. Enable audit logs by adding **1 enricher + 1 drain wrapper + 1 helper**. ::prompt --- actions: - copy - cursor - claude description: Add an audit log to my app icon: i-lucide-shield-check --- Add a tamper-evident audit log to my app on top of evlog. - Identify my framework and follow its evlog integration pattern - Register auditEnricher() on the evlog\:enrich hook (or in initLogger.enrichers) - Register a separate auditOnly(signed(createFsDrain({ dir: '.audit' }), { strategy: 'hash-chain' })) drain alongside my main drain - Use { await: true } on the audit drain so audit events are flushed before the response returns - Call log.audit({ action, actor, target, outcome, reason }) for every security-sensitive action (login, role change, refund, data export, deletion) - Audit events are force-kept past sampling and signed via hash-chain for tamper-evidence - Combine with the Better Auth integration so actor.id / actor.email are automatic Docs: {rel=""nofollow""} Adapters: {rel=""nofollow""} :: ## Agent Skills Install the evlog skill catalog so your assistant can follow **`build-audit-logs`** end to end: written policy, framework wiring, `withAudit` / `log.audit`, denials, redaction, multi-tenant isolation, tamper-evident sinks, and grep-based review passes. If you use the file-system drain for audits or general logs, **`analyze-logs`** teaches assistants to read NDJSON under `.evlog/logs/`. ```bash [Terminal] npx skills add https://www.evlog.dev ``` See [Agent Skills](https://www.evlog.dev/reference/agent-skills) for the full list. Skill paths in the repo: `skills/build-audit-logs`, `skills/analyze-logs`. ## Why Audit Logs? Compliance frameworks (SOC2, HIPAA, GDPR, PCI) require knowing **who did what, on which resource, when, from where, with which outcome**. evlog covers this without a second logging library. ::tip **An audit event is a fact about an intent, not a measurement of an operation.** A regular wide event answers "how did this request behave?" (latency, status, tokens). An audit event answers "who tried to do what, and was it allowed?". Same pipeline, different question — that's why the schema is reserved and the event is force-kept past sampling. :: :audit-force-keep ## Quickstart You already use evlog. Add audit logs in three changes: ```typescript [server/plugins/evlog.ts] import { auditEnricher, auditOnly, signed } from 'evlog' import { createAxiomDrain } from 'evlog/axiom' import { createFsDrain } from 'evlog/fs' export default defineNitroPlugin((nitro) => { nitro.hooks.hook('evlog:enrich', auditEnricher()) nitro.hooks.hook('evlog:drain', createAxiomDrain()) nitro.hooks.hook('evlog:drain', auditOnly( signed(createFsDrain({ dir: '.audit' }), { strategy: 'hash-chain' }), { await: true }, )) }) ``` ::code-group ```typescript [Nuxt / Nitro] export default defineEventHandler(async (event) => { const log = useLogger(event) const user = await requireUser(event) const invoice = await refundInvoice(getRouterParam(event, 'id')) log.audit({ action: 'invoice.refund', actor: { type: 'user', id: user.id, email: user.email }, target: { type: 'invoice', id: invoice.id }, outcome: 'success', reason: 'Customer requested refund', }) return { ok: true } }) ``` ```typescript [Next.js] import { withEvlog, useLogger } from '@/lib/evlog' export const POST = withEvlog(async (req, { params }) => { const log = useLogger() const user = await requireUser(req) const invoice = await refundInvoice(params.id) log.audit({ action: 'invoice.refund', actor: { type: 'user', id: user.id, email: user.email }, target: { type: 'invoice', id: invoice.id }, outcome: 'success', reason: 'Customer requested refund', }) return Response.json({ ok: true }) }) ``` ```typescript [Hono] import type { EvlogVariables } from 'evlog/hono' import { Hono } from 'hono' const app = new Hono() app.post('/invoices/:id/refund', async (c) => { const log = c.get('log') const user = await requireUser(c) const invoice = await refundInvoice(c.req.param('id')) log.audit({ action: 'invoice.refund', actor: { type: 'user', id: user.id, email: user.email }, target: { type: 'invoice', id: invoice.id }, outcome: 'success', reason: 'Customer requested refund', }) return c.json({ ok: true }) }) ``` ```typescript [Express] import type { Request, Response } from 'express' app.post('/invoices/:id/refund', async (req: Request, res: Response) => { const log = req.log const user = await requireUser(req) const invoice = await refundInvoice(req.params.id) log.audit({ action: 'invoice.refund', actor: { type: 'user', id: user.id, email: user.email }, target: { type: 'invoice', id: invoice.id }, outcome: 'success', reason: 'Customer requested refund', }) res.json({ ok: true }) }) ``` ```typescript [Standalone job] import { audit } from 'evlog' audit({ action: 'invoice.refund', actor: { type: 'system', id: 'billing-worker' }, target: { type: 'invoice', id: 'inv_889' }, outcome: 'success', reason: 'Auto-refund triggered by chargeback webhook', }) ``` ```json [Output — wide event] { "level": "info", "service": "billing-api", "method": "POST", "path": "/api/invoices/inv_889/refund", "status": 200, "duration": "84ms", "durationMs": 84, "requestId": "a566ef91-7765-4f59-b6f0-b9f40ce71599", "audit": { "action": "invoice.refund", "actor": { "type": "user", "id": "usr_42", "email": "demo@example.com" }, "target": { "type": "invoice", "id": "inv_889" }, "outcome": "success", "reason": "Customer requested refund", "version": 1, "idempotencyKey": "ak_8f3c4b2a1e5d6f7c", "context": { "requestId": "a566ef91-7765-4f59-b6f0-b9f40ce71599", "ip": "203.0.113.7", "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36" } } } ``` :: That's it. The audit event: - Travels through the same wide-event pipeline as the rest of your logs. - Is **always kept** past tail sampling. - Goes to your main drain (Axiom) **and** to a dedicated, signed, append-only sink (FS journal). - Carries `requestId`, `traceId`, `ip`, and `userAgent` automatically via `auditEnricher`. ::tip **Why two drains?** The main drain (Axiom, Datadog, ...) keeps audits next to the rest of your telemetry so dashboards and queries still work. The signed sink is your insurance: if the main drain has an outage, gets purged, or an admin quietly removes a row, the FS journal still holds the chain. Auditors want both — fast querying *and* a tamper-evident artefact. :: :audit-dual-sink ## Composition Each layer is **opt-in and replaceable**. Every node except `log.audit`, `auditEnricher`, and `auditOnly` / `signed` is shared with regular wide events. :audit-composition-flow ## Where to next ::card-group :::card --- icon: i-lucide-file-text title: Schema to: https://www.evlog.dev/use-cases/audit/schema --- The `AuditFields` type, action naming conventions, actor types, and idempotency. ::: :::card --- icon: i-lucide-pen-line title: Recording Events to: https://www.evlog.dev/use-cases/audit/recording --- `log.audit` , `log.audit.deny` , standalone `audit()` , `withAudit` , `defineAuditAction` , `defineAuditCatalog` , and `auditDiff` . ::: :::card --- icon: i-lucide-link title: Drains & Integrity to: https://www.evlog.dev/use-cases/audit/pipeline --- `auditEnricher` , `auditOnly` , and `signed` (HMAC and hash-chain) drain wrappers. ::: :::card --- icon: i-lucide-shield-check title: Compliance to: https://www.evlog.dev/use-cases/audit/compliance --- Integrity, redact presets, GDPR vs append-only, retention, and common pitfalls. ::: :::card --- icon: i-lucide-book-open title: Recipes to: https://www.evlog.dev/use-cases/audit/recipes --- FS, Axiom, and Postgres recipes — plus testing with `mockAudit` and the API reference. ::: :: # Audit Schema `event.audit` is a typed field on every wide event. Downstream queries filter on `audit IS NOT NULL` to materialise an audit dataset out of regular logs. ## `AuditFields` type ::code-collapse ```typescript interface AuditFields { action: string // 'invoice.refund' actor: { type: 'user' | 'system' | 'api' | 'agent' id: string displayName?: string email?: string // For type === 'agent', mirrors evlog/ai fields: model?: string tools?: string[] reason?: string promptId?: string } target?: { type: string, id: string, [k: string]: unknown } outcome: 'success' | 'failure' | 'denied' reason?: string changes?: { before?: unknown, after?: unknown, patch?: AuditPatchOp[] } causationId?: string // ID of the action that caused this one correlationId?: string // Shared by every action in one operation version?: number // Defaults to 1 idempotencyKey?: string // Auto-derived; safe retries across drains context?: { // Filled by auditEnricher requestId?: string traceId?: string ip?: string userAgent?: string tenantId?: string } signature?: string // Set by signed({ strategy: 'hmac' }) prevHash?: string // Set by signed({ strategy: 'hash-chain' }) hash?: string } ``` :: ## Action naming ::tip **Naming convention for `action`.** Use `noun.verb` ( `invoice.refund` , `user.invite` , `apiKey.revoke` ). Past tense if the action already happened ( `invoice.refunded` ), present tense if `withAudit()` will resolve the outcome. Keep a small fixed dictionary in one file — auditors and SIEM rules query on `action` , so a typo is a missing alert. :: A single dictionary file makes alerting straightforward: ```typescript [src/audit/actions.ts] export const AUDIT_ACTIONS = { USER_INVITE: 'user.invite', USER_REMOVE: 'user.remove', USER_ROLE_CHANGE: 'user.role-change', INVOICE_REFUND: 'invoice.refund', API_KEY_REVOKE: 'apiKey.revoke', } as const ``` ::tip For more than a handful of actions, prefer [`defineAuditCatalog`](https://www.evlog.dev/use-cases/audit/recording#defineauditcatalog) over a plain object dictionary — same `noun.verb` convention, but you get autocomplete on the action union and per-entry `target` type inference for free. :: ## Actor types ::warning **Don't fake the actor.** Use `actor.type: 'system'` for cron jobs, queue workers, and background tasks; `actor.type: 'api'` for machine-to-machine calls authenticated by a token; `actor.type: 'agent'` for AI tool calls. Logging a synthetic `'user'` for system actions is the single fastest way to fail an audit review. :: | `actor.type` | When to use | | ------------ | --------------------------------------------------------------------------------- | | `'user'` | A human authenticated through your normal auth flow. | | `'system'` | Cron jobs, queue workers, scheduled tasks, internal background processes. | | `'api'` | Machine-to-machine calls from another service authenticated by a token. | | `'agent'` | AI tool calls (combine with `evlog/ai` fields like `model`, `tools`, `promptId`). | ## Outcomes | `outcome` | Meaning | | ----------- | ----------------------------------------------------------------------------- | | `'success'` | The action completed as requested. | | `'failure'` | The action was attempted but failed (downstream error, race condition, etc.). | | `'denied'` | The action was rejected by an authorisation check. | `'failure'` and `'denied'` are different things — auditors care a lot about denied actions because they signal probing or misconfigured access controls. Always log denials (see [Recording Events](https://www.evlog.dev/use-cases/audit/recording#deny)). ## Idempotency `idempotencyKey` is auto-derived from a hash of `action`, `actor.id`, `target`, and a coarse timestamp. The result: even if your drain retries an audit insert across a network blip, the duplicate row collapses on `ON CONFLICT DO NOTHING`. You don't have to think about it — it's filled in for you. Use the field as the primary key in Postgres / Bigtable / DynamoDB so retries stay safe by construction. ## Causation and correlation | Field | Use case | | --------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | `correlationId` | Shared by every audit event that belongs to the **same operation** (e.g. one HTTP request that triggers a refund + an email + a webhook). | | `causationId` | The id of the previous audit event that **caused** this one. Useful for reconstructing chains of cascading actions. | Most teams set `correlationId` to `requestId`. `causationId` is opt-in and only worth filling when a single user action triggers many internal audit events. # Recording Events Five APIs cover every shape of audit recording: in-request, denied, standalone, auto-instrumented, and typed. ## `log.audit()` `log.audit()` is sugar over `log.set({ audit: ... })` plus tail-sample force-keep: ```typescript log.audit({ action: 'invoice.refund', actor: { type: 'user', id: user.id }, target: { type: 'invoice', id: 'inv_889' }, outcome: 'success', }) // Strictly equivalent to: log.set({ audit: { action: 'invoice.refund', /* ... */, version: 1 } }) ``` This is the form you'll use most. The audit event lands on the same wide event as the rest of the request. ## `log.audit.deny()` `log.audit.deny(reason, fields)` records AuthZ-denied actions. Most teams forget to log denials, but they're exactly what auditors and security teams ask for: ::code-group ```typescript [Input] if (!user.canRefund(invoice)) { log.audit.deny('Insufficient permissions', { action: 'invoice.refund', actor: { type: 'user', id: user.id }, target: { type: 'invoice', id: invoice.id }, }) throw createError({ status: 403, message: 'Forbidden' }) } ``` ```json [Output — denied] { "level": "warn", "service": "billing-api", "method": "POST", "path": "/api/invoices/inv_889/refund", "status": 403, "duration": "12ms", "durationMs": 12, "requestId": "9c3f7d12-8a45-4e60-b8a9-1f0d4c5e6e7d", "audit": { "action": "invoice.refund", "actor": { "type": "user", "id": "usr_intruder" }, "target": { "type": "invoice", "id": "inv_889" }, "outcome": "denied", "reason": "Insufficient permissions", "version": 1, "idempotencyKey": "ak_d12c3a4f5b6e7d8c", "context": { "requestId": "9c3f7d12-8a45-4e60-b8a9-1f0d4c5e6e7d", "ip": "203.0.113.7" } } } ``` :: ## Standalone `audit()` For non-request contexts (jobs, scripts, CLIs), use the standalone `audit()`: ::code-group ```typescript [scripts/cleanup.ts] import { audit } from 'evlog' audit({ action: 'cron.cleanup', actor: { type: 'system', id: 'cron' }, target: { type: 'job', id: 'cleanup-stale-sessions' }, outcome: 'success', }) ``` ```json [Output — wide event] { "level": "info", "service": "billing-api", "audit": { "action": "cron.cleanup", "actor": { "type": "system", "id": "cron" }, "target": { "type": "job", "id": "cleanup-stale-sessions" }, "outcome": "success", "version": 1, "idempotencyKey": "ak_2b8e1f9d4c6a7b3e" } } ``` :: ::note Standalone `audit()` events have no `requestId` , no `context.ip` , no `userAgent` — there is no request to enrich from. Add your own context manually ( `context: { jobId, queue, runId }` ) when it matters for forensics. :: ## `defineAuditAction()` Define audit actions in one place to avoid magic strings and get full type-safety on `target`: ```typescript import { defineAuditAction } from 'evlog' const refund = defineAuditAction('invoice.refund', { target: 'invoice' }) log.audit(refund({ actor: { type: 'user', id: user.id }, target: { id: 'inv_889' }, // type inferred as 'invoice' outcome: 'success', })) ``` Pair this with the action dictionary from [Schema → Action naming](https://www.evlog.dev/use-cases/audit/schema#action-naming). ## `defineAuditCatalog()` For more than a handful of actions, group them in a typed **catalog** instead of declaring `defineAuditAction` one-by-one. Same convention as error catalogs: `UPPER_SNAKE_CASE` keys, `lower.dot.case` prefix, wire `action` is `${prefix}.${KEY}`. ::code-group ```typescript [audit/billing.ts] import { defineAuditCatalog } from 'evlog' export const billingAudit = defineAuditCatalog('billing', { INVOICE_REFUND: { target: 'invoice', severity: 'high', requiresChanges: true, description: 'Refund an invoice to the customer', redactPaths: ['cardNumber'], }, INVOICE_CREATE: { target: 'invoice' }, INVOICE_VOID: { target: 'invoice', severity: 'high', requiresReason: true }, SUBSCRIPTION_CANCEL: { target: 'subscription', severity: 'high' }, }) ``` ```typescript [server/api/refund.post.ts] import { billingAudit } from '~/audit/billing' log.audit(billingAudit.INVOICE_REFUND({ actor: { type: 'user', id: user.id }, target: { id: 'inv_889' }, // type inferred as 'invoice' outcome: 'success', })) ``` :: Each entry produces a thin wrapper around `defineAuditAction` (target type is fixed at definition time, action name is auto-prefixed). Catalog metadata is exposed on each factory and on `_actions` / `_prefix`: ```typescript billingAudit.INVOICE_REFUND.action // 'billing.INVOICE_REFUND' (literal type) billingAudit.INVOICE_REFUND.target // 'invoice' billingAudit.INVOICE_REFUND.severity // 'high' billingAudit.INVOICE_REFUND.requiresChanges // true billingAudit.INVOICE_REFUND.redactPaths // ['cardNumber'] billingAudit._actions // readonly ['billing.INVOICE_REFUND', ...] ``` | Entry field | Purpose | | ----------------- | ----------------------------------------------------------------------- | | `target` | Default `target.type` injected at call sites | | `description` | Human-readable label for docs, SIEM rules, review tooling | | `severity` | `'low' | 'medium' | 'high' | 'critical'` — alerting and review priority | | `requiresChanges` | Document that callers should attach `changes` (e.g. via `auditDiff`) | | `requiresReason` | Document that callers should attach `reason` (especially denials) | | `redactPaths` | Default paths for `auditDiff({ redactPaths: [...] })` on this action | ### `defineAuditAction` vs `defineAuditCatalog` — when to choose Both produce the same call-site factory shape. Pick by scale: - **`defineAuditAction(action, opts?)`** — one-off actions, or per-file organisation in very large repos. Mirrors `defineError`. Equivalent to a catalog with a single entry but with no prefix derivation: you write the full wire `action` directly. - **`defineAuditCatalog(prefix, map)`** — group anything beyond a handful of related actions under one prefix. Mirrors `defineErrorCatalog`. The wire `action` is auto-derived as `${prefix}.${KEY}`, catalog metadata (`_actions`, `_prefix`) is exposed for introspection, and a single `declare module 'evlog'` line surfaces the whole bundle in the typed `AuditAction` union. You can mix the two in the same codebase — keep cross-cutting one-off actions as `defineAuditAction`, group bounded contexts (`billing`, `auth`, `subscription`) as catalogs. ### Type-safe actions everywhere (opt-in) Mirror the error catalog augmentation by augmenting `RegisteredAuditCatalogs`: ```typescript import type { billingAudit } from './audit/billing' declare module 'evlog' { interface RegisteredAuditCatalogs { billing: typeof billingAudit } } ``` This surfaces the union of all registered actions on the typed `AuditAction` export, useful for shared helpers, dashboards, and refactor-safe comparisons. ::callout --- color: primary icon: i-lucide-arrow-right to: https://www.evlog.dev/learn/catalogs --- **Going further.** The dedicated [Catalogs page](https://www.evlog.dev/learn/catalogs) covers the scaling story (single file → folder → feature → npm package) for both error and audit catalogs, plus npm packaging, composition patterns, and the type-augmentation deep dive. :: ## `auditDiff()` For mutating actions, use `auditDiff()` to produce a compact, redact-aware JSON Patch: ::warning **Don't feed entire DB rows into `auditDiff()`.** Strip computed columns, hashed passwords, internal flags, and large JSON blobs before diffing. The point of `changes` is *what changed semantically* (status went from `paid` → `refunded` ), not *what bytes changed* (a `lastModified` timestamp ticked). A noisy `changes` field is the fastest way to make audit logs unreadable. :: ::code-group ```typescript [Input] import { auditDiff } from 'evlog' const before = await db.users.byId(id) const after = await db.users.update(id, patch) log.audit({ action: 'user.update', actor: { type: 'user', id: actorId }, target: { type: 'user', id }, outcome: 'success', changes: auditDiff(before, after, { redactPaths: ['password', 'token'] }), }) ``` ```json [Output — changes patch] { "audit": { "action": "user.update", "actor": { "type": "user", "id": "usr_42" }, "target": { "type": "user", "id": "usr_99" }, "outcome": "success", "changes": [ { "op": "replace", "path": "/email", "from": "old@example.com", "to": "new@example.com" }, { "op": "replace", "path": "/role", "from": "member", "to": "admin" }, { "op": "replace", "path": "/password", "from": "[REDACTED]", "to": "[REDACTED]" } ], "version": 1, "idempotencyKey": "ak_5e7d8f9a0b1c2d3e" } } ``` :: ## `withAudit()` — auto-instrumentation Devs forget to call `log.audit()`. Wrap the function and never miss a record: ::tip **When to wrap vs. call manually.** Wrap functions that are *pure audit-worthy actions* (refund, delete, role change, password reset) — outcome resolution is automatic and you can't accidentally skip the call. Stick to manual `log.audit()` when the audit is one of several decisions inside a larger handler, or when you need to emit the audit *before* the action completes (e.g. "user requested deletion"). :: ::code-group ```typescript [Input] import { withAudit, AuditDeniedError } from 'evlog' const refundInvoice = withAudit( { action: 'invoice.refund', target: input => ({ type: 'invoice', id: input.id }) }, async (input: { id: string }, ctx) => { if (!ctx.actor) throw new AuditDeniedError('Anonymous refund denied') return await db.invoices.refund(input.id) }, ) await refundInvoice({ id: 'inv_889' }, { actor: { type: 'user', id: user.id }, correlationId: requestId, }) ``` ```json [Output — success] { "audit": { "action": "invoice.refund", "actor": { "type": "user", "id": "usr_42" }, "target": { "type": "invoice", "id": "inv_889" }, "outcome": "success", "version": 1, "idempotencyKey": "ak_8f3c4b2a1e5d6f7c", "correlationId": "a566ef91-7765-4f59-b6f0-b9f40ce71599" } } ``` ```json [Output — failure] { "level": "error", "audit": { "action": "invoice.refund", "actor": { "type": "user", "id": "usr_42" }, "target": { "type": "invoice", "id": "inv_889" }, "outcome": "failure", "reason": "Stripe error: charge already refunded", "version": 1, "idempotencyKey": "ak_4c5d6e7f8a9b0c1d", "correlationId": "a566ef91-7765-4f59-b6f0-b9f40ce71599" }, "error": { "name": "StripeError", "message": "charge already refunded", "stack": "..." } } ``` ```json [Output — denied] { "level": "warn", "audit": { "action": "invoice.refund", "actor": { "type": "system", "id": "anonymous" }, "target": { "type": "invoice", "id": "inv_889" }, "outcome": "denied", "reason": "Anonymous refund denied", "version": 1, "idempotencyKey": "ak_d12c3a4f5b6e7d8c", "correlationId": "a566ef91-7765-4f59-b6f0-b9f40ce71599" } } ``` :: Outcome resolution: - `fn` resolves → `outcome: 'success'`. - `fn` throws an `AuditDeniedError` (or any error with `status === 403`) → `outcome: 'denied'`, error message becomes `reason`. - Other thrown errors → `outcome: 'failure'`, then re-thrown. # Drains & Integrity Three building blocks: `auditEnricher` fills context, `auditOnly` routes audits to a dedicated drain, and `signed` adds tamper-evident integrity. Each is opt-in and replaceable. ## `auditEnricher()` `auditEnricher()` populates `event.audit.context.{requestId, traceId, ip, userAgent, tenantId}`. Skip it and ship a custom enricher if your strategy differs. ```typescript [server/plugins/evlog.ts] import { auditEnricher } from 'evlog' nitro.hooks.hook('evlog:enrich', auditEnricher()) ``` For multi-tenant apps and custom session bridges, pass options: ```typescript nitro.hooks.hook('evlog:enrich', auditEnricher({ tenantId: ctx => ctx.event.tenant as string | undefined, bridge: { getSession: async ctx => readSessionActor(ctx.headers) }, })) ``` Without `auditEnricher`, `audit.context` stays empty — auditors and incident responders need at least `requestId` and `ip` to triangulate a recorded action. ## `auditOnly()` ::tip **Why filter audits to a separate sink?** Three reasons: **cost** (audit volume is tiny next to product telemetry — keep them separate so retention costs don't explode), **permissions** (the audit dataset should be read-only for engineers and write-only for the app), and **retention** (audits often live 7+ years; product logs rarely live more than 90 days). :: `auditOnly(drain)` only forwards events with an `audit` field. Compose with **any** drain: ```typescript import { auditOnly } from 'evlog' import { createAxiomDrain } from 'evlog/axiom' // Send audits to a dedicated Axiom dataset: nitro.hooks.hook('evlog:drain', auditOnly( createAxiomDrain({ dataset: 'audit', apiKey: process.env.AXIOM_AUDIT_API_KEY }), )) ``` Set `await: true` to make audit writes synchronous (no fire-and-forget for audits — crash-safe by default): ```typescript auditOnly(createFsDrain({ dir: '.audit' }), { await: true }) ``` The `await: true` flag costs you a small bit of latency per request that records an audit (one synchronous drain call), but guarantees the audit hits disk before the response is sent. For compliance-grade audits, the trade-off is always worth it. ## `signed()` `signed(drain, opts)` adds tamper-evident integrity. Two strategies: | Strategy | What it adds | Use case | | -------------- | ----------------------------------------------------- | --------------------------------------------------------------------- | | `'hmac'` | `event.audit.signature` (HMAC of the canonical event) | Single-event integrity check (any later mutation fails verification). | | `'hash-chain'` | `event.audit.prevHash` and `event.audit.hash` | A verifiable chain — deletions and reordering also become detectable. | ::tip **What `signed()` actually buys you.** Detection, not prevention. Anyone with write access to the underlying sink can still nuke the file or table — but the chain proves *which* events were dropped or modified after the fact. Skip `signed()` if you already write to an append-only / WORM store (S3 Object Lock, Postgres with row-level immutability, BigQuery append-only tables); doubling integrity layers just adds latency without raising the bar. :: ### HMAC Each event gets a signature. Tampering with one row breaks that row's verification, but doesn't break later rows. ```typescript import { signed } from 'evlog' signed(drain, { strategy: 'hmac', secret: process.env.AUDIT_SECRET! }) ``` ### Hash-chain Each event references the previous event's hash. Deleting any row breaks the chain forward of that point, so the verifier can pinpoint the exact row that was tampered with. ```typescript signed(drain, { strategy: 'hash-chain', state: { load: () => fs.readFile('.audit/head', 'utf8').catch(() => null), save: (h) => fs.writeFile('.audit/head', h), }, }) ``` The `state` config is required for cross-process or durable chains: load the previous head hash from your own store (Redis, Postgres, file) before each event, save the new head after. :hash-chain-tamper ::note A CLI to walk and verify the chain ( `evlog audit verify` ) is on the roadmap. Until then, validate by recomputing the hashes of stored events and comparing each `prevHash` against the previous event's `hash` . :: # Compliance Compliance frameworks (SOC2, HIPAA, GDPR, PCI) ask the same five questions of every audit log: **who, what, when, from where, with which outcome**, plus **how do we know it wasn't tampered with**. evlog answers each one through composition of the existing primitives. ## Integrity Hash-chain the audit log so any tampering is detectable. Each event's hash includes the previous hash, so deleting a row breaks the chain forward of that point. ```typescript auditOnly( signed(createFsDrain({ dir: '.audit' }), { strategy: 'hash-chain' }), { await: true }, ) ``` ::warning **Rotate `secret` for HMAC-signed audits annually.** When you rotate, embed a key id alongside the signature (e.g. extend `AuditFields` with `keyId` via `declare module` ) so old events stay verifiable against the previous secret. Verifiers should look up the key by id, not assume a single global secret. :: See [Drains & Integrity](https://www.evlog.dev/use-cases/audit/pipeline#signed) for the difference between HMAC and hash-chain. ## Redact Audit events run through your existing `RedactConfig`. Compose with the strict audit preset to harden PII handling: ```typescript import { auditRedactPreset } from 'evlog' initLogger({ redact: { paths: [ ...(auditRedactPreset.paths ?? []), ], }, }) ``` The preset redacts `authorization`, `cookie`, `set-cookie`, and common credential key names (`password`, `token`, `apiKey`, `cardNumber`, `cvv`, `ssn`) **at any nesting depth** — including inside `audit.changes.before` / `audit.changes.after`. ## GDPR vs append-only Append-only audit logs collide with GDPR's right to be forgotten. Recommended pattern today: 1. Keep audit rows immutable. 2. Encrypt PII fields with a per-actor key (held outside the audit store). 3. To "forget" a user, delete their key — the audit row stays, the chain stays valid, the PII becomes unreadable. A built-in `cryptoShredding` helper is on the [follow-up roadmap](https://github.com/HugoRCD/evlog/issues){rel=""nofollow""}. ## Retention Retention is a storage-layer concern by design. evlog's audit layer doesn't enforce retention windows because every supported sink already has a stronger, audited mechanism for it. Pick the one matching your sink: | Sink | Retention mechanism | | ---------------------- | --------------------------------------------------------------------------------- | | FS | Combine `createFsDrain({ maxFiles })` with a daily compactor. | | Postgres | Schedule `DELETE FROM audit_events WHERE timestamp < now() - interval '7 years'`. | | Axiom / Datadog / Loki | Set the dataset retention policy in the platform. | | S3 Object Lock | Configure lifecycle rules + Object Lock retention period. | Document the chosen window in your security policy. Auditors care about the written rule, not the enforcing component. ## Common Pitfalls - **Logging only successes.** Auditors care most about denials. Always pair `log.audit()` with `log.audit.deny()` on the negative branch of every authorisation check. - **Leaking PII through `changes`.** `auditDiff()` runs through your `RedactConfig`, but only if the field paths are listed. Add `password`, `token`, `apiKey`, etc. once globally so you never have to think about it again. - **Treating audits as observability.** Don't sample, downsample, or summarise audit events. Force-keep is on by default — don't disable it. - **Conflating `actor.id` with the session id.** `actor.id` is the stable user id (or system identity). Correlate sessions via `context.requestId` / `context.traceId`, never via the actor. - **Forgetting standalone jobs.** Cron tasks, queue workers, and CLIs trigger audit-worthy actions too. Use `audit()` (no request) or `withAudit()` to keep coverage parity with your HTTP routes. - **Skipping `await: true` on the audit drain.** Without it, audits are fire-and-forget — a crash between the event being emitted and the drain flushing means the action happened but no audit row exists. # Recipes & Reference Pick the recipe that matches your sink, drop it in, and you have a tamper-evident audit log. Each recipe composes the same primitives (`auditOnly`, `signed`, optional `await: true`) over different drains. ## Audit logs on disk ::code-group ```typescript [Input — server/plugins/evlog.ts] import { auditOnly, signed } from 'evlog' import { createFsDrain } from 'evlog/fs' nitro.hooks.hook('evlog:drain', auditOnly( signed(createFsDrain({ dir: '.audit', maxFiles: 30 }), { strategy: 'hash-chain' }), { await: true }, )) ``` ```jsonl [Output — .audit/2026-04-24.ndjson] {"audit":{"action":"invoice.refund","actor":{"type":"user","id":"usr_42"},"target":{"type":"invoice","id":"inv_889"},"outcome":"success","version":1,"idempotencyKey":"ak_8f3c4b2a1e5d6f7c","prevHash":null,"hash":"3f2c8e1a..."}} {"audit":{"action":"user.update","actor":{"type":"user","id":"usr_42"},"target":{"type":"user","id":"usr_99"},"outcome":"success","version":1,"idempotencyKey":"ak_5e7d8f9a0b1c2d3e","prevHash":"3f2c8e1a...","hash":"9a1b4d7c..."}} ``` :: Each line's `prevHash` matches the previous line's `hash`. Tampering with any row breaks the chain forward of that point — a verifier replays the hashes and reports the first mismatch. ## Audit logs to a dedicated Axiom dataset ::code-group ```typescript [Input — server/plugins/evlog.ts] import { auditOnly } from 'evlog' import { createAxiomDrain } from 'evlog/axiom' nitro.hooks.hook('evlog:drain', createAxiomDrain({ dataset: 'logs' })) nitro.hooks.hook('evlog:drain', auditOnly( createAxiomDrain({ dataset: 'audit', apiKey: process.env.AXIOM_AUDIT_API_KEY }), )) ``` ```kusto [Output — Axiom query] ['audit'] | where audit.action == "invoice.refund" | summarize count() by audit.outcome, bin(_time, 1h) ``` ```kusto [Output — denials by actor] ['audit'] | where audit.outcome == "denied" | summarize count() by audit.actor.id, audit.action | order by count_ desc ``` :: Splitting datasets means the audit dataset can have a longer retention (7y), tighter access controls, and a separate billing line — without touching the rest of your pipeline. ## Audit logs in Postgres ::code-group ```typescript [Input — server/plugins/evlog.ts] import { auditOnly } from 'evlog' import type { DrainContext } from 'evlog' const postgresAudit = async (ctx: DrainContext) => { await db.insert(auditEvents).values({ id: ctx.event.audit!.idempotencyKey, timestamp: new Date(ctx.event.timestamp), payload: ctx.event, }).onConflictDoNothing() } nitro.hooks.hook('evlog:drain', auditOnly(postgresAudit, { await: true })) ``` ```sql [Output — audit_events row] SELECT id, timestamp, payload->'audit'->>'action' AS action, payload->'audit'->>'outcome' AS outcome FROM audit_events WHERE id = 'ak_8f3c4b2a1e5d6f7c'; -- id | timestamp | action | outcome -- ---------------------+-----------------------+-----------------+--------- -- ak_8f3c4b2a1e5d6f7c | 2026-04-24 10:23:45.6 | invoice.refund | success ``` :: The deterministic `idempotencyKey` makes retries safe — duplicate inserts collapse via `ON CONFLICT DO NOTHING`. Without it, a transient network blip during a retry would create a duplicate audit row, which is exactly what you don't want. ## Testing audits `mockAudit()` captures every audit event on emit — from standalone `audit()`, `log.audit()`, and `log.set({ audit })`: ```typescript import { mockAudit } from 'evlog' it('refunds the invoice and records an audit', async () => { const captured = mockAudit() await refundInvoice({ id: 'inv_889' }, { actor: { type: 'user', id: 'u1' } }) captured.assertAudit({ action: 'invoice.refund', target: { type: 'invoice', id: 'inv_889' }, outcome: 'success', }) captured.restore() }) ``` Prefer `assertAudit()` when a missing audit should fail the test with a readable message. Use `toIncludeAuditOf()` when you need a boolean inside `expect(...)`. Always call `captured.restore()` in an `afterEach` (or wrap with a fixture) so a failing assertion never leaks into the next test. ## API Reference | Symbol | Kind | Notes | | ----------------------------------- | --------- | --------------------------------------------------------------- | | `AuditFields` | type | Reserved field on the wide event | | `defineAuditAction(name, opts?)` | factory | Typed action registry, infers target shape | | `defineAuditCatalog(prefix, map)` | factory | Bundle of typed audit actions sharing a prefix | | `log.audit(fields)` | method | Sugar over `log.set({ audit })` + force-keep | | `log.audit.deny(reason, fields)` | method | Records a denied action | | `audit(fields)` | function | Standalone for scripts / jobs | | `withAudit({ action, target })(fn)` | wrapper | Auto-emit success / failure / denied | | `auditDiff(before, after)` | helper | Redact-aware JSON Patch for `changes` | | `mockAudit()` | test util | Capture audits on emit; `assertAudit()` or `toIncludeAuditOf()` | | `auditEnricher(opts?)` | enricher | Auto-fill request / runtime / tenant context | | `auditOnly(drain, { await? })` | wrapper | Routes only events with an `audit` field | | `signed(drain, opts)` | wrapper | Generic integrity wrapper (hmac / hash-chain) | | `auditRedactPreset` | config | Strict PII for audit events | Everything ships from the main `evlog` entrypoint. # telemetry `@evlog/telemetry` brings [evlog](https://evlog.dev){rel=""nofollow""}'s wide-event model to tools that run on other people's machines — CLIs, GitHub Actions, dev scripts, and CI jobs. Same philosophy as HTTP logging: **one command execution → one structured event**, not a stream of analytics calls. ::code-group ```bash [pnpm] pnpm add @evlog/telemetry ``` ```bash [bun] bun add @evlog/telemetry ``` ```bash [yarn] yarn add @evlog/telemetry ``` ```bash [npm] npm install @evlog/telemetry ``` :: You get command name, sanitized flags, duration, and outcome automatically. Call `telemetry.set()` only when you have extra counters — numbers and booleans by default. Raw `argv` is never read; disclosure is generated from your runtime config so it cannot drift from what you actually collect. ::prompt --- actions: - copy - cursor - claude description: Add anonymous telemetry to my CLI or script icon: i-lucide-gauge --- Add privacy-respecting telemetry to my CLI, script, or GitHub Action with @evlog/telemetry. - Detect my entrypoint: citty CLI (`withTelemetry`), standalone script (`createTelemetry`), or GitHub Actions (`createGitHubActionsTelemetry`) - Install: `pnpm add @evlog/telemetry` (or npm/yarn/bun) - Citty: wrap the root command in `withTelemetry()` in `src/index.ts` and add `defineTelemetryCommands({ name: TOOL })` - Script / migrator: `createTelemetry({ name, version })` and wrap each logical unit with `t.run('command', fn)` - GitHub Actions: `createGitHubActionsTelemetry()` — only `GITHUB_ACTION` / `GITHUB_EVENT_NAME` are injected into `custom` - Declare `collect.flags` / `collect.fields` for any string values you need beyond the `""` marker - Put your product schema in `custom` via `telemetry.set()` — see [Extending the schema](https://www.evlog.dev/use-cases/telemetry/reference#extending-the-schema) - Call `telemetry.set()` for business counters (numbers/booleans) inside handlers or helpers on the same async stack - Run `generateDisclosure()` and commit `TELEMETRY.md` beside the tool - Bake an ingestion URL in `endpoint` (or `EVLOG_TELEMETRY_ENDPOINT`) — the local outbox is a buffer, not the destination - Ship `POST /api/telemetry/ingest` on my app: use `parseIngestBody()` from `@evlog/telemetry/ingest` with `allowedTools` + `allowedCustomKeys`, dedupe on `idempotencyKey`, return 204 on success Docs: {rel=""nofollow""} :: ## From CLI to your backend The disk outbox (`~/.config/{toolName}/telemetry/outbox.ndjson`) is a **reliability buffer**, not where telemetry lives long-term. Each run appends one event locally, then the HTTP drain POSTs the backlog to **your** ingestion endpoint. Short-lived CI jobs and offline machines drain on the next invocation. 1. **Run** — `my-tool doctor` executes on the user's machine 2. **Capture** — `@evlog/telemetry` sanitizes flags, checks consent, and records one `RunEvent` 3. **Buffer** — the event appends to `~/.config/{toolName}/telemetry/outbox.ndjson` 4. **Send** — on flush, POST `{ events: RunEvent[] }` to your ingest URL 5. **Validate** — `parseIngestBody()` allowlists tools and custom keys ([Ingest](https://www.evlog.dev/use-cases/telemetry/ingest)) 6. **Store** — dedupe on `idempotencyKey`, then DB / warehouse / evlog drain 7. **Ack** — on `2xx`, delivered keys are removed from the outbox ```text CLI → @evlog/telemetry → outbox.ndjson → POST /ingest → validate → store ``` Nothing is sent until you configure an endpoint and the server returns a success status. Until then, events stay in the outbox (or are purged on opt-out). ## Why install it You can wire anonymous usage telemetry yourself — outbox file, consent flags, flag sanitization, disclosure markdown, ingest validation, first-run notice. Or you add one dependency and get the evlog playbook baked in. ### What you get for \~28 KB | | | | ---------------------- | ----------------------------------------------------------------------------------------------------------------- | | **Published size** | \~28 KB ESM (`@evlog/telemetry`), \~8.6 KB gzip — standalone, no dependency on `evlog` core | | **Server ingest only** | `@evlog/telemetry/ingest` \~5 KB (\~1.7 KB gzip) — `parseIngestBody()` without pulling citty wiring into your API | | **Runtime deps** | `citty` + `std-env` only — both `sideEffects: false`, tree-shakeable ESM | | **Node** | 18+ · ESM · `sideEffects: false` on the package | ### Fits wherever your code runs | Surface | Entry | Lines of integration | | --------------------- | -------------------------------------------------- | ------------------------------------------------------ | | **citty CLI** | `withTelemetry()` on the root command | One wrapper + optional `defineTelemetryCommands()` | | **Script / migrator** | `createTelemetry()` + `t.run()` | Wrap each logical run | | **GitHub Actions** | `createGitHubActionsTelemetry()` | Same as script; `ghaAction` / `ghaEvent` auto-injected | | **Your API (ingest)** | `parseIngestBody()` from `@evlog/telemetry/ingest` | One validator + one POST route | No framework lock-in. No PostHog / Segment / custom analytics SDK. No second schema to maintain — disclosure is generated from the same `collect` config the CLI uses at runtime. ### What you skip building - Privacy-safe flag capture (never reads raw `argv`) - `DO_NOT_TRACK` / `EVLOG_TELEMETRY` / persisted opt-out with outbox purge - Disk outbox with lockfile, stale-lock recovery, and backlog drain - Auto-generated disclosure (`TELEMETRY.md` + `telemetry status`) - First-run notice with opt-out instructions - Typed `telemetry.set()` with allowlisted string fields - Server-side ingest validation aligned with the CLI envelope - Hard guarantee: **telemetry never throws, never blocks exit** ### The payoff Once ingest is live you can answer product questions that CLI authors usually guess at: - Which commands are actually used (`doctor` vs `sync` vs `telemetry status`) - Where runs fail (`outcome`, `errorCode`, version breakdown) - How long operations take (`durationMs` per command) - Which versions are still in the wild (`tool.version` on every event) - Whether CI or local dev dominates (`env.ci`, `env.agent`, `env.tty`) All from one wide event per run — the same mental model as evlog on the server, without bolting a browser analytics stack onto a terminal tool. ::callout{color="neutral" icon="i-lucide-package"} Try it locally: [`examples/telemetry-playground`](https://github.com/HugoRCD/evlog/tree/main/examples/telemetry-playground){rel=""nofollow""} — `pnpm run cli -- doctor` , `EVLOG_TELEMETRY_DEBUG=1` to inspect payloads, `telemetry status` for disclosure. :: ## See also - [Setup](https://www.evlog.dev/use-cases/telemetry/setup) — citty, scripts, GitHub Actions, delivery config - [Ingest](https://www.evlog.dev/use-cases/telemetry/ingest) — server endpoint, validation, storage - [Reference](https://www.evlog.dev/use-cases/telemetry/reference) — envelope, schema extensions, disclosure, consent - [Audit](https://www.evlog.dev/use-cases/audit/overview) — wide events for security-sensitive actions - [Drain pipeline](https://www.evlog.dev/extend/drain-pipeline) — forward ingested events into Axiom, Datadog, OTLP, or a custom store # Telemetry Setup ## Configure delivery Point the CLI at your ingestion URL when you ship it — baked into the binary, overridable per environment: ```ts [src/index.ts] withTelemetry(command, { name: TOOL, version: VERSION, endpoint: 'https://telemetry.my-tool.dev/api/telemetry/ingest', }) ``` | Override | Effect | | -------------------------------------- | ------------------------------------------------ | | `EVLOG_TELEMETRY_ENDPOINT` | Replaces the baked-in URL (staging, self-hosted) | | `EVLOG_TELEMETRY=0` / `DO_NOT_TRACK=1` | No recording, no send | | `EVLOG_TELEMETRY_DEBUG=1` | Print payloads to stderr without sending | The built-in HTTP drain POSTs `Content-Type: application/json` with body `{ events: RunEvent[] }` and a 400ms timeout. On success (`res.ok`), delivered keys are removed from the outbox. `flush()` is capped at 500ms and never throws. ## Setup with citty Wrap your root command in `withTelemetry()` — typically in `src/index.ts`, the file that calls `runMain()`. Subcommands can live in the same file or in `src/commands/*.ts`; only the entrypoint needs the wrapper. ```ts [src/index.ts] import { defineCommand, runMain } from 'citty' import { withTelemetry, defineTelemetryCommands } from '@evlog/telemetry' import { doctorCommand } from './commands/doctor' import { syncCommand } from './commands/sync' const TOOL = 'my-tool' const VERSION = '1.0.0' export const main = withTelemetry( defineCommand({ meta: { name: 'my-tool', description: '…', version: VERSION }, subCommands: { doctor: doctorCommand, sync: syncCommand, telemetry: defineTelemetryCommands({ name: TOOL }), }, }), { name: TOOL, version: VERSION, endpoint: 'https://telemetry.my-tool.dev/api/telemetry/ingest', collect: { flags: { format: ['json', 'csv'] }, fields: { framework: ['nuxt', 'next'] }, }, }, ) runMain(main) ``` ### What gets recorded automatically `withTelemetry` walks your citty tree. Each `run` handler produces one event — no per-command telemetry boilerplate. | Invocation | `event.command` | Notes on `flags` | | --------------------------------------- | ------------------ | ------------------------------------------------------------------- | | `my-tool doctor` | `doctor` | `{}` — nothing was passed, and defaults are not choices | | `my-tool doctor --json` | `doctor` | `{ json: true }` | | `my-tool sync --dry-run --output ./out` | `sync` | `{ dryRun: true, output: "" }` — the path itself is never sent | | `my-tool sync --format json` | `sync` | `{ format: "json" }` only if allowlisted in `collect.flags` | | `my-tool telemetry status` | `telemetry status` | nested subcommands join with a space | The root `meta.name` is not prefixed when the root only delegates to `subCommands`. ### Enriching a run Call `telemetry.set()` anywhere inside a command handler — or in helpers that run on the same async stack (the run context is preserved via AsyncLocalStorage). ::code-collapse ```ts [src/commands/doctor.ts] import { telemetry } from '@evlog/telemetry' import { existsSync } from 'node:fs' import { resolveConfigPath } from '../lib/config' async function runHealthChecks() { let checksFailed = 0 let checksWarn = 0 const configPath = resolveConfigPath() if (!existsSync(configPath)) { checksFailed++ } try { await fetch('https://registry.npmjs.org/my-tool') } catch { checksWarn++ } // Counters land in event.custom on the wide event for this run telemetry.set({ checksFailed, checksWarn }) return checksFailed === 0 } export const doctorCommand = { meta: { name: 'doctor', description: 'Check environment' }, args: { json: { type: 'boolean', alias: 'j' }, }, async run({ args }: { args: { json?: boolean } }) { const ok = await runHealthChecks() if (!ok) { throw Object.assign(new Error('Health checks failed'), { code: 'DOCTOR_FAILED' }) } if (!args.json) process.stdout.write('ok\n') }, } ``` :: Throw an error with a `code` property and `outcome: "error"` plus `errorCode` are recorded automatically: ```ts [src/commands/doctor.ts] throw Object.assign(new Error('Config missing'), { code: 'CONFIG_NOT_FOUND' }) ``` A minimal sync handler for comparison — flags are auto-captured, you only `set()` business counters: ```ts [src/commands/sync.ts] import { telemetry } from '@evlog/telemetry' export const syncCommand = { meta: { name: 'sync', description: 'Pull remote state' }, args: { dryRun: { type: 'boolean' }, output: { type: 'string', description: 'Output path' }, }, async run() { const itemsSynced = await pullRemoteState() telemetry.set({ itemsSynced }) }, } ``` ## Setup without citty For scripts, migrators, or custom CLIs, use `createTelemetry()` and wrap each logical run with `t.run()`: ```ts [scripts/migrate.ts] import { createTelemetry, telemetry } from '@evlog/telemetry' const t = createTelemetry({ name: 'my-migrator', version: '2.0.0' }) await t.run('migrate', async () => { const rows = await migrateBatch() telemetry.set({ rowsMigrated: rows.length, batchSize: 500 }) }) await t.flush() // optional — also runs at end of each t.run() ``` GitHub Actions: swap `createTelemetry` for `createGitHubActionsTelemetry()` in the same file — it adds `ghaAction` and `ghaEvent` to `custom` from `GITHUB_ACTION` / `GITHUB_EVENT_NAME` only (never repo content). ```ts [scripts/ci-report.ts] import { createGitHubActionsTelemetry, telemetry } from '@evlog/telemetry' const t = createGitHubActionsTelemetry({ name: 'my-action', version: '1.0.0' }) await t.run('report', async () => { const artifacts = await collectArtifacts() telemetry.set({ artifactCount: artifacts.length }) }) ``` ## Debug ```bash EVLOG_TELEMETRY_DEBUG=1 my-tool doctor EVLOG_TELEMETRY=0 my-tool sync ``` Debug mode prints would-be payloads to stderr. Nothing is sent unless an endpoint is configured and delivery succeeds. # Telemetry Ingest You ship a CLI. Users run it on their machines. You want to know *which commands run, how often they fail, and which versions are out there* — without building a separate analytics SDK or reading raw `argv`. That means a **server endpoint** your CLI POSTs to. The local outbox is only there so short CI jobs and offline runs do not lose data before the POST succeeds. ## The security question (read this first) ::tip **CLI telemetry is not a vault.** You are collecting anonymous usage counters (command name, duration, outcome, a few numbers) — not passwords, not file paths, not tokens. Design for that threat model and you do not need impossible client-side secrets. :: **"I put the ingest URL in my CLI — isn't that a secret?"** No. The URL lives in your npm package or binary. Anyone can read it with `strings`, by installing the package, or by watching network traffic. The same applies to an API key or `Authorization` header baked into the CLI — extractable, not a real barrier. **"So can't anyone send fake events?"** In theory, yes — someone could `curl` your endpoint with forged JSON. That is why you treat the endpoint as **semi-public** (like a browser analytics key) and defend on the server: | What you might worry about | Practical answer | | ----------------------------------- | ------------------------------------------------------------------------------------- | | Random internet bots | Validate the payload shape; reject garbage with `400` | | Someone spamming fake `doctor` runs | Rate-limit by IP; data is low-value counters, not money | | Forged `tool.name` | Allowlist only your published tool names server-side | | Unexpected fields in `custom` | Filter to the keys you declared — same list as `collect` on the CLI | | Double-counting on retry | Dedupe on `idempotencyKey` when storing | | Leaking user secrets | CLI never reads raw `argv`; you only allowlisted flags and `telemetry.set()` counters | **What "good enough" looks like:** validate every POST, store idempotently, rate-limit at the edge (CDN, API gateway, or middleware), and keep the payload boring (no PII by design). That is the same playbook as client-side analytics — not banking-grade auth, but reliable product insight. ::callout{color="info" icon="i-lucide-shield-check"} `@evlog/telemetry` ships **`parseIngestBody()`** for your server — the same validation rules documented here, so you do not copy-paste a validator from the docs. Pair it with your framework route and your database or evlog drain. :: ## Step 1 — Validate with `parseIngestBody()` Mirror the CLI config on the server: same `name`, same custom keys you allow via `collect` / `telemetry.set()`. ```ts [lib/telemetry-ingest.ts] import { parseIngestBody } from '@evlog/telemetry/ingest' export const ingestOptions = { allowedTools: ['my-tool'], allowedCustomKeys: { 'my-tool': ['checksFailed', 'checksWarn', 'itemsSynced'], }, } as const export function parseTelemetryBody(raw: string) { return parseIngestBody(raw, ingestOptions) } ``` `parseIngestBody()` checks batch size, JSON shape, `event: 'run'`, tool allowlist, envelope types, ISO timestamp, and strips `custom` keys you did not declare. Throws `IngestValidationError` on failure — return `400` from your route. ## Step 2 — Wire your route Same contract for any framework — read the raw body, validate, store, return **204** so the CLI clears its outbox: ::code-group ```ts [server/api/telemetry/ingest.post.ts (Nitro)] import { defineEventHandler, readRawBody, setResponseStatus } from 'h3' import { IngestValidationError } from '@evlog/telemetry/ingest' import { parseTelemetryBody } from '~/lib/telemetry-ingest' import { storeRunEvents } from '~/lib/telemetry-store' export default defineEventHandler(async (event) => { const raw = await readRawBody(event, 'utf8') if (!raw) { setResponseStatus(event, 400) return { error: 'empty body' } } try { const events = parseTelemetryBody(raw) await storeRunEvents(events) setResponseStatus(event, 204) return null } catch (err) { setResponseStatus(event, err instanceof IngestValidationError ? 400 : 500) return { error: 'invalid payload' } } }) ``` ```ts [app/api/telemetry/ingest/route.ts (Next.js App Router)] import { IngestValidationError } from '@evlog/telemetry/ingest' import { parseTelemetryBody } from '@/lib/telemetry-ingest' import { storeRunEvents } from '@/lib/telemetry-store' export async function POST(request: Request) { const raw = await request.text() try { const events = parseTelemetryBody(raw) await storeRunEvents(events) return new Response(null, { status: 204 }) } catch (err) { const status = err instanceof IngestValidationError ? 400 : 500 return Response.json({ error: 'invalid payload' }, { status }) } } ``` ```ts [src/routes/telemetry.ts (Hono)] import { Hono } from 'hono' import { IngestValidationError } from '@evlog/telemetry/ingest' import { parseTelemetryBody } from '../lib/telemetry-ingest' import { storeRunEvents } from '../lib/telemetry-store' const app = new Hono() app.post('/api/telemetry/ingest', async (c) => { const raw = await c.req.text() try { const events = parseTelemetryBody(raw) await storeRunEvents(events) return c.body(null, 204) } catch (err) { const status = err instanceof IngestValidationError ? 400 : 500 return c.json({ error: 'invalid payload' }, status) } }) export default app ``` :: ## Step 3 — Store idempotently Retries and backlog drains can POST the same `idempotencyKey` twice. Upsert (or skip) so metrics stay correct: ```ts [lib/telemetry-store.ts] import type { RunEvent } from '@evlog/telemetry' export async function storeRunEvents(events: RunEvent[]): Promise { for (const run of events) { await db.telemetryRun.upsert({ where: { idempotencyKey: run.idempotencyKey }, create: { tool: run.tool.name, version: run.tool.version, command: run.command, outcome: run.outcome, durationMs: run.durationMs, custom: run.custom, recordedAt: run.timestamp, }, update: {}, }) } // Or forward into evlog's drain pipeline for Axiom / Datadog / OTLP: // await ingestWideEvents(events.map(run => ({ source: 'telemetry', ...run }))) } ``` ## Step 4 — Rate-limit at the edge `parseIngestBody()` is your last line of schema defense, not your only one. Add rate limits where traffic enters — CDN, API gateway, or middleware — especially per IP. Optional: also bucket by `machineId` hash inside the handler if you need tighter control. Non-2xx responses keep events in the user's outbox; they will retry on the next CLI invocation. Return **204** (or any `2xx`) only when storage succeeded. # Telemetry Reference ## Standard envelope Every run shares the same shape. You do not declare per-command schemas. ```jsonc { "event": "run", "command": "sync", "durationMs": 412, "outcome": "success", "flags": { "dryRun": true, "output": "" }, "tool": { "name": "my-tool", "version": "1.0.0" }, "env": { "node": "20.11", "ci": false, "provider": null, "tty": true, "agent": "cursor" // std-env: cursor, claude, codex, … or null }, "machineId": "ab3f…", // hashed; omitted in ephemeral CI "custom": { "itemsSynced": 42 } } ``` ## Extending the schema The envelope above is **fixed** — every tool sends the same top-level shape so `parseIngestBody()` and disclosure stay predictable. You do not add fields next to `command` or `durationMs`. Your product schema lives in two extension zones: | Zone | Who sets it | What goes there | | -------- | -------------------------------------------- | ---------------------------------------------------------------------------- | | `flags` | citty (auto) + `collect.flags` | Flags you passed — booleans/numbers as values, strings only when allowlisted | | `custom` | you via `telemetry.set()` + `collect.fields` | Business counters, categorical dimensions, schema version | Think of it as two contracts: - **Transport contract** (`RunEvent`) — owned by `@evlog/telemetry`, stable across tools - **Product contract** (`custom` + declared `flags`) — owned by you, declared in `collect`, mirrored on the server ### Declare your extensions once Everything you collect beyond the standard envelope is declared inline in the same `withTelemetry()` / `createTelemetry()` call: ```ts [src/index.ts] withTelemetry(command, { name: 'acme-cli', version: '2.1.0', endpoint: 'https://telemetry.acme.dev/api/ingest', collect: { flags: { format: ['json', 'yaml', 'table'], target: ['staging', 'production'], }, fields: { product: ['cli', 'action', 'migrator'], plan: ['free', 'pro', 'enterprise'], framework: ['nuxt', 'next', 'remix'], }, }, }) ``` Inside handlers, add counters and dimensions: ```ts [src/commands/deploy.ts] await t.run('deploy', async () => { const result = await deployServices() telemetry.set({ servicesDeployed: result.count, // number — always allowed rollbackUsed: false, // boolean — always allowed framework: 'nuxt', // string — allowed (in collect.fields) schemaVersion: 2, // number — version your custom shape }) }) ``` Undeclared strings are **dropped at runtime**, never thrown. That is the privacy guarantee — no accidental paths, tokens, or free-form PII in `custom`. ::note **Typing:** `TelemetryHandle.set()` autocomplete covers declared `collect.fields` keys. Use the ambient `telemetry.set()` for extra numeric/boolean counters in the same run — both merge into `custom` before the event is recorded. :: ### Mirror on the server Your ingest handler is the second gate. Pass the same tool name and custom keys you declared on the CLI: ```ts [app/api/telemetry/ingest/route.ts] import { parseIngestBody } from '@evlog/telemetry/ingest' const ALLOWED_CUSTOM = [ 'servicesDeployed', 'rollbackUsed', 'framework', 'product', 'plan', 'schemaVersion', 'ghaAction', 'ghaEvent', ] as const export async function POST(req: Request) { const raw = await req.text() const events = parseIngestBody(raw, { allowedTools: ['acme-cli'], allowedCustomKeys: { 'acme-cli': ALLOWED_CUSTOM }, }) // dedupe on idempotencyKey, then persist } ``` `parseIngestBody()` strips any `custom` key you did not list — even if a client somehow sent it. ### Map to your warehouse schema After validation, shape the event for **your** database. The evlog envelope is the wire format; your tables are your choice: ```ts [lib/telemetry-store.ts] function toRow(event: RunEvent) { return { command: event.command, duration_ms: event.durationMs, outcome: event.outcome, tool_version: event.tool.version, format: event.flags.format, target: event.flags.target, services_deployed: event.custom.servicesDeployed, framework: event.custom.framework, plan: event.custom.plan, schema_version: event.custom.schemaVersion ?? 1, is_ci: event.env.ci, node: event.env.node, received_at: new Date(), } } ``` This is where a company "modifies the schema" in practice — not by changing `RunEvent`, but by defining how `custom` maps into internal analytics tables. ### Version breaking changes in `custom` When your product counters evolve, bump a numeric `schemaVersion` in `custom` (no allowlist needed) and branch in the ingest mapper: ```ts const version = (event.custom.schemaVersion as number | undefined) ?? 1 if (version === 1) return mapV1(event) return mapV2(event) ``` Commit an updated `TELEMETRY.md` whenever `collect` changes so disclosure stays aligned with releases. ### Multiple tools, one endpoint If several CLIs POST to the same ingest URL, namespace custom keys or rely on `tool.name`: ```ts // Option A — prefix keys per product line telemetry.set({ acme_product: 'cli', deployCount: 3 }) // Option B — separate allowedCustomKeys per tool.name in parseIngestBody allowedCustomKeys: { 'acme-cli': ['deployCount', 'framework'], 'acme-action': ['jobDuration', 'ghaAction', 'ghaEvent'], } ``` ### What v1 does not support | Need | v1 answer | Workaround | | ----------------------------------- | -------------------- | -------------------------------------------------------------------------- | | New top-level field (`tenantId`) | Not on the wire | Flat key in `custom` + allowlist, or map server-side from `machineId` | | Nested objects (`custom.user.plan`) | Flat records only | Flatten: `userPlan: 'pro'` with `collect.fields` | | Free-form strings (paths, emails) | Blocked by design | Flags record `""` instead of the value; hash before `telemetry.set()` | | Different schema per command | One envelope per run | Convention: only set relevant keys per command | | Transform before outbox | No hook yet | Map on the server (recommended) or see planned v2 below | ::tip **Rule of thumb:** if it is safe to print in `TELEMETRY.md` and fits a closed set or a number, it belongs in `collect` \+ `custom` . Everything else stays server-side. :: ### Planned v2 extensions Not shipped yet — design targets for when the v1 extension zones are not enough: **`enrich` hook** — run after sanitization, before the outbox append. Lets you inject computed fields without forking the package: ```ts // Planned API — not available in v1 createTelemetry({ name: 'acme-cli', version: '3.0.0', collect: { fields: { product: ['cli', 'action'] } }, enrich(event) { return { ...event, custom: { ...event.custom, schemaVersion: 2, }, } }, }) ``` The hook cannot bypass sanitization rules — strings still require `collect.fields`. Server-side `allowedCustomKeys` stays the source of truth. **Namespaced keys** — optional `acme.product` convention for multi-tool endpoints without prefix collisions. Would be documented in disclosure as `acme.product: cli | action`. If you need one of these before v2 lands, open a discussion on the repo — the v1 path (`custom` + server mapping) covers most product analytics needs. ## Privacy Raw `argv` is never read. Sanitization applies to **citty-parsed** flags only: - **Booleans / numbers** → value stored (`json: true`, `limit: 50`) - **Strings** → `""` (`output: ""`) unless allowlisted in `collect.flags` - **`telemetry.set()`** → numbers and booleans always; strings only via `collect.fields` (undeclared values are dropped at runtime, never thrown) ```ts [src/index.ts] collect: { flags: { format: ['json', 'csv'] }, // --format json → "json"; --format yaml → "" fields: { framework: ['nuxt', 'next'] }, // telemetry.set({ framework: 'nuxt' }) ok } ``` Three kinds of parser noise are dropped before the event is built, so `flags` reads as what the user asked for rather than what the parser filled in: - **Positionals** — citty's `_` bucket holds argument values, not flag names - **Defaults** — a flag still sitting at its declared `default` is not a choice anyone made - **Kebab-case duplicates** — `--min-score` arrives as both `minScore` and `min-score`; only the camelCase name is kept, and that is the name `collect.flags` allowlists Negation still reports: `--no-write` against `write: { default: true }` records `write: false`. Declare allowlists in the same `withTelemetry()` / `createTelemetry()` call as `collect` — no separate config file. ## Disclosure `generateDisclosure()` produces markdown + JSON from the standard envelope plus your `collect` extensions. Commit the output (e.g. `TELEMETRY.md`) so it stays in sync with releases: ```ts [scripts/generate-disclosure.ts] import { writeFile } from 'node:fs/promises' import { generateDisclosure } from '@evlog/telemetry' const { markdown } = generateDisclosure('my-tool', { flags: { format: ['json', 'csv'] }, }) await writeFile('TELEMETRY.md', markdown) ``` Users can also read it at runtime via `my-tool telemetry status` when you wire `defineTelemetryCommands()`. ## Consent and reliability **Opt-out priority:** `DO_NOT_TRACK=1` → `EVLOG_TELEMETRY=0` → persisted preference (`disableTelemetry()` / `telemetry disable`). Opt-out purges the undelivered outbox. **Never harms the host:** telemetry never throws, never blocks exit; `flush()` has a 500ms hard cap. **Outbox:** events append locally before any network attempt. Offline machines and CI matrix jobs drain the backlog on the next invocation once your ingestion endpoint returns `2xx`. **Endpoint:** `EVLOG_TELEMETRY_ENDPOINT` env → `endpoint` option baked into the CLI. Omit both during local development; ship the URL when your ingest handler is live. # Enrichers Enrichers add derived context to your wide events after they are emitted, before they reach your drain adapters. Use them to automatically extract useful information from request headers without cluttering your application code. All built-in enrichers are exported from `evlog/enrichers`. Each enricher is a factory function that returns an `(ctx: EnrichContext) => void` callback. To write your own, see [Custom Enrichers](https://www.evlog.dev/extend/custom-enrichers). ::prompt --- actions: - copy - cursor - claude description: Add all built-in evlog enrichers icon: i-lucide-puzzle --- Add all built-in enrichers to my evlog setup. 1. Identify which framework I'm using and follow its evlog integration pattern 2. Import createUserAgentEnricher, createGeoEnricher, createRequestSizeEnricher, and createTraceContextEnricher from 'evlog/enrichers' 3. Wire the enrichers into my framework's enrich configuration 4. Enrichers add userAgent, geo, requestSize, and traceContext fields to wide events 5. All enrichers accept { overwrite?: boolean } - defaults to false to preserve user-set data Enricher docs: {rel=""nofollow""} Framework setup: {rel=""nofollow""} :: ```typescript [server/plugins/evlog-enrich.ts] import { createUserAgentEnricher, createGeoEnricher, createRequestSizeEnricher, createTraceContextEnricher, } from 'evlog/enrichers' ``` ## All built-in enrichers Use `createDefaultEnrichers()` to compose user agent, geo, request size, and trace context in one call: ```typescript [server/plugins/evlog-enrich.ts] import { createDefaultEnrichers } from 'evlog/enrichers' const enrich = createDefaultEnrichers() export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:enrich', enrich) }) ``` Each enricher accepts `{ overwrite?: boolean }` (default `false`) so user-set fields are preserved. ## User Agent Parse browser, OS, and device type from the `User-Agent` header. **Sets:** `event.userAgent` ```typescript [user-agent-enricher.ts] const enrich = createUserAgentEnricher() ``` **Output shape:** ```typescript [user-agent-types.ts] interface UserAgentInfo { raw: string // Original User-Agent string browser?: { name: string; version?: string } // Chrome, Firefox, Safari, Edge os?: { name: string; version?: string } // Windows, macOS, iOS, Android, Linux device?: { type: 'mobile' | 'tablet' | 'desktop' | 'bot' | 'unknown' } } ``` **Example output:** ```json [Example wide event: userAgent] { "userAgent": { "raw": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/120.0.0.0", "browser": { "name": "Chrome", "version": "120.0.0.0" }, "os": { "name": "macOS", "version": "10.15.7" }, "device": { "type": "desktop" } } } ``` **Detected browsers:** Edge, Chrome, Firefox, Safari (checked in order, Edge before Chrome to avoid false matches). **Detected devices:** Bot (crawlers, spiders), Tablet (iPad), Mobile (iPhone, Android phones), Desktop (fallback). ## Geo Extract geographic data from platform-injected headers. **Sets:** `event.geo` ```typescript [geo-enricher.ts] const enrich = createGeoEnricher() ``` **Output shape:** ```typescript [geo-types.ts] interface GeoInfo { country?: string // ISO country code (e.g., "US", "FR") region?: string // Region/state name regionCode?: string // Region code city?: string // City name latitude?: number // Decimal latitude longitude?: number // Decimal longitude } ``` **Supported platforms:** | Platform | Headers | Coverage | | ---------- | ------------------------------------------------------------------------------------------------------------------------ | ------------ | | Vercel | `x-vercel-ip-country`, `x-vercel-ip-country-region`, `x-vercel-ip-city`, `x-vercel-ip-latitude`, `x-vercel-ip-longitude` | Full | | Cloudflare | `cf-ipcountry` | Country only | ::callout{color="info" icon="i-lucide-info"} **Cloudflare note:** Only `cf-ipcountry` is a standard Cloudflare HTTP header. Other geo fields ( `city` , `region` , `latitude` , etc.) are properties of `request.cf` , which is not exposed as headers. For full Cloudflare geo data, write a [custom enricher](https://www.evlog.dev/extend/custom-enrichers) that reads `request.cf` , or use a Workers middleware to copy `cf` properties into custom headers. :: ## Request Size Capture request and response payload sizes from `Content-Length` headers. **Sets:** `event.requestSize` ```typescript [request-size-enricher.ts] const enrich = createRequestSizeEnricher() ``` **Output shape:** ```typescript [request-size-types.ts] interface RequestSizeInfo { requestBytes?: number // Request Content-Length responseBytes?: number // Response Content-Length } ``` **Example output:** ```json [Example wide event: requestSize] { "requestSize": { "requestBytes": 1234, "responseBytes": 5678 } } ``` ::callout{color="info" icon="i-lucide-info"} This enricher reads the `Content-Length` header from both the request and response. If the header is missing (e.g., for chunked transfer encoding), the corresponding field will be `undefined` . :: ## Trace Context Extract W3C trace context from the `traceparent` and `tracestate` headers. **Sets:** `event.traceContext`, `event.traceId`, `event.spanId` ```typescript [trace-context-enricher.ts] const enrich = createTraceContextEnricher() ``` **Output shape:** ```typescript [trace-context-types.ts] interface TraceContextInfo { traceparent?: string // Full traceparent header value tracestate?: string // Full tracestate header value traceId?: string // 32-char hex trace ID (parsed from traceparent) spanId?: string // 16-char hex span ID (parsed from traceparent) } ``` **Example output:** ```json [Example wide event: traceContext] { "traceContext": { "traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01", "traceId": "4bf92f3577b34da6a3ce929d0e0e4736", "spanId": "00f067aa0ba902b7" }, "traceId": "4bf92f3577b34da6a3ce929d0e0e4736", "spanId": "00f067aa0ba902b7" } ``` `traceId` and `spanId` are also set at the top level of the event for easy querying and correlation. ::callout{color="info" icon="i-lucide-info"} The traceparent format follows the [W3C Trace Context](https://www.w3.org/TR/trace-context/){rel=""nofollow""} specification: `{version}-{traceId}-{spanId}-{flags}` . :: ## Full Setup Example Use all built-in enrichers together. The list of enrichers is identical across frameworks — only the wiring changes. ::code-group ```typescript [Nuxt / Nitro] // server/plugins/evlog-enrich.ts import { createUserAgentEnricher, createGeoEnricher, createRequestSizeEnricher, createTraceContextEnricher, } from 'evlog/enrichers' export default defineNitroPlugin((nitroApp) => { const enrichers = [ createUserAgentEnricher(), createGeoEnricher(), createRequestSizeEnricher(), createTraceContextEnricher(), ] nitroApp.hooks.hook('evlog:enrich', (ctx) => { for (const enricher of enrichers) enricher(ctx) }) }) ``` ```typescript [Next.js] // lib/evlog.ts import { createEvlog } from 'evlog/next' import { createUserAgentEnricher, createGeoEnricher, createRequestSizeEnricher, createTraceContextEnricher, } from 'evlog/enrichers' const enrichers = [ createUserAgentEnricher(), createGeoEnricher(), createRequestSizeEnricher(), createTraceContextEnricher(), ] export const { withEvlog, useLogger, log, createError } = createEvlog({ service: 'my-app', enrich: (ctx) => { for (const enricher of enrichers) enricher(ctx) }, }) ``` ```typescript [Hono / Express / Fastify / Elysia / NestJS] import { createUserAgentEnricher, createGeoEnricher, createRequestSizeEnricher, createTraceContextEnricher, } from 'evlog/enrichers' const enrichers = [ createUserAgentEnricher(), createGeoEnricher(), createRequestSizeEnricher(), createTraceContextEnricher(), ] app.use(evlog({ enrichers })) // Hono / Express / Elysia // await app.register(evlog, { enrichers }) // Fastify // EvlogModule.forRoot({ enrichers }) // NestJS ``` ```typescript [Standalone] // index.ts import { initLogger } from 'evlog' import { createUserAgentEnricher, createGeoEnricher, createRequestSizeEnricher, createTraceContextEnricher, } from 'evlog/enrichers' initLogger({ enrichers: [ createUserAgentEnricher(), createGeoEnricher(), createRequestSizeEnricher(), createTraceContextEnricher(), ], }) ``` :: ## Next Steps - [Custom Enrichers](https://www.evlog.dev/extend/custom-enrichers) - Write your own enricher - [Adapters](https://www.evlog.dev/integrate/adapters/overview) - Send enriched events to external services # eve [eve](https://eve.dev/docs/introduction){rel=""nofollow""} ships built-in observability: **Agent Runs** on Vercel and optional **OpenTelemetry** spans via `agent/instrumentation.ts`. `evlog/eve` adds a third layer — **exportable wide events** per turn with your full evlog pipeline (drains, enrichers, tail sampling, audit). ::callout{color="info" icon="i-lucide-info"} `evlog/eve` requires **eve 0.30 or later** . eve is still in beta and stream event shapes may change before GA, so pin `eve` and `evlog` versions in production agents. :: ::prompt --- actions: - copy - cursor - claude description: Add evlog wide events to my eve agent icon: i-custom-eve --- Add evlog wide events to my eve agent. - Install evlog: pnpm add evlog - Create agent/hooks/evlog.ts with defineEvlogHook from 'evlog/eve' - Pass drain, enrich, and keep options (same as HTTP middleware integrations) - In tools, import useLogger from 'evlog/eve' and call useLogger() inside execute() — the turn logger is bound via AsyncLocalStorage when defineEvlogHook() is registered; pass ctx only if ALS is unavailable in your runtime - User message content is omitted by default (message: 'omit'); use 'preview' or 'full' only after reviewing PII policy - Optionally add agent/instrumentation.ts with defineEvlogInstrumentation from 'evlog/eve' to join OTel spans to the wide events - Keep eve Agent Runs — evlog/eve is additive Docs: {rel=""nofollow""} Adapters: {rel=""nofollow""} :: ## When to use what | Need | Use | | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | | Debug a session in Vercel | eve **Agent Runs** (automatic) | | Span-level traces in Datadog / Honeycomb | eve **`agent/instrumentation.ts`** + OTel exporter | | Wide events to Axiom / Better Stack / FS, billing, audit, tail sampling | **`evlog/eve`** | | Jumping from a span to its wide event, and back | **`defineEvlogInstrumentation()`** — [below](https://www.evlog.dev/#correlate-traces-with-wide-events) | ## Quick Start ### 1. Install ::code-group ```bash [pnpm] pnpm add evlog eve ``` ```bash [bun] bun add evlog eve ``` ```bash [yarn] yarn add evlog eve ``` ```bash [npm] npm install evlog eve ``` :: ### 2. Add the hook Create `agent/hooks/evlog.ts`: ```typescript [agent/hooks/evlog.ts] import { defineEvlogHook } from 'evlog/eve' import { createAxiomDrain } from 'evlog/axiom' export default defineEvlogHook({ init: { env: { service: 'my-agent' } }, drain: createAxiomDrain(), enrich: (ctx) => { ctx.event.region = process.env.VERCEL_REGION }, }) ``` eve auto-discovers hook files under `agent/hooks/`. No HTTP middleware — the unit of work is an agent **turn**, not a request. ### 3. Log business context from tools `defineEvlogHook()` binds the turn logger via **AsyncLocalStorage** on `turn.started`. Inside tool `execute()` handlers, call `useLogger()` with no arguments — same ergonomics as `useLogger(event)` in Nuxt or Hono. In the [example agent](https://github.com/HugoRCD/evlog/tree/main/examples/eve){rel=""nofollow""}, support tools attach customer and order context as the agent works a refund: ```typescript [agent/tools/lookup_order.ts] import { defineTool } from 'eve/tools' import { useLogger } from 'evlog/eve' import { z } from 'zod' export default defineTool({ description: 'Look up an order by id.', inputSchema: z.object({ orderId: z.string() }), async execute({ orderId }) { const order = await fetchOrder(orderId) const log = useLogger() log.set({ order: { id: order.id, amount: order.amount, status: order.status } }) return order }, }) ``` ::callout{color="neutral" icon="i-lucide-plug"} **Fallback:** if `useLogger()` throws outside a turn, pass eve tool `ctx` : `useLogger(ctx)` . This covers separate hook/tool bundles or runtimes where AsyncLocalStorage does not propagate. With a standard eve agent layout and `agent/hooks/evlog.ts` registered, you should not need it. :: ### 4. Next.js web chat (optional) Wrap `next.config.ts` with `withEve()` from `eve/next` and use `useEveAgent()` from `eve/react` in your app. eve starts alongside `next dev` and proxies `/eve/v1/*` on the same origin — tool calls, approvals, and `ask_question` work out of the box. See the [example agent](https://github.com/HugoRCD/evlog/tree/main/examples/eve){rel=""nofollow""}. ## Wide event shape Each completed turn emits one event: ```json [Wide Event — turn awaiting approval] { "method": "EVE", "path": "/sessions/sess_abc/turns/turn_0", "status": 200, "duration": "7.9s", "durationMs": 7903, "service": "clearbill-support-agent", "eve": { "sessionId": "sess_abc", "turnId": "turn_0", "turnSequence": 0, "phase": "awaiting-approval", "sessionTurns": 1 }, "customer": { "slug": "acme-corp", "plan": "enterprise" }, "order": { "id": "4821", "amount": 890, "currency": "USD" }, "approval": { "status": "pending", "tool": "issue_refund" }, "ai": { "calls": 2, "steps": 2, "inputTokens": 11724, "outputTokens": 282, "finishReason": "tool-calls", "tools": [ { "name": "lookup_customer", "durationMs": 26, "success": true }, { "name": "lookup_order", "durationMs": 13, "success": true } ] } } ``` After approval, the next turn carries the outcome: ```json [Wide Event — refund completed] { "path": "/sessions/sess_abc/turns/turn_1", "eve": { "sessionId": "sess_abc", "turnId": "turn_1", "turnSequence": 1, "sessionTurns": 2 }, "refund": { "orderId": "4821", "amount": 890, "status": "refunded" }, "audit": { "action": "refund.issued", "target": { "type": "order", "id": "4821" } }, "approval": { "status": "approved", "tool": "issue_refund" }, "ai": { "tools": [{ "name": "issue_refund", "durationMs": 993, "success": true }], "finishReason": "stop" } } ``` Token usage and tool executions are accumulated from eve stream events (`step.completed`, `actions.requested`, `action.result`). Business fields set via `useLogger()` carry across turns in the same session. Link turns in analytics with `eve.sessionId` + `eve.turnSequence` — each event stays self-contained. `eve.phase` is only set for non-routine endings: `awaiting-approval`, `awaiting-authorization`, `rejected`, `cancelled`, `failed`. ### Everything the turn can report Beyond the identifiers above, a turn records what eve reports about it. Every field is optional — it appears only when the turn produced it. | Field | What it tells you | | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `eve.runtime` | eve version, agent id, model, and the deployed `gitSha` / `gitBranch` / `deployedAt` | | `eve.caller` | Who triggered the turn: `principalId`, `principalType` and `authenticator`. On a multi-user channel this is what you group cost and volume by. `subject` and `attributes` are never recorded — a channel may put a name or an email in them | | `eve.parent` | Parent and root session ids for a subagent run — rebuild the delegation tree with `rootSessionId` | | `eve.authorizations` | Connection sign-ins with their `outcome`, `reason` and duration | | `eve.compaction` | How many compactions ran, on which model, and `inputTokensAtTrigger` — how full the context was when the first one fired | | `eve.stepFailures` / `eve.failedSteps` | Model calls that failed, including on a turn that then succeeded on retry | | `eve.subagents` | Delegations with their status and duration | | `eve.reasoning` | `blocks` and `chars` — the size of the model's thinking, never its content | | `eve.result` | The structured result, for an agent with an output schema | | `eve.contextCleared` | The durable history was wiped during this turn | | `message.responseChars` | Length of the agent's response, recorded whatever the `message` mode | | `ai.costUsd` | Cost as reported by eve. `ai.estimatedCost` is the fallback computed from `cost` | ## Correlate traces with wide events A wide event and an Agent Runs span describe the same turn, but nothing joins them on its own. `defineEvlogInstrumentation()` stamps evlog's turn identity onto eve's AI SDK spans: ```typescript [agent/instrumentation.ts] import { defineEvlogInstrumentation } from 'evlog/eve' export default defineEvlogInstrumentation() ``` Every model-call span — and its children — then carries `evlog.request_id` and `evlog.session_id`, the same values the wide event reports as `requestId` and `eve.sessionId`. Jump from a trace in Braintrust, Datadog or Agent Runs straight to the event in your drain, and back. Without `setup`, OpenTelemetry export is untouched: eve keeps writing its local traces, readable with `eve traces`. Pass one to export elsewhere: ```typescript [agent/instrumentation.ts] import { defineEvlogInstrumentation } from 'evlog/eve' import { registerOTel } from '@vercel/otel' export default defineEvlogInstrumentation({ setup: ({ agentName }) => registerOTel({ serviceName: agentName }), }) ``` `functionId`, `recordInputs`, `recordOutputs` and `traceChannelRequests` pass straight through to eve's `defineInstrumentation`. ### Alongside another observability backend `defineEvlogInstrumentation()` is the shortcut for an agent whose instrumentation is evlog's alone. It owns the file, and an agent has exactly one `agent/instrumentation.ts` — every observability item in eve's registry writes that same path, which is why `eve add instrumentation/sentry` after `eve add instrumentation/posthog` refuses rather than clobbering the first. Once another backend is in play, write the file yourself with eve's own `defineInstrumentation` and spread `evlogRuntimeContext` into your runtime context. evlog contributes attributes to your instrumentation instead of wrapping it: ```typescript [agent/instrumentation.ts] import { BatchSpanProcessor, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base' import { PostHogTraceExporter } from '@posthog/ai/otel' import { OTLPHttpProtoTraceExporter, registerOTel } from '@vercel/otel' import { defineInstrumentation } from 'eve/instrumentation' import { evlogRuntimeContext } from 'evlog/eve' export default defineInstrumentation({ // One provider, one span processor per backend. Items that generate // `traceExporter` (Sentry, Datadog, Arize, Jaeger, Braintrust, Honeycomb) // become an entry in this array. Call registerOTel once, not once per backend. setup: ({ agentName }) => registerOTel({ serviceName: agentName, spanProcessors: [ new SimpleSpanProcessor(new PostHogTraceExporter({ projectToken: process.env.POSTHOG_PROJECT_TOKEN!, })), new BatchSpanProcessor(new OTLPHttpProtoTraceExporter({ url: process.env.SENTRY_OTLP_TRACES_ENDPOINT!, headers: { 'x-sentry-auth': `sentry sentry_key=${process.env.SENTRY_PUBLIC_KEY}` }, })), ], }), events: { 'step.started': (input) => { const principalId = input.session.auth.initiator?.principalId ?? input.session.auth.current?.principalId return { runtimeContext: { ...evlogRuntimeContext(input), // Omitted rather than blank: an empty attribute reads as an empty id. ...(principalId ? { posthog_distinct_id: principalId } : {}), }, } }, }, }) ``` `evlogRuntimeContext` returns `undefined` outside a tracked turn, and spreading that adds nothing. PostHog is the only registry item that also uses `events`; the rest only need their span processor. Keep each generated file open while you merge — the env vars and exporter options are the parts worth copying exactly. Spans and wide events stay joined by attribute, not by trace id: an agent turn has no inbound `traceparent` to inherit, so `evlog.request_id` is what carries across. ::callout{color="warning" icon="i-lucide-triangle-alert"} **PostHog drops `evlog.*`.** Its exporter forwards the whole span, but the server-side conversion into `$ai_generation` / `$ai_span` events keeps only attributes prefixed `posthog_` , and strips that prefix. To join on PostHog, repeat the ids under names that survive — `posthog_evlog_request_id` arrives as `evlog_request_id` . Other OTLP backends receive `evlog.*` unchanged. :: eve applies the runtime context to the step span only, so the generation spans beneath it inherit nothing. On PostHog that means cost lands on events with no identity and no environment. A span processor that copies the turn's `posthog_*` attributes onto every child span is what closes the gap. To close the loop the other way, send the agent's own wide events to PostHog Logs and point the drain at the same principal: ```typescript [agent/hooks/evlog.ts] import { createPostHogDrain } from 'evlog/posthog' import { defineEvlogHook } from 'evlog/eve' export default defineEvlogHook({ drain: createPostHogDrain({ distinctIdField: 'eve.caller.principalId' }), }) ``` Turns then land on the same PostHog person as the LLM traces they produced. See the [PostHog adapter](https://www.evlog.dev/integrate/adapters/cloud/posthog#linking-logs-to-people-and-session-replays). ## Production Long-running eve agents should disable terminal pretty-printing and use a non-blocking drain: ```typescript [agent/hooks/evlog.ts] import { defineEvlogHook } from 'evlog/eve' import { createAxiomDrain } from 'evlog/axiom' import { createDrainPipeline } from 'evlog/pipeline' const drain = createDrainPipeline({ batch: { size: 50, intervalMs: 5000 } })( createAxiomDrain(), ) export default defineEvlogHook({ init: { env: { service: 'my-agent', environment: 'production' }, pretty: false, sampling: { rates: { info: 10 } }, }, drain, maxSessions: 256, }) ``` | Concern | Recommendation | | --------------- | ------------------------------------------------------------------------ | | Terminal output | `init.pretty: false` — pretty-print is for local dev only | | Drain latency | Batch or async HTTP drains; never block the turn on I/O | | Head sampling | `init.sampling.rates` — eve emits one event per turn, not per token | | Memory | `maxSessions` (default `256`) evicts oldest idle session state | | Load | Hook handlers are O(1) per stream event; cost is dominated by your drain | ## Options | Option | Description | | --------------------------------------- | ------------------------------------------------------------------------------------------------------------- | | `init` | Passed to `initLogger()` on first hook invocation | | `drain` / `enrich` / `keep` / `plugins` | Same as HTTP integrations ([plugins](https://www.evlog.dev/extend/plugins)) | | `message` | `'omit'` (default), `'preview'` or `'full'` — how much of the user message and the agent response to record | | `messagePreviewLength` | Characters kept in `'preview'` mode (default `500`) | | `sessionEvent` | `true` emits one extra event per session, rolling up its turns | | `cost` / `model` | Fallback token pricing (`ModelCost` from `evlog/ai`) → `ai.estimatedCost`, used only when eve reports no cost | | `maxSessions` | In-memory session cap for context carry-over (default `256`) | | `include` / `exclude` | Route-style filters on turn paths (`/sessions/*/turns/*`) | ## Tail sampling Use `keep` to force-keep turns with failed tools or high token usage: ```typescript [agent/hooks/evlog.ts] export default defineEvlogHook({ drain: createAxiomDrain(), keep: (ctx) => { const ai = ctx.context.ai as { inputTokens?: number outputTokens?: number tools?: Array<{ success: boolean }> } | undefined const totalTokens = (ai?.inputTokens ?? 0) + (ai?.outputTokens ?? 0) if (totalTokens > 10_000) ctx.shouldKeep = true if (ai?.tools?.some(t => !t.success)) ctx.shouldKeep = true }, }) ``` ## Audit logs Combine with [Audit Logs](https://www.evlog.dev/use-cases/audit/overview): register `auditEnricher()` via `init.plugins` or a global plugin, and call `log.audit()` inside tools when a human approval gate fires. Tool rejections surface on `action.result` with `status: "rejected"`. ## Run locally ```bash git clone https://github.com/HugoRCD/evlog cd evlog pnpm install pnpm example eve ``` The `examples/eve` project is a **support refund copilot** (Clearbill SaaS): lookup customer → lookup order → issue refund, with eve approvals when amount > $100. Run `pnpm example eve`, open **{rel=""nofollow""}**, and click a starter prompt. ## What to read next - [eve hooks guide](https://eve.dev/docs/guides/hooks){rel=""nofollow""} — stream event vocabulary - [eve instrumentation](https://eve.dev/docs/guides/instrumentation){rel=""nofollow""} — OTel spans (complementary) - [AI SDK use case](https://www.evlog.dev/use-cases/ai-sdk/overview) — when you own the model loop directly - [Adapters overview](https://www.evlog.dev/integrate/adapters/overview) — drain destinations # Extend evlog evlog is designed to be extended on both ends — you can **observe** what flows through (without altering the pipeline), **plug into** the pipeline (enrich, decide what to keep, react to lifecycle events), or **build your own bricks** (custom drains for unsupported destinations, your own framework integration). Each page in this section ships with a `::prompt` block you can drop into Cursor / Claude to scaffold the integration in your own app. ## Mental model ```text your app code │ │ emit ▼ ┌──────────────────────────────────────────────────────────────────┐ │ evlog pipeline │ │ │ │ request lifecycle → enrich → tail sample → drain │ │ │ └──────────────────────────────────────────────────────────────────┘ │ │ │ observe (no mutation) │ ship ▼ ▼ ┌─────────────────────────────────────────┐ ┌─────────────────────┐ │ stream (in-process + SSE bridge) │ │ custom drains │ │ fs reader (NDJSON history) │ │ drain pipeline │ │ diagnostics channel (evlog.event) │ │ (batch + fanout) │ │ consumer recipes (devtools, dashboards)│ │ │ └─────────────────────────────────────────┘ └─────────────────────┘ ``` ## Three ways to extend ### Observe the pipeline (no mutation) Subscribe to events without changing what gets emitted. The stream is the live feed, the fs reader is the historic log, the diagnostics channel lets a consumer subscribe by channel name alone, and consumer recipes show you how to wire any of them to a devtool, dashboard, CLI tail, or `curl` + `jq`. | You want to… | Use | | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | | Subscribe to live events (in-process or over SSE for browsers / CLIs) | [Stream](https://www.evlog.dev/extend/stream) | | Replay or tail historic events from disk | [FS reader](https://www.evlog.dev/extend/fs-reader) | | Build a small consumer panel, devtool, or pipe to `curl` + `jq` | [Consumer recipes](https://www.evlog.dev/extend/consumer-recipes) | | Let a consumer subscribe without importing evlog, or reach a Cloudflare Tail Worker | [Diagnostics channel](https://www.evlog.dev/extend/diagnostics-channel) | ### Plug into the pipeline Hook into the pipeline at one or more of its lifecycle stages. The four pages below cover the entire extension surface — pick one when you have a single concern, pick `definePlugin` when you have several. | You want to… | Use | | ------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | | React to lifecycle events from a single cohesive object (multiple hooks share state) | [Plugins](https://www.evlog.dev/extend/plugins) | | Add a derived field on every event automatically | [Custom enrichers](https://www.evlog.dev/extend/custom-enrichers) | | Decide post-hoc whether to keep an event (status, duration, custom logic) | [Tail sampling](https://www.evlog.dev/extend/tail-sampling) | | Identify your evlog traffic on the receiver side (override `User-Agent` / `X-Evlog-Source`) | [Identity headers](https://www.evlog.dev/extend/identity-headers) | ### Build your own bricks When the built-in adapters or framework integrations don't cover what you need, the toolkit lets you build your own with the same shape and ergonomics. | You want to… | Use | | --------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | | Ship events to a backend without a built-in adapter (HTTP or any other transport) | [Custom drains](https://www.evlog.dev/extend/custom-drains) | | Wrap any drain in batch + retry + fanout for production | [Drain pipeline](https://www.evlog.dev/extend/drain-pipeline) | | Support a framework that's not in the list, or a non-HTTP runtime | [Custom framework integration](https://www.evlog.dev/extend/custom-framework) | ## What works where Only the [stream](https://www.evlog.dev/extend/stream) (in-process bus + SSE bridge) is **local-only** — it lives inside one Node / Bun / Deno process and does not work on serverless platforms (Vercel Functions, Cloudflare Workers, AWS Lambda). The [stream page](https://www.evlog.dev/extend/stream) explains the constraint in detail. Everything else — [FS reader](https://www.evlog.dev/extend/fs-reader), [plugins](https://www.evlog.dev/extend/plugins), [custom enrichers](https://www.evlog.dev/extend/custom-enrichers), [tail sampling](https://www.evlog.dev/extend/tail-sampling), [identity headers](https://www.evlog.dev/extend/identity-headers), [custom drains](https://www.evlog.dev/extend/custom-drains), [drain pipeline](https://www.evlog.dev/extend/drain-pipeline), [custom framework integration](https://www.evlog.dev/extend/custom-framework) — runs everywhere evlog runs. # Stream evlog ships a **stream primitive** so any local consumer can subscribe to wide events without re-implementing a drain. There are two layers, building on each other: - **In-process bus** — `createStreamDrain()`, the canonical pub/sub. Sync listeners, async iterators, ring-buffered replay. - **Network bridge** — `startStreamServer()`, an opt-in HTTP mini-server that exposes the in-process bus over Server-Sent Events for browsers, CLIs, or external devtools. ::callout{icon="i-lucide-shield"} **Local-only by design.** Both layers live inside a single Node / Bun / Deno process. They work in `pnpm dev` , on long-lived self-hosted servers, on VMs and containers (Fly, Railway, Coolify…). They do **not** work on serverless platforms (Vercel Functions, Cloudflare Workers, AWS Lambda) — each invocation is an isolated process. Use a real broker (Redis Streams, NATS, Pub/Sub) for cross-instance fan-out there. :: ## In-process bus :stream-bus `createStreamDrain()` is just a drain. Register it on the evlog drain hook and subscribe to events as they're emitted — no HTTP, no serialization, no extra hops. ::prompt --- actions: - copy - cursor - claude description: Subscribe to wide events in-process icon: i-lucide-radio-tower --- Wire an in-process subscriber on top of evlog's stream drain. - Import `createStreamDrain` from `evlog/stream` and call it once at app boot - Register the returned `drain` on the evlog drain hook for my framework (Nitro: `nitroApp.hooks.hook('evlog:drain', stream.drain)`; Next/standalone: pass to `initLogger({ drain })`) - Subscribe with `stream.subscribe((event) => ...)` for sync listeners or `for await (const event of stream.events())` for async iteration - Seed history for late subscribers with `stream.recent()` (snapshot of the ring buffer) before opening the live iterator - Tune `buffer` for replay history and `perSubscriberQueue` for slow-consumer backpressure - Skip on serverless platforms — the stream is in-process, isolated invocations won't share it Docs: {rel=""nofollow""} :: ```ts import { createStreamDrain } from 'evlog/stream' const stream = createStreamDrain({ buffer: 200 }) nitroApp.hooks.hook('evlog:drain', stream.drain) const off = stream.subscribe((event) => { if (event.level === 'error') notify(event) }) off() for (const past of stream.recent()) { bootstrap(past) } for await (const event of stream.events()) { bootstrap(event) } ``` ### Options | Option | Type | Description | | -------------------- | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | | `buffer` | `number` | Ring-buffer size for `recent()` snapshots. Set to `0` to disable. Default: `500`. | | `perSubscriberQueue` | `number` | Max queued events per `events()` async iterator before the oldest are dropped. The drain itself is never blocked. Default: `1000`. | | `filter` | `(event) => boolean` | Optional predicate run on each drained event — return `false` to skip the event entirely (neither buffered nor delivered). | `stream.drain(events)` accepts either a single event or a batch. `stream.recent()` returns a snapshot of the buffered events (oldest first, most recent last) for ad-hoc inspection or to seed a UI panel. ## Network bridge — stream server :sse-wire `startStreamServer()` boots a tiny `node:http` server in the same process as your app, on its own ephemeral port, and exposes the in-process bus over Server-Sent Events. Any consumer (browser tab, CLI, Tauri/Electron devtool) can subscribe — your application API is untouched. ::callout{icon="i-lucide-shield"} **Strict opt-in.** Nothing starts unless you set the option explicitly. There is no auto-enable in dev — the server only boots when you ask for it. :: ::prompt --- actions: - copy - cursor - claude description: Expose the evlog stream over SSE icon: i-lucide-radio --- Turn on the local stream server so I can subscribe to wide events from a browser tab, CLI, or Tauri/Electron devtool. - Detect my framework and opt in explicitly (Nuxt: `evlog.stream: true` in `nuxt.config.ts`; Next.js: `defineStreamedInstrumentation({ stream: true })` in `instrumentation.ts`; Hono/Express/Fastify/Elysia/standalone: call `startStreamServer()` once at boot and register the returned `drain` on the evlog drain hook) - Never enable in production by default; gate it behind `process.env.NODE_ENV !== 'production'` or a feature flag - For shared dev environments, set `token: process.env.EVLOG_STREAM_TOKEN` and have the consumer send it as `Authorization: Bearer ` on every request - Discover the URL from `.evlog/stream.url` (or `/api/_evlog/stream-info` on Nuxt) — never hard-code the port, it's ephemeral - Skip on serverless platforms — the server is in-process Docs: {rel=""nofollow""} :: ### What boots up When you opt in, evlog calls `startStreamServer()` and: 1. Picks an ephemeral free port (or the one you specify) 2. Spins a tiny `node:http` server on `127.0.0.1` (or your host) 3. Writes the discovered URL to `.evlog/stream.url` 4. Prints a one-line banner in the server console 5. Hooks the in-process stream drain into the evlog pipeline 6. Exposes `GET /` for the SSE stream and `GET /info` for handshake metadata On shutdown (SIGINT / SIGTERM / process exit) the server cleans up listeners and removes the URL file. ### Per-framework opt-in ::code-group ```ts [nuxt.config.ts] export default defineNuxtConfig({ modules: ['evlog/nuxt'], evlog: { stream: true, // or: stream: { port: 4444, token: process.env.EVLOG_STREAM_TOKEN } }, }) ``` ```ts [instrumentation.ts] import { defineStreamedInstrumentation } from 'evlog/next/stream' export const { register } = defineStreamedInstrumentation({ stream: true, }) ``` ```ts [Hono / Express / Fastify / standalone] import { startStreamServer } from 'evlog/stream' if (process.env.NODE_ENV !== 'production' && process.env.EVLOG_STREAM === '1') { const { drain } = await startStreamServer() // Plug `drain` into the evlog drain hook for your framework } ``` :: The Hono / Express / Fastify integrations don't need a "feature PR" — `startStreamServer()` is **orthogonal** to your framework. You boot it once and connect its `drain` to the evlog pipeline like any other drain. ### Discovery The mini-server runs on a random port, so any consumer must discover it. ```ts [.evlog/stream.url] http://127.0.0.1:53942 ``` Read directly from disk: ```ts import { readFile } from 'node:fs/promises' const url = (await readFile('.evlog/stream.url', 'utf-8')).trim() ``` Or — for same-origin browser tabs in a Nuxt app — hit the discovery route: ```ts const { url } = await fetch('/api/_evlog/stream-info').then(r => r.json()) ``` ### Wire format Every SSE message has the shape `{ evlog: '1', type, data }`: | `type` | When | `data` | | -------- | ------------------------------------------------------------------------------------ | ------------------------------------------- | | `hello` | First frame after connect | `{ evlogVersion, bufferSize, heartbeatMs }` | | `replay` | Right after `hello`, replays buffered events when the consumer passed `?since=` | `WideEvent` | | `event` | Every emitted event after that | `WideEvent` | | `ping` | Heartbeat (default every 15s, configurable via `heartbeatMs`) | `{ ts: number }` | ```ts [Browser consumer] const { url } = await fetch('/api/_evlog/stream-info').then(r => r.json()) const es = new EventSource(url) es.onmessage = (msg) => { const { type, data } = JSON.parse(msg.data) if (type === 'event') events.push(data) } ``` ### Auth + CORS | Option | Behavior | | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `token` | When set, the server requires `Authorization: Bearer ` on every request and 401s otherwise. | | (no token) | Connections are accepted only when there is no `Origin` header or the origin host is local (`127.0.0.1` / `localhost` / `[::1]`). Other origins receive 403. | | `host` | Default `127.0.0.1` — never reachable from the LAN. Override to `0.0.0.0` only with a token set. | | `heartbeatMs` | Heartbeat interval (default `15000`). | | `buffer` | Ring buffer kept on the underlying default stream — replayed for late-joining clients via `?since=`. Default `500`. | ## Going further - [FS reader](https://www.evlog.dev/extend/fs-reader) — replay or tail historic NDJSON files (cross-process, survives restarts) - [Consumer recipes](https://www.evlog.dev/extend/consumer-recipes) — build a minimal devtool, pipe to curl + jq, replay history then go live # Custom Framework Integration When the framework you use doesn't have an `evlog/` package yet, you build the integration yourself. `evlog/toolkit` ships the same building blocks that power every built-in integration (Hono, Express, Fastify, Elysia, NestJS, SvelteKit) — you only write the framework-specific glue. The mental model is always the same: **request lifecycle → logger creation → enrich → drain**. The toolkit handles the request-context plumbing. ::callout{color="warning" icon="i-lucide-flask-conical"} The toolkit API is marked as **beta** . The surface is stable (used by all built-in integrations) but may evolve based on community feedback. :: | Surface | What it does | When to use | | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | | [`defineFrameworkIntegration()`](https://www.evlog.dev/#manifest-mode-recommended) | Declaratively wire request extraction + logger attachment | HTTP frameworks with a `(ctx, next)` middleware shape (Hono, Express, Fastify, Elysia, NestJS-shaped) | | [`createMiddlewareLogger()`](https://www.evlog.dev/#custom-mode) | Imperative path: create the logger at request start, emit on response end | Frameworks whose lifecycle doesn't fit `(ctx, next)` (NestJS interceptors, Next.js App Router, SvelteKit `handle`) | | [`createRequestLogger()`](https://www.evlog.dev/#non-http-runtimes) | Wrap any unit of work in a logger lifecycle | Non-HTTP runtimes (queue workers, CLI, cron, durable workflows) | ::prompt --- actions: - copy - cursor - claude description: Build an evlog integration for a custom framework icon: i-lucide-puzzle --- Wire evlog into an HTTP framework (or non-HTTP runtime) that doesn't have a built-in integration. - For HTTP frameworks with `(ctx, next)`, use `defineFrameworkIntegration` from `evlog/toolkit` — declare `extractRequest(ctx)` returning `{ method, path, headers, requestId? }`, `attachLogger(ctx, logger)`, and an optional storage from `createLoggerStorage()` (prefer `evlog/toolkit/storage` on Workers / edge) - Headers may be either Web `Headers` or Node `IncomingHttpHeaders` — `defineFrameworkIntegration` normalizes both - In your middleware, call `integration.start(ctx, options)` which returns `{ skipped, finish, runWith, logger, middlewareOptions }` - If `skipped` is `true`, skip directly to `next` - Run downstream handlers inside `runWith(() => next())` so `AsyncLocalStorage` and `log.fork()` work - On success: `await finish({ status })`; on error: `await finish({ error })` then re-throw - Expose `drain`, `enrich`, `keep`, `include`, `exclude`, `routes`, and `plugins` options - On Cloudflare Workers / Vercel Edge, pass `waitUntil` (or `extractWaitUntil` on the manifest) so async drains complete after the response - For non-HTTP runtimes (queue workers, CLI, cron), use `createRequestLogger` from `evlog/toolkit` directly — wrap each unit of work in a logger lifecycle Docs: {rel=""nofollow""} :: ## Install ::code-group ```bash [pnpm] pnpm add evlog ``` ```bash [bun] bun add evlog ``` ```bash [yarn] yarn add evlog ``` ```bash [npm] npm install evlog ``` :: ## What's in the toolkit | Export | Purpose | | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `defineFrameworkIntegration(spec)` | Manifest factory — extract request, create logger, attach, run with ALS | | `createMiddlewareLogger(opts)` | Lower-level lifecycle (custom mode) | | `waitUntil` on middleware options | Defer drain on Cloudflare Workers / Vercel Edge (see [Serverless](https://www.evlog.dev/#serverless-workers-edge)) | | `createRequestLogger(opts)` | Wrap a non-HTTP unit of work in a logger lifecycle | | `BaseEvlogOptions` | Base user-facing options — `drain`, `enrich`, `keep`, `include`, `exclude`, `routes`, `plugins` | | `MiddlewareLoggerResult` | Return type: `{ logger, finish, skipped }` | | `extractSafeHeaders(headers)` | Filter sensitive headers from a Web API `Headers` object | | `extractSafeNodeHeaders(headers)` | Filter sensitive headers from Node.js `IncomingHttpHeaders` | | `createLoggerStorage(hint)` | Factory returning `{ storage, useLogger }` backed by `AsyncLocalStorage` — also on `evlog/toolkit/storage` (prefer that entry on Workers / edge to isolate `node:async_hooks`) | | `attachForkToLogger(storage, parent, opts)` | Wires `log.fork(label, fn)` onto the request logger so consumers can spawn correlated background work — used by manifest mode automatically; call manually in custom mode after `createMiddlewareLogger` returns the logger and before the lifecycle finishes | | `defineEvlog(config)` | Canonical config object — works for `initLogger` and middleware options | | `definePlugin(plugin)` | Plugin contract — opt into any subset of `setup`, `enrich`, `drain`, `keep`, `onRequestStart`, `onRequestFinish`, `onClientLog`, `extendLogger` | | `composeEnrichers / composeDrains / composeKeep / composePlugins` | Combine multiple extensions into one | Types like `RequestLogger`, `DrainContext`, `EnrichContext`, `WideEvent`, and `TailSamplingContext` are exported from the main `evlog` package. ## Manifest mode (recommended) Most frameworks fit a `(ctx, next)` middleware shape. For those, write a manifest describing how to extract the request and attach the logger — `defineFrameworkIntegration` does the rest. ::code-collapse ```typescript [my-framework-evlog.ts] import type { IncomingMessage, ServerResponse } from 'node:http' import { createLoggerStorage, defineFrameworkIntegration, type BaseEvlogOptions, } from 'evlog/toolkit' // On Workers / edge, prefer: import { createLoggerStorage } from 'evlog/toolkit/storage' import type { RequestLogger } from 'evlog' export type MyFrameworkEvlogOptions = BaseEvlogOptions const { storage, useLogger } = createLoggerStorage( 'Cannot access logger outside of middleware context. Make sure evlog middleware is registered before your routes.', ) export { useLogger } const integration = defineFrameworkIntegration({ name: 'my-framework', extractRequest: (req) => ({ method: req.method || 'GET', path: req.url || '/', headers: req.headers, requestId: typeof req.headers['x-request-id'] === 'string' ? req.headers['x-request-id'] : undefined, }), attachLogger: (req, logger) => { (req as IncomingMessage & { log: RequestLogger }).log = logger }, storage, }) export function evlog(options: MyFrameworkEvlogOptions = {}) { return async (req: IncomingMessage, res: ServerResponse, next: () => Promise) => { const { skipped, finish, runWith } = integration.start(req, options) if (skipped) { await next() return } try { await runWith(() => next()) await finish({ status: res.statusCode }) } catch (error) { await finish({ error: error as Error }) throw error } } } ``` :: That's it. This middleware gets every feature for free: route filtering, drain adapters, enrichers, tail sampling, error capture, plugin lifecycle hooks, `log.fork()`, and duration tracking. ### What `defineFrameworkIntegration` does Given the manifest above, the helper: 1. Normalizes headers (auto-detects `Headers` vs `IncomingHttpHeaders`). 2. Generates a `requestId` if `extractRequest` doesn't return one. 3. Calls `createMiddlewareLogger` with the merged options. 4. Calls `attachLogger(ctx, logger)`. 5. Attaches `log.fork()` to the logger when `storage` is provided (so users can spawn correlated background work). 6. Exposes `runWith(fn)` — runs `fn()` inside `storage.run(logger, …)` if storage is configured, otherwise just calls `fn()`. You're left with only the framework-specific glue: where to read the request from, where to attach the logger, and how to compute the response status. ## Custom mode If your framework's lifecycle doesn't fit a clean `(ctx, next)` shape (NestJS interceptors, Next.js App Router, SvelteKit `handle`), drop one level lower and call `createMiddlewareLogger` directly: ```typescript import { createMiddlewareLogger, extractSafeNodeHeaders } from 'evlog/toolkit' const { logger, finish, skipped } = createMiddlewareLogger({ method, path, requestId, headers: extractSafeNodeHeaders(rawHeaders), ...options, }) ``` You'll be responsible for ALS wrapping (`storage.run`), `log.fork()` attachment (via `attachForkToLogger`), and finishing the lifecycle — but you keep the full pipeline (route filtering, sampling, emit, enrich, drain, plugins) for free. ## Serverless (Workers / Edge) On Cloudflare Workers and Vercel Edge, the runtime can terminate as soon as the response is returned. If your drain sends HTTP to an observability backend, pass `waitUntil` so enrich still runs inline but drain work survives after the response — the same behavior as [`evlog/workers`](https://www.evlog.dev/integrate/frameworks/cloudflare-workers) and the Nitro plugin. **Custom mode** — pass `waitUntil` per request: ```typescript import { waitUntil } from '@vercel/functions' // import { waitUntil } from 'cloudflare:workers' // Vercel-style global on some runtimes const { logger, finish, skipped } = createMiddlewareLogger({ method, path, requestId, headers: extractSafeNodeHeaders(rawHeaders), waitUntil, // or ctx.waitUntil.bind(ctx) on Cloudflare ...options, }) ``` **Manifest mode** — either pass `waitUntil` in `integration.start(ctx, options)` or declare `extractWaitUntil` on the manifest when the hook lives on the framework context: ```typescript const integration = defineFrameworkIntegration({ name: 'my-framework', extractRequest: (ctx) => ({ /* … */ }), attachLogger: (ctx, logger) => { /* … */ }, extractWaitUntil: ctx => ctx.executionCtx.waitUntil.bind(ctx.executionCtx), }) export function evlog(options: BaseEvlogOptions = {}) { return async (ctx, next) => { const { skipped, finish, runWith } = integration.start(ctx, options) // Per-request override still works: // integration.start(ctx, { ...options, waitUntil: ctx.executionCtx.waitUntil.bind(ctx.executionCtx) }) // … } } ``` Per-request `options.waitUntil` takes precedence over `extractWaitUntil`. Without either, drains are awaited (correct for traditional Node.js servers). ## Non-HTTP runtimes For queue workers, CLI drivers, cron jobs, or durable execution engines, skip the HTTP-shaped helpers and use `createRequestLogger` from `evlog/toolkit` directly: ```ts import { createRequestLogger } from 'evlog/toolkit' async function processJob(job: Job) { const logger = createRequestLogger({ service: 'jobs', context: { jobId: job.id, queue: job.queue }, }) try { await runJob(job) logger.set({ status: 'success' }) } catch (err) { logger.error(err) throw err } finally { await logger.emit() } } ``` Same enrichers, same drain hook, same [identity headers](https://www.evlog.dev/extend/identity-headers) on outbound HTTP drain requests — only the entry point shape changes. ## Reference implementations Study these built-in integrations for framework-specific patterns: | Framework | Lines | Mode | Source | | --------- | ----- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | | Hono | \~50 | manifest | [hono/index.ts](https://github.com/hugorcd/evlog/blob/main/packages/evlog/src/hono/index.ts){rel=""nofollow""} | | Express | \~50 | manifest + ALS | [express/index.ts](https://github.com/hugorcd/evlog/blob/main/packages/evlog/src/express/index.ts){rel=""nofollow""} | | Fastify | \~70 | manifest + Fastify hooks | [fastify/index.ts](https://github.com/hugorcd/evlog/blob/main/packages/evlog/src/fastify/index.ts){rel=""nofollow""} | | Elysia | \~80 | manifest + custom ALS scoping | [elysia/index.ts](https://github.com/hugorcd/evlog/blob/main/packages/evlog/src/elysia/index.ts){rel=""nofollow""} | | NestJS | \~120 | custom (interceptor) | [nestjs/](https://github.com/hugorcd/evlog/blob/main/packages/evlog/src/nestjs/){rel=""nofollow""} | | SvelteKit | \~90 | custom (`handle` hook) | [sveltekit/](https://github.com/hugorcd/evlog/blob/main/packages/evlog/src/sveltekit/){rel=""nofollow""} | ::callout{color="neutral" icon="i-lucide-heart"} Built an integration for a framework we don't support? [Open a PR](https://github.com/hugorcd/evlog/pulls){rel=""nofollow""} — the community will thank you. :: ## Next steps - [Custom Drains](https://www.evlog.dev/extend/custom-drains) — same toolkit shape for drain destinations - [Custom Enrichers](https://www.evlog.dev/extend/custom-enrichers) — same toolkit shape for derived event fields - [Plugins](https://www.evlog.dev/extend/plugins) — multi-hook extensions (drain + enrich + keep in one object) - [Wide Events](https://www.evlog.dev/learn/wide-events) — design comprehensive events with context layering - [Sampling](https://www.evlog.dev/learn/sampling) — control log volume with head and tail sampling - [Adapters](https://www.evlog.dev/integrate/adapters/overview) — send logs to Axiom, Sentry, PostHog, and more # Diagnostics Channel [`node:diagnostics_channel`](https://nodejs.org/api/diagnostics_channel.html){rel=""nofollow""} is the runtime's built-in pub/sub for instrumentation. evlog can publish every wide event on the `evlog.event` channel, so a consumer subscribes by channel name alone — no evlog import, no entry in `initLogger()`. ## What it's for Two things you cannot do with a [plugin](https://www.evlog.dev/extend/plugins) or a [drain](https://www.evlog.dev/extend/custom-drains): - **Ship an evlog integration from a package that does not depend on evlog.** A subscriber only needs the channel name, so a vendor SDK, an internal shared library, or an APM agent can consume wide events without a peer dependency, a version constraint, or a line in your `initLogger()` call. - **Get events out of a Cloudflare Worker without a drain.** Workers forwards every channel message to a [Tail Worker](https://www.evlog.dev/#cloudflare-workers), which runs after the response with its own CPU budget — no `waitUntil`, no drain competing with your request. For everything else — batching, retry, adding fields, fanning out to several in-process consumers — a plugin or a drain is the better tool. There is a [comparison at the bottom of this page](https://www.evlog.dev/#when-a-plugin-is-the-better-tool). ## Enabling it It is off by default. Turn it on once, at startup: ```typescript import { enableDiagnosticsChannel } from 'evlog/diagnostics' await enableDiagnosticsChannel() ``` The call takes no options and is the same on every framework — only *where* your app runs startup code differs: ::code-group ```typescript [Nuxt / Nitro] // server/plugins/evlog-diagnostics.ts import { enableDiagnosticsChannel } from 'evlog/diagnostics' export default defineNitroPlugin(async () => { await enableDiagnosticsChannel() }) ``` ```typescript [Next.js] // instrumentation.ts import { defineNodeInstrumentation } from 'evlog/next/instrumentation' export const { register, onRequestError } = defineNodeInstrumentation(async () => { const { createInstrumentation } = await import('evlog/next/instrumentation/create') const { enableDiagnosticsChannel } = await import('evlog/diagnostics') const { register: evlogRegister, onRequestError } = createInstrumentation({ service: 'my-app' }) return { async register() { await evlogRegister() await enableDiagnosticsChannel() }, onRequestError, } }) ``` ```typescript [SvelteKit] // src/hooks.server.ts import { createEvlogHooks } from 'evlog/sveltekit' import { enableDiagnosticsChannel } from 'evlog/diagnostics' await enableDiagnosticsChannel() export const { handle, handleError } = createEvlogHooks() ``` ```typescript [Hono] // Same shape for Express, Fastify, Elysia, NestJS and oRPC: // enable once in the server entry, before the app starts serving. import { Hono } from 'hono' import { evlog } from 'evlog/hono' import { enableDiagnosticsChannel } from 'evlog/diagnostics' await enableDiagnosticsChannel() const app = new Hono() app.use(evlog()) ``` ```typescript [Cloudflare Workers] // src/worker.ts — needs the nodejs_compat flag import { initWorkersLogger, withEvlog } from 'evlog/workers' import { enableDiagnosticsChannel } from 'evlog/diagnostics' initWorkersLogger({ env: { service: 'my-worker' } }) await enableDiagnosticsChannel() export default withEvlog(async (request, env, ctx, log) => { log.set({ action: 'handle_request' }) return Response.json({ ok: true }) }) ``` ```typescript [Standalone] // Any Node, Bun or Deno entry point import { initLogger } from 'evlog' import { enableDiagnosticsChannel } from 'evlog/diagnostics' initLogger({ env: { service: 'worker' } }) await enableDiagnosticsChannel() ``` :: `enableDiagnosticsChannel()` is async because it loads `node:diagnostics_channel` lazily — that is what keeps the built-in out of the main bundle for Convex, workerd and other non-Node targets. Events emitted before the promise settles are not published, so call it at startup rather than inside a request. Frameworks whose entry point cannot use top-level `await` should call it from the same place they call `initLogger()`, and await it there. ::callout{color="info" icon="i-lucide-info"} This is an observation side channel, not a transport. Subscribers run synchronously and are not awaited — no batching, no retry, no `waitUntil` . For delivery to a backend, use a [drain](https://www.evlog.dev/extend/custom-drains) ; both see the same event. :: ## Subscribing The point of the channel is that a consumer needs nothing from evlog but the channel name: ```typescript [metrics.ts] import { channel } from 'node:diagnostics_channel' /** The published message. Declared locally so this file needs no evlog import. */ type EvlogMessage = { event: Record & { level: string; service: string } } channel('evlog.event').subscribe((message) => { const { event } = message as EvlogMessage if (event.level === 'error') metrics.increment('errors', { path: String(event.path ?? 'unknown') }) }) ``` Node types the published message as `unknown`, so narrow it once at the top of your handler. `channel(name).subscribe()` is used rather than the module-level `subscribe()` because it exists on every Node that evlog supports. If you already depend on evlog and want the payload typed: ```typescript [alerts.ts] import { subscribeToWideEvents } from 'evlog/diagnostics' const stop = await subscribeToWideEvents((event) => { // ^? WideEvent if (typeof event.status === 'number' && event.status >= 500) { alerts.push({ path: String(event.path ?? '-'), requestId: String(event.requestId ?? '-') }) } }) ``` Fields beyond the [base event](https://www.evlog.dev/learn/wide-events) are typed `unknown`, and events emitted outside a request carry no HTTP fields at all — narrow before using them rather than casting. ## What a subscriber receives The same object a drain receives: post-audit, post-redaction, post-enrich. Requests carry everything enrichers added — geo, user agent, trace context: ```json { "timestamp": "2026-08-02T10:23:45.612Z", "level": "error", "service": "checkout", "environment": "production", "method": "POST", "path": "/api/checkout", "status": 500, "duration": "1.20s", "requestId": "4a8ff3a8-...", "user": { "id": "usr_123", "plan": "premium" }, "error": { "name": "PaymentDeclined", "message": "Card declined" } } ``` Events emitted outside a request (`log.info({ ... })`, `createLogger().emit()`) arrive without the HTTP fields, and events from `log.fork()` carry `operation` and `_parentRequestId`. ::callout{color="warning" icon="i-lucide-triangle-alert"} The event is the live object, not a copy — mutating it mutates what drains receive. Treat it as read-only. And a subscriber that throws is **not** contained: `Channel.publish()` re-raises it as an uncaught exception on the next tick, which is fatal in most apps. Keep subscribers total. :: In pretty mode (the dev default), tagged logs like `log.info('auth', 'User logged in')` are written straight to the console and never become wide events, so they do not appear on the channel. Wide events themselves are published in both modes. ## What you can build ### An integration that does not depend on evlog A package that subscribes needs the channel name and nothing else — no `evlog` dependency, no peer range to keep in sync, no wiring in the host app beyond importing it: ```typescript [acme-apm/src/evlog.ts] import { channel } from 'node:diagnostics_channel' type EvlogMessage = { event: Record & { timestamp: string; level: string; service: string } } channel('evlog.event').subscribe((message) => { const { event } = message as EvlogMessage acme.ingest({ at: event.timestamp, level: event.level, service: event.service, attributes: event, }) }) ``` ```typescript [the host app] import 'acme-apm/evlog' ``` The same shape works for an internal library shared across services: one package subscribes, every service that imports it reports, and none of them touch `initLogger()`. ### Counters and alerts next to your app A subscriber is a plain function, so anything you can compute in-process you can compute here — without giving up the drain slot, which stays free for shipping events to your backend: ```typescript [observability.ts] import { channel } from 'node:diagnostics_channel' const errorsByPath = new Map() channel('evlog.event').subscribe((message) => { const { event } = message as { event: Record & { level: string } } if (event.level !== 'error') return const path = String(event.path ?? 'unknown') errorsByPath.set(path, (errorsByPath.get(path) ?? 0) + 1) if (typeof event.status === 'number' && event.status >= 500) { void pager.notify(`5xx on ${path}`, { requestId: event.requestId }) } }) export function errorCounts(): Record { return Object.fromEntries(errorsByPath) } ``` A [plugin](https://www.evlog.dev/extend/plugins) does this too, and is the better choice when the code lives in your own app. The channel wins when the subscriber ships as a separate package, or when it has to attach without editing the app's logger configuration. ## Cloudflare Workers Workers forwards every diagnostics channel message to a [Tail Worker](https://developers.cloudflare.com/workers/observability/logs/tail-workers/){rel=""nofollow""}. Enable the channel in your Worker and the wide events leave the isolate with no drain, no `waitUntil`, and their own CPU budget — the Tail Worker does the shipping, after the response has already gone out: ```typescript [tail-worker/index.ts] export default { async tail(events) { for (const event of events) { for (const messageData of event.diagnosticsChannelEvents) { if (messageData.channel !== 'evlog.event') continue await fetch('https://logs.example.com/ingest', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(messageData.message.event), }) } } }, } ``` Each entry carries `timestamp`, `channel` and `message` — `message` being what evlog published, so the wide event is at `messageData.message.event`. Requires the `nodejs_compat` flag, and messages go through the structured clone algorithm, so only cloneable values survive. ## When a plugin is the better tool The channel is not a replacement for [plugins](https://www.evlog.dev/extend/plugins) — it is narrower on purpose: | You want to… | Use | | ------------------------------------------------------ | --------------------------------------------------------------------- | | Ship events to a backend, with batching and retry | [Custom drain](https://www.evlog.dev/extend/custom-drains) | | Add fields to the event before it drains | [Enricher](https://www.evlog.dev/extend/custom-enrichers) or a plugin | | Fan out to several in-process consumers | Plugins — `initLogger({ plugins: [a, b, c] })` already does this | | Subscribe from a package that must not depend on evlog | This channel | | Get events out of a Cloudflare Worker without a drain | This channel | `diagnostics_channel` is in-process: nothing attaches to a running process from the outside. A subscriber's code has to be loaded by your app either way — the channel saves it a line of configuration, not a dependency. # FS reader The [filesystem drain](https://www.evlog.dev/integrate/adapters/self-hosted/fs) writes wide events as NDJSON to `.jsonl` files under `.evlog/logs/` (one file per day, e.g. `2026-05-08.jsonl`, plus rotation suffixes like `.1.jsonl` when size-based rotation is enabled). The `evlog/fs` module also ships **readers** that let any Node tool replay or follow that history without hooking into the running app. :ndjson-tail ::prompt --- actions: - copy - cursor - claude description: Read or tail evlog NDJSON logs from disk icon: i-lucide-folder-search --- Build a script that consumes evlog's local NDJSON history (no app hook required). - Confirm the filesystem drain is wired up (`evlog/fs` adapter writing NDJSON to `.evlog/logs/*.jsonl`) - For replay: import `readFsLogs` from `evlog/fs` and iterate `for await (const event of readFsLogs({ since, until, level, filter }))` - For follow mode: import `tailFsLogs` and iterate the same way — it watches for new lines, handles rotation, and accepts an `AbortSignal` - Apply filters at read time (`level`, `since`, `until`, custom `filter` predicate) instead of post-processing - Treat malformed lines as silently skipped (partial writes happen) — never crash the script on a bad line Docs: {rel=""nofollow""} :: ## Replay history ```ts import { readFsLogs } from 'evlog/fs' for await (const event of readFsLogs({ since: '2026-03-01', level: 'error' })) { console.log(event.timestamp, event.action ?? event.message) } ``` `readFsLogs(options)` walks the NDJSON files in chronological order, parses them line by line, and yields events that pass all filters. Files outside the date window are skipped entirely. ### Options | Option | Type | Description | | -------- | ----------------------- | ----------------------------------------------- | | `dir` | `string` | Directory to read from. Default: `.evlog/logs`. | | `since` | `Date | string` | Yield events with `timestamp >= since`. | | `until` | `Date | string` | Yield events with `timestamp <= until`. | | `level` | `LogLevel | LogLevel[]` | Filter by event level. | | `filter` | `(event) => boolean` | Custom predicate. | Malformed lines (partial writes, manual edits) are silently skipped — your script never crashes on a bad line. ## Live tail ```ts import { tailFsLogs } from 'evlog/fs' const ac = new AbortController() process.on('SIGINT', () => ac.abort()) for await (const event of tailFsLogs({ signal: ac.signal })) { console.log('live:', event.action ?? event.message) } ``` `tailFsLogs(options)` first yields existing events (unless `fromEnd: true`), then keeps yielding new ones as they're appended — including events written into newly created daily files. Partial writes split across polls are recombined transparently. ### Tail-specific options | Option | Type | Description | | ---------------- | ------------- | ------------------------------------------------------------- | | `pollIntervalMs` | `number` | Polling interval. Default: 500ms (minimum 50ms). | | `fromEnd` | `boolean` | Skip existing events; only yield future ones. Default: false. | | `signal` | `AbortSignal` | Stop tailing when aborted. | All [`readFsLogs`](https://www.evlog.dev/#options) options also apply. ## Use cases - A local Electron / Tauri dashboard reading `.evlog/logs/` from a target project directory - A CI report aggregator that scans logs after a test run - A `grep`-style CLI that pipes filtered events into `jq` - Replaying historic events into a dashboard before switching to a live in-process subscription. See the [replay-then-live recipe](https://www.evlog.dev/extend/consumer-recipes#3-replay-history-then-go-live) # Recipes Real-world patterns that combine the [in-process bus and stream server](https://www.evlog.dev/extend/stream) with the [filesystem reader](https://www.evlog.dev/extend/fs-reader). ::prompt --- actions: - copy - cursor - claude description: Build a custom evlog devtool / dashboard icon: i-lucide-chef-hat --- Bootstrap a local devtool or dashboard that consumes evlog wide events. - Pick the source: live (stream server over SSE) or history (`readFsLogs` from `.evlog/logs`) or both (replay then live tail) - For SSE: discover the URL via `.evlog/stream.url` or `GET /api/_evlog/stream-info`, never hard-code the port - Open an `EventSource` and decode messages as `{ evlog: '1', type, data }` envelopes (`type` is `hello | event | replay | ping`) - For browser tabs running on a different origin from the dev server, configure CORS via the stream server `cors` option and forward credentials carefully - Aggregate on the consumer side (counts, latency histograms, error groups) — keep the server simple - Skip on serverless platforms — the stream is in-process Docs: {rel=""nofollow""} :: ## 1. Build a minimal devtool A live event panel is essentially `EventSource` + a list. The full wire format and discovery rules — `.evlog/stream.url`, `/api/_evlog/stream-info`, the `{ evlog: '1', type, data }` envelope, and auth — are documented on the [stream page](https://www.evlog.dev/extend/stream#wire-format). Each recipe below assumes you've grabbed the URL via either of those mechanisms. ### Vanilla HTML + JS (drop into any page) ::code-collapse ```html evlog mini devtool
timelevelserviceaction
``` :: Save as `devtool.html`, open in any browser tab while your evlog-instrumented dev server is running. That's the whole MVP. ### Vue 3 component ::code-collapse ```vue ``` :: ### React hook ```ts import { useEffect, useState } from 'react' import type { WideEvent } from 'evlog' export function useEvlogStream(url: string) { const [events, setEvents] = useState([]) useEffect(() => { if (!url) return const es = new EventSource(url) es.onmessage = (e) => { const env = JSON.parse(e.data) if (env.evlog !== '1') return if (env.type === 'event' || env.type === 'replay') { setEvents(prev => [env.data, ...prev].slice(0, 500)) } } return () => es.close() }, [url]) return events } ``` That's the entire integration surface. No SDK, no special types beyond `WideEvent` exported from `evlog`. ## 2. Quick CLI inspection with curl + jq The URL is in `.evlog/stream.url`: ```bash URL=$(cat .evlog/stream.url) curl -N "$URL" | jq -c 'select(.type == "event") | .data' ``` Filter on the client side as needed: ```bash # Only errors curl -sN "$URL" | jq -c 'select(.type == "event" and .data.level == "error") | .data' # Only one service curl -sN "$URL" | jq -c 'select(.type == "event" and .data.service == "checkout") | .data' # Slow requests curl -sN "$URL" | jq -c 'select(.type == "event" and .data.durationMs > 500) | .data' ``` `-N` keeps `curl` in streaming mode (no buffering). `-s` is silent. ## 3. Replay history then go live History on disk (filesystem drain) + live updates from the stream server = a full picture from any point in time. ```ts import { readFsLogs } from 'evlog/fs' import { readFile } from 'node:fs/promises' import type { WideEvent } from 'evlog' async function bootstrap(handle: (e: WideEvent) => void) { // 1. Replay the last hour from `.evlog/logs/` const since = new Date(Date.now() - 60 * 60 * 1000) for await (const event of readFsLogs({ since })) { handle(event) } // 2. Switch to the live SSE stream const url = (await readFile('.evlog/stream.url', 'utf-8')).trim() const es = new EventSource(url) es.onmessage = (e) => { const env = JSON.parse(e.data) if (env.evlog !== '1') return if (env.type === 'event' || env.type === 'replay') { handle(env.data) } } return () => es.close() } ``` `readFsLogs` skips files outside the date range, so the replay step is fast even if you keep weeks of history. For a tail-only mode without on-disk replay, hit the stream server with `?since=` to reuse the in-process ring buffer instead. ## 4. Node / Bun client (fetch + ReadableStream) Same protocol, no `EventSource` polyfill needed: ```ts import { readFile } from 'node:fs/promises' const url = (await readFile('.evlog/stream.url', 'utf-8')).trim() const res = await fetch(url) const reader = res.body!.getReader() const decoder = new TextDecoder() let buffer = '' while (true) { const { value, done } = await reader.read() if (done) break buffer += decoder.decode(value, { stream: true }) let idx while ((idx = buffer.indexOf('\n\n')) !== -1) { const frame = buffer.slice(0, idx) buffer = buffer.slice(idx + 2) const dataLine = frame.split('\n').find(l => l.startsWith('data:')) if (!dataLine) continue const env = JSON.parse(dataLine.slice(5).trim()) if (env.type === 'event') console.log(env.data) } } ``` ## 5. Filter, transform, aggregate on the consumer Keep the server dumb — every consumer picks what it cares about: ```ts // Just errors const errors = events.filter(e => e.level === 'error') // Slow requests const slowReqs = events.filter(e => (e.durationMs ?? 0) > 500) // Group by service const byService = Object.groupBy(events, e => e.service) // Rolling error rate (last 100 events) const last100 = events.slice(0, 100) const errorRate = last100.filter(e => e.level === 'error').length / last100.length // Ad-hoc cost analytics — works because evlog/ai writes ai.* fields on every AI call const totalCost = events .filter(e => typeof e.ai?.estimatedCost === 'number') .reduce((sum, e) => sum + (e.ai?.estimatedCost as number), 0) ``` ## 6. Self-hosted "tail -f" replacement Skip the network entirely if the consumer runs on the same machine: ```ts import { tailFsLogs } from 'evlog/fs' const ac = new AbortController() process.on('SIGINT', () => ac.abort()) for await (const event of tailFsLogs({ signal: ac.signal })) { if (event.level === 'error') notifyOps(event) } ``` Works without instrumenting the running app — useful for sidecar / observer processes that watch a directory. ## What not to do - **Don't run the stream server on Vercel Functions / Cloudflare Workers / Lambda.** Each invocation is a separate isolate; subscribers in one isolate never see events emitted by other isolates. Use a real broker (Redis Streams, NATS, Pub/Sub) for cross-instance fan-out. - **Don't put auth-sensitive data in wide events** unless your evlog config redacts them. The server relays exactly what your app emitted — including any unredacted PII. - **Don't filter at the server** ("only error events please"). The server is purpose-built to be transparent. Filter on the consumer side; that way one filter doesn't starve another consumer. # Plugins :lifecycle-flow `definePlugin()` is the **canonical extension point** for evlog. Drains and enrichers are special cases of plugins, but a single plugin can opt into multiple hooks at once — the right shape for any non-trivial extension that mixes several concerns (e.g. enrich on every event + side-effect on drain + keep decision on tail sampling, all reading the same shared state). When the extension only does one thing, prefer the single-purpose [`enricherPlugin()`](https://www.evlog.dev/extend/custom-enrichers) / [`drainPlugin()`](https://www.evlog.dev/extend/custom-drains) wrappers. Reach for `definePlugin` when several hooks share state. ::prompt --- actions: - copy - cursor - claude description: Build a multi-hook evlog plugin icon: i-lucide-blocks --- Build an evlog plugin that hooks into more than one lifecycle stage. - Import `definePlugin` from `evlog` and pick the hooks I need (`onRequestStart`, `enrich`, `drain`, `extendLogger`, `keep`, `onClientLog`, `onRequestFinish`) - Keep `enrich` pure (no I/O, no throwing — use try/catch internally) - For drains, batch and ship to the destination; respect backpressure if the sink is slow - Register the plugin via the framework config: Next/standalone `initLogger({ plugins: [myPlugin] })`, Hono/Express/Fastify/Elysia middleware option `{ plugins: [myPlugin] }`. For Nitro, register the individual hooks directly (`nitroApp.hooks.hook('evlog:enrich' | 'evlog:drain' | …)`) - Prefer single-purpose `enricherPlugin()` / `drainPlugin()` wrappers for simple extensions; use `definePlugin` only when several hooks are needed Docs: {rel=""nofollow""} :: ## Minimal example ```ts import { definePlugin } from 'evlog' export const tenantPlugin = definePlugin({ name: 'tenant', onRequestStart({ logger, headers }) { const tenantId = headers?.['x-tenant-id'] if (tenantId) logger.set({ tenant: { id: tenantId } }) }, enrich({ event }) { event.region = process.env.REGION }, }) ``` Register the plugin where you bootstrap evlog. The shape depends on your runtime: ::code-group ```ts [Next.js / standalone] import { initLogger } from 'evlog' import { tenantPlugin } from './plugins/tenant' initLogger({ plugins: [tenantPlugin] }) ``` ```ts [Hono / Express / Fastify / Elysia] import { evlogMiddleware } from 'evlog/' import { tenantPlugin } from './plugins/tenant' app.use(evlogMiddleware({ plugins: [tenantPlugin] })) ``` ```ts [Nitro] // Register the hooks you actually use directly: nitroApp.hooks.hook('evlog:enrich', tenantPlugin.enrich!) nitroApp.hooks.hook('evlog:request:start', tenantPlugin.onRequestStart!) ``` :: ## Hooks | Hook | When | Use it for | | ---------------------- | ------------------------------------------------ | ------------------------------------------------------------------ | | `setup(ctx)` | Once when registered | Read `env`, set up shared state | | `onRequestStart(ctx)` | Each request, before any handler runs | Pull values from headers into `logger` | | `enrich(ctx)` | Every event, before drain | Add derived fields (geo, deploy id…) | | `keep(ctx)` | Tail sampling decision | Force-keep based on outcome (`status >= 400`, `duration > 500`, …) | | `drain(ctx)` | Every emitted event | Side-effect: alert, mirror to a queue, etc. | | `onRequestFinish(ctx)` | After response | Per-request post-processing | | `onClientLog(ctx)` | Browser-submitted event hits the ingest endpoint | Observe / reject client traffic | | `extendLogger(logger)` | Each request | Add custom methods (e.g. `logger.audit.refund()`) | Every hook is **optional**. A plugin can implement any subset. The full type lives in [`packages/evlog/src/shared/plugin.ts`](https://github.com/HugoRCD/evlog/blob/main/packages/evlog/src/shared/plugin.ts){rel=""nofollow""}. ## A multi-hook example Plugins shine when several concerns share state. Here, a single `request-metrics` plugin tracks per-request timing through `setup`, `onRequestStart`, and `drain`: ```ts import { definePlugin } from 'evlog/toolkit' export const requestMetricsPlugin = definePlugin({ name: 'request-metrics', setup({ env }) { statsd.init({ service: env.service }) }, enrich({ event }) { if (event.durationMs !== undefined) event.tier = event.durationMs > 1000 ? 'slow' : 'fast' }, drain({ event }) { if (event.durationMs !== undefined) { statsd.timing('http.request', event.durationMs, { path: event.path as string }) } }, onRequestStart({ logger, request }) { logger.set({ trace: { startedAt: Date.now() } }) }, onRequestFinish({ event, durationMs }) { if (event && (event.level === 'error' || durationMs > 5000)) { // alert / forward / etc. } }, }) ``` ## Sugar plugins For single-hook extensions, the toolkit offers `drainPlugin()` and `enricherPlugin()` wrappers: ```typescript import { drainPlugin, enricherPlugin } from 'evlog/toolkit' const drainOnly = drainPlugin('axiom', createAxiomDrain()) const enricherOnly = enricherPlugin('user-agent', createUserAgentEnricher()) ``` These are equivalent to a `definePlugin({ name, drain | enrich })` shape but read more clearly when intent is obvious. ## Common pitfalls - **Don't throw from a hook.** The plugin runner catches and logs errors with the plugin name, but a thrown error from `enrich` won't propagate the event downstream. Keep hooks defensive. - **`drain` runs for every event** — not just per-request. If you only care about per-request lifecycle, use `onRequestFinish` instead. - **`extendLogger` mutates the logger object** — augment `RequestLogger` in a `.d.ts` so `useLogger(event)` exposes the new methods to TypeScript. See [typed fields](https://www.evlog.dev/learn/typed-fields). - **Plugins are de-duplicated by `name`**. Re-registering with the same `name` replaces the previous version (last registration wins). ## Next steps - [Custom Enrichers](https://www.evlog.dev/extend/custom-enrichers) — single-hook enrichment - [Custom Drains](https://www.evlog.dev/extend/custom-drains) — single-destination output - [Tail Sampling](https://www.evlog.dev/extend/tail-sampling) — outcome-aware keep decisions - [Identity Headers](https://www.evlog.dev/extend/identity-headers) — tag every drain request # Custom Enrichers :enricher-chain An **enricher** runs on every emitted event before it reaches drains. It's the right tool when you want a field on every event without touching every call site — geo, user agent, trace context, deploy id, tenant id, feature flags, performance tier. Use `defineEnricher` from `evlog/toolkit` — provide a single `compute()` function returning the value you want to merge into the event, and the toolkit handles error isolation, undefined skipping, and the merge step. Every built-in enricher is built on this same factory. ::prompt --- actions: - copy - cursor - claude description: Write a custom evlog enricher icon: i-lucide-code --- Write a custom evlog enricher that adds derived context to every wide event. - Use `defineEnricher` from `evlog/toolkit` — never write the merge / error / undefined logic by hand - Pass `{ name, field, compute }` to `defineEnricher` - `compute(ctx)` reads from `ctx.headers` / `ctx.request` / `ctx.response` / `ctx.event` and returns the value to merge (or `undefined` to skip) - Keep `compute` pure and fast: no awaitable I/O on the hot path; cache anything expensive at module scope - `defineEnricher` already handles: error isolation (errors logged, never thrown), single-field merge, overwrite option - Wire the enricher into my framework via the `enrich` option (middleware) or `initLogger.enrichers` (standalone) - For multiple enrichers, use `composeEnrichers([...])` from `evlog/toolkit` - For multi-hook features (enrich + drain side-effect, etc.), use `definePlugin` instead Docs: {rel=""nofollow""} Built-in: {rel=""nofollow""} :: ## Basic example Add deployment metadata to every event. The enricher is the same function everywhere — only the wiring step differs per framework. ::code-group ```typescript [Nuxt / Nitro] // server/plugins/evlog-enrich.ts export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:enrich', (ctx) => { ctx.event.deploymentId = process.env.DEPLOYMENT_ID ctx.event.deployedBy = process.env.DEPLOYED_BY }) }) ``` ```typescript [Next.js] // lib/evlog.ts import { createEvlog } from 'evlog/next' export const { withEvlog, useLogger, log, createError } = createEvlog({ service: 'my-app', enrich: (ctx) => { ctx.event.deploymentId = process.env.DEPLOYMENT_ID ctx.event.deployedBy = process.env.DEPLOYED_BY }, }) ``` ```typescript [Hono / Express / Fastify / Elysia / NestJS] import type { EnrichContext } from 'evlog' const deployment = (ctx: EnrichContext) => { ctx.event.deploymentId = process.env.DEPLOYMENT_ID ctx.event.deployedBy = process.env.DEPLOYED_BY } app.use(evlog({ enrichers: [deployment] })) // Hono / Express / Elysia // await app.register(evlog, { enrichers: [deployment] }) // Fastify // EvlogModule.forRoot({ enrichers: [deployment] }) // NestJS ``` ```typescript [Standalone] // index.ts import type { EnrichContext } from 'evlog' import { initLogger } from 'evlog' const deployment = (ctx: EnrichContext) => { ctx.event.deploymentId = process.env.DEPLOYMENT_ID ctx.event.deployedBy = process.env.DEPLOYED_BY } initLogger({ enrichers: [deployment] }) ``` :: ## EnrichContext The `evlog:enrich` hook receives an `EnrichContext`: ```typescript [enrich-context.ts] interface EnrichContext { /** The emitted wide event (mutable) */ event: WideEvent /** Request metadata */ request?: { method?: string path?: string requestId?: string } /** Safe HTTP request headers (sensitive headers filtered out) */ headers?: Record /** Response metadata */ response?: { status?: number headers?: Record } } ``` ::callout{color="success" icon="i-lucide-shield-check"} **Security:** Sensitive headers ( `authorization` , `cookie` , `x-api-key` , etc.) are automatically filtered and never passed to enrichers. :: ## Recommended pattern — `defineEnricher` Every built-in enricher uses this same factory. Provide `compute()` and you're done: ```typescript [server/utils/enrichers.ts] import { defineEnricher, getHeader, type EnricherOptions } from 'evlog/toolkit' interface TenantInfo { id: string org?: string } export function createTenantEnricher(options: EnricherOptions & { headerName?: string } = {}) { const headerName = options.headerName ?? 'x-tenant-id' return defineEnricher({ name: 'tenant', field: 'tenant', compute: ({ headers }) => { const id = getHeader(headers, headerName) if (!id) return undefined return { id } }, }, options) } ``` `defineEnricher` automatically: - skips when `compute()` returns `undefined` - merges the result into `ctx.event[field]` via `mergeEventField` (respecting `options.overwrite`) - catches errors and logs them as `[evlog/]` instead of breaking the pipeline Wire it like any other enricher: ::code-group ```typescript [Nuxt / Nitro] // server/plugins/evlog-enrich.ts import { createTenantEnricher } from '~/server/utils/enrichers' export default defineNitroPlugin((nitroApp) => { const enrichTenant = createTenantEnricher({ headerName: 'x-org-id' }) nitroApp.hooks.hook('evlog:enrich', enrichTenant) }) ``` ```typescript [Next.js] // lib/evlog.ts import { createEvlog } from 'evlog/next' import { createTenantEnricher } from './enrichers' const enrichTenant = createTenantEnricher({ headerName: 'x-org-id' }) export const { withEvlog, useLogger, log, createError } = createEvlog({ service: 'my-app', enrich: enrichTenant, }) ``` ```typescript [Hono / Express / Fastify / Elysia / NestJS] import { createTenantEnricher } from './enrichers' const enrichTenant = createTenantEnricher({ headerName: 'x-org-id' }) app.use(evlog({ enrichers: [enrichTenant] })) // await app.register(evlog, { enrichers: [enrichTenant] }) // Fastify // EvlogModule.forRoot({ enrichers: [enrichTenant] }) // NestJS ``` ```typescript [Standalone] import { initLogger } from 'evlog' import { createTenantEnricher } from './enrichers' initLogger({ enrichers: [createTenantEnricher({ headerName: 'x-org-id' })], }) ``` :: ## Combining with built-in enrichers Custom and built-in enrichers compose freely — they're all just `(ctx: EnrichContext) => void` functions. Use `composeEnrichers` from `evlog/toolkit` to combine them into a single callable: ```typescript [enrichers.ts] import { composeEnrichers, defineEnricher } from 'evlog/toolkit' import { createDefaultEnrichers } from 'evlog/enrichers' const region = defineEnricher({ name: 'region', field: 'region', compute: () => process.env.FLY_REGION ?? process.env.AWS_REGION, }) export const enrich = composeEnrichers([ createDefaultEnrichers(), // userAgent + geo + requestSize + traceContext region, ]) ``` ## More examples Each example below is a plain `defineEnricher` call — wire it the same way as the basic example, regardless of framework. ### Feature flags ```typescript [enricher-feature-flags.ts] import { defineEnricher } from 'evlog/toolkit' export const featureFlags = defineEnricher({ name: 'feature-flags', field: 'featureFlags', compute: () => ({ newCheckout: isEnabled('new-checkout'), betaApi: isEnabled('beta-api'), }), }) ``` ### Response time classification ```typescript [enricher-perf-tier.ts] import { defineEnricher } from 'evlog/toolkit' export const performanceTier = defineEnricher({ name: 'performance-tier', field: 'performanceTier', compute: ({ event }) => { const durationMs = event.durationMs if (durationMs === undefined) return undefined if (durationMs < 100) return 'fast' if (durationMs < 500) return 'normal' if (durationMs < 2000) return 'slow' return 'critical' }, }) ``` ## When to reach for a plugin instead If your feature mixes enrichment with other hooks (e.g. enrich + tail-sample + side-effect on drain), use a [plugin](https://www.evlog.dev/extend/plugins) instead — one cohesive object covering several lifecycle points. ## Next steps - [Built-in Enrichers](https://www.evlog.dev/use-cases/enrichers) — User Agent, Geo, Request Size, Trace Context - [Plugins](https://www.evlog.dev/extend/plugins) — multi-hook extensions (drain + enrich + keep in one object) - [Adapters](https://www.evlog.dev/integrate/adapters/overview) — send enriched events to external services # Tail Sampling :tail-sample-decision **Tail sampling** is a decision made *after* the request runs, with full knowledge of its outcome (status, duration, errors, custom flags). It's how you keep all errors and slow requests while throwing away the bulk of healthy traffic — the opposite of head sampling, which decides up front before knowing what happens. The full theory and config reference — built-in `keep` rules, custom predicates via `evlog:emit:keep`, combining head + tail sampling — lives at [Sampling](https://www.evlog.dev/learn/sampling). This page covers the **extension surface**: how to plug your own keep logic into the pipeline. ::prompt --- actions: - copy - cursor - claude description: Configure tail sampling on evlog icon: i-lucide-filter --- Set up tail sampling so I keep all errors and slow requests while dropping healthy noise. - Start with the built-in declarative rules: `evlog.sampling.keep = { status: '>=400', duration: '>1000', path: ['/api/auth/*'] }` - For multi-field or derived conditions, register an `evlog:emit:keep` hook (Nitro: `nitroApp.hooks.hook('evlog:emit:keep', (ctx) => ...)`); set `ctx.shouldKeep = true` to keep - Keep the hook fast — it runs on every request after enrichment; no I/O, no async work - Combine with head sampling (e.g. 10% of healthy traffic) by setting both `sample` (head) and `keep` (tail) - Always keep error events (`level: 'error'`) regardless of sampling; double-check rules don't accidentally drop them Docs: {rel=""nofollow""} :: ## When the built-in rules aren't enough The built-in declarative `keep` rules cover the typical cases (status code thresholds, duration thresholds, path matching, level matching). Drop to a custom hook when you need: - **Conditional logic on more than one field** (e.g. "keep if `status >= 500` AND `user.plan === 'enterprise'`") - **Keep based on a derived value** (e.g. "keep if `event.audit?.context.actor.role === 'admin'`") - **Stateful decisions** (rare; needs care since sampling runs in the hot path) ## Custom keep hook The hook signature is the same regardless of framework. The wiring depends on your runtime. ::code-group ```ts [Nuxt / Nitro] nitroApp.hooks.hook('evlog:emit:keep', (ctx) => { if (ctx.context.user?.plan === 'enterprise' && ctx.status >= 500) { ctx.shouldKeep = true } }) ``` ```ts [Plugin (any framework)] import { definePlugin } from 'evlog/toolkit' export const keepEnterpriseErrors = definePlugin({ name: 'keep-enterprise-errors', keep(ctx) { if (ctx.context.user?.plan === 'enterprise' && ctx.status >= 500) { ctx.shouldKeep = true } }, }) // Then: initLogger({ plugins: [keepEnterpriseErrors] }) // or: app.use(evlog({ plugins: [keepEnterpriseErrors] })) ``` :: For non-trivial logic, prefer the plugin shape — it travels with the rest of your evlog config (drains, enrichers) and is reusable across frameworks. ## Composing several keep predicates Use `composeKeep` from `evlog/toolkit` to combine multiple predicates into one hook. Each predicate runs independently and the final `shouldKeep` is `true` if any of them set it: ```ts import { composeKeep } from 'evlog/toolkit' const keep = composeKeep([ ({ duration, shouldKeep }) => duration && duration > 2000 ? true : shouldKeep, ({ event }) => event.level === 'error', ({ context, status }) => context.user?.plan === 'enterprise' && status >= 500, ]) ``` Errors in individual predicates are isolated (logged with the `[evlog/keep]` prefix) so a buggy predicate cannot silently drop legitimate events. ## What you receive The keep hook gets a `TailSamplingContext`: ```ts interface TailSamplingContext { /** The event level (debug | info | warn | error) */ level: string /** HTTP response status, if known */ status?: number /** Request duration in milliseconds, if measured */ duration?: number /** The full accumulated context (everything log.set'd) */ context: Record /** The fully enriched event ready to drain */ event: WideEvent /** Mutable: set to true to force-keep this event */ shouldKeep: boolean } ``` Setting `shouldKeep = true` forces the event through. Setting `shouldKeep = false` is a no-op (other predicates may still keep it; the head sampler decides the default). ## Next steps - [Sampling](https://www.evlog.dev/learn/sampling) — head sampling, tail sampling, the built-in declarative `keep` rules - [Plugins](https://www.evlog.dev/extend/plugins) — when keep belongs in a multi-hook plugin - [Best Practices](https://www.evlog.dev/reference/best-practices) — keep all errors, double-check the rules # Identity Headers Every drain request sent by evlog is tagged with two identity headers so receivers can identify the traffic: | Header | Value | | ---------------- | ----------------------------------------------------------------------------------------------- | | `User-Agent` | `evlog/` (Node / server runtimes only — browsers strip this header) | | `X-Evlog-Source` | The adapter name (`axiom`, `datadog`, `otlp`, `posthog`, `sentry`, `better-stack`, `client`, …) | The browser-side `evlog/http` drain (used by the client transport) sets `X-Evlog-Source: client` instead, since browsers cannot override `User-Agent`. ## Why - **Triage at the receiver.** Quickly distinguish evlog traffic from other clients in the receiving system's logs. - **Track adapter usage and version drift.** Roll out a new evlog version and watch the `User-Agent` distribution change centrally. - **Debug a specific drain.** Filter by `X-Evlog-Source` to isolate one adapter's behavior in a sea of incoming requests. ## Reading the version Both constants are exported from `evlog/toolkit` so your drain (or your receiver) can reference the canonical values: ```ts import { EVLOG_USER_AGENT, EVLOG_VERSION } from 'evlog/toolkit' console.log(EVLOG_VERSION) // → "2.16.0" console.log(EVLOG_USER_AGENT) // → "evlog/2.16.0" ``` ## Overriding from a custom drain Adapters built with [`defineHttpDrain()`](https://www.evlog.dev/extend/custom-drains) automatically pass the drain `name` as `source` and the canonical `evlog/` as `userAgent`. You don't need to think about it. When you build a drain on top of `httpPost` from `evlog/toolkit` directly (e.g. for a fork with a different identity, or for a vendor that wants its own UA), pass `source` and/or `userAgent` to override: ```ts import { httpPost } from 'evlog/toolkit' await httpPost({ url: 'https://my-platform.example.com/ingest', headers: { 'Content-Type': 'application/json' }, body: '[]', timeout: 5000, label: 'my-platform', source: 'my-platform', // sent as X-Evlog-Source userAgent: 'my-fork/1.0', // overrides the default User-Agent // userAgent: false, // suppress the header entirely }) ``` ## Next steps - [Custom Drains](https://www.evlog.dev/extend/custom-drains) — `defineHttpDrain` injects identity headers automatically - [Drain Pipeline](https://www.evlog.dev/extend/drain-pipeline) — wrap any drain in batch + retry while keeping identity headers # Custom Drains A **drain** is the terminal step of evlog's pipeline: a function that receives wide events and ships them somewhere — an HTTP API, a message queue, a database, a webhook, a local file. evlog ships built-in drains for popular providers ([Adapters overview](https://www.evlog.dev/integrate/adapters/overview)). When you need a destination that isn't covered, you write your own. Two factories cover every case: | You have… | Use | | --------------------------------------------------------------------- | --------------------------------------------------------------------------- | | An HTTP backend (REST, JSON ingest, vendor `/v1/logs` endpoint) | [`defineHttpDrain`](https://www.evlog.dev/#definehttpdrain-the-http-recipe) | | A non-HTTP transport (gRPC, WebSocket, vendor SDK, queue, raw socket) | [`defineDrain`](https://www.evlog.dev/#definedrain-non-http-transports) | Both come from `evlog/toolkit` and are the exact factories every built-in adapter uses. ::prompt --- actions: - copy - cursor - claude description: Build a custom evlog drain icon: i-lucide-code-2 --- Build a custom evlog drain that ships wide events to a backend without a built-in adapter. - For HTTP backends, use `defineHttpDrain({ name, resolve, encode })` from `evlog/toolkit` — never call `fetch` directly - For non-HTTP transports (queue, DB, native SDK, raw socket), use `defineDrain({ name, send })` and implement `send(events)` myself - Resolve config lazily inside `resolve()` via `resolveAdapterConfig(namespace, fields, overrides)` so users get the standard precedence (overrides → `runtimeConfig.evlog.` → env) - Use the standardized field names: `apiKey` for bearer secrets, `endpoint` for the base URL, `serviceName`, `timeout` - Encode batched events into the destination's wire format inside `encode(events, config)` — return `{ url, headers, body }` (or `null` to opt out of the batch) - `defineHttpDrain` handles retries, timeouts, error isolation, batching, and identity headers — don't reimplement them - Wire the drain via `defineEvlog({ drain: createMyDrain() })` or my framework's middleware `drain` option - For production, wrap the result in `createDrainPipeline` for batching + retries Docs: {rel=""nofollow""} Pipeline: {rel=""nofollow""} :: ## `defineHttpDrain` (the HTTP recipe) The recipe every built-in adapter follows. Two pure functions: `resolve()` returns the config (or `null` to skip), `encode()` returns the HTTP request payload. ::code-collapse ```typescript [lib/my-drain.ts] import { defineHttpDrain, resolveAdapterConfig, type ConfigField, } from 'evlog/toolkit' interface MyServiceConfig { apiKey: string endpoint?: string timeout?: number } const FIELDS: ConfigField[] = [ { key: 'apiKey', env: ['MYSERVICE_API_KEY'] }, { key: 'endpoint', env: ['MYSERVICE_ENDPOINT'] }, { key: 'timeout' }, ] export function createMyServiceDrain(overrides?: Partial) { return defineHttpDrain({ name: 'myservice', resolve: async () => { const cfg = await resolveAdapterConfig('myservice', FIELDS, overrides) if (!cfg.apiKey) { console.error('[evlog/myservice] Missing apiKey') return null } return cfg as MyServiceConfig }, encode: (events, cfg) => ({ url: `${cfg.endpoint ?? 'https://api.myservice.com'}/v1/ingest`, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${cfg.apiKey}`, }, body: JSON.stringify(events), }), }) } ``` :: That's it. `defineHttpDrain` handles batching, retries (default 2), timeouts (default 5000ms), error isolation, and the identity headers (`User-Agent: evlog/` + `X-Evlog-Source: `). Your app pipeline keeps running even if your destination is down. ### A 5-minute example — internal Loki drain A complete working drain in 25 lines, with no external config helper: ```ts [lib/loki-drain.ts] import { defineHttpDrain } from 'evlog/toolkit' export function createLokiDrain(overrides?: { url?: string, token?: string }) { return defineHttpDrain<{ url: string, token: string }>({ name: 'loki', resolve: () => ({ url: overrides?.url ?? process.env.LOKI_URL!, token: overrides?.token ?? process.env.LOKI_TOKEN!, }), encode: (events, config) => ({ url: `${config.url}/loki/api/v1/push`, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${config.token}`, }, body: JSON.stringify({ streams: events.map(e => ({ stream: { service: e.service, level: e.level }, values: [[String(Date.parse(e.timestamp) * 1e6), JSON.stringify(e)]], })), }), }), }) } ``` ## Standardized config priority `resolveAdapterConfig(namespace, fields, overrides)` walks the standard chain so users get the same configuration UX as built-in adapters: 1. Explicit `overrides` passed to your factory 2. `runtimeConfig.evlog.` (Nuxt/Nitro) 3. `runtimeConfig.` (legacy Nuxt/Nitro) 4. `_` env vars (list `NUXT__` in `ConfigField.env` for silent Nuxt compat; show only `_` in error messages via `formatPublicEnvKeys`) Field names should follow the project conventions: `apiKey`, `endpoint`, `serviceName`, `timeout`. If you're renaming an existing field (e.g. `token` → `apiKey`), keep both as `ConfigField` entries for one major version — see `axiom.ts` and `better-stack.ts` for the deprecation pattern. ## Wiring the drain into your framework Once `createMyServiceDrain()` returns the drain, wire it like any other: ::code-group ```typescript [Nuxt / Nitro] // server/plugins/evlog-drain.ts import { createMyServiceDrain } from '~/server/utils/my-drain' const drain = createMyServiceDrain() export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', drain) }) ``` ```typescript [Next.js] // lib/evlog.ts import { createEvlog } from 'evlog/next' import { createMyServiceDrain } from './my-drain' export const { withEvlog, useLogger, log, createError } = createEvlog({ service: 'my-app', drain: createMyServiceDrain(), }) ``` ```typescript [Hono / Express / Elysia] import { createMyServiceDrain } from './my-drain' app.use(evlog({ drain: createMyServiceDrain() })) ``` ```typescript [Fastify] await app.register(evlog, { drain: createMyServiceDrain() }) ``` ```typescript [NestJS] EvlogModule.forRoot({ drain: createMyServiceDrain() }) ``` ```typescript [Standalone] import { initLogger } from 'evlog' import { createMyServiceDrain } from './my-drain' initLogger({ drain: createMyServiceDrain() }) ``` :: For production, wrap it once in [`createDrainPipeline`](https://www.evlog.dev/extend/drain-pipeline) so events are batched and retried. ## Filtering and transforming events `encode()` receives the full batch of `WideEvent[]` plus the resolved config. Filter or transform inline — returning `null` is a clean opt-out for that batch: ```typescript encode: (events, cfg) => { const filtered = events.filter(e => e.level === 'error' && e.path !== '/health') if (filtered.length === 0) return null const payload = filtered.map(e => ({ ts: new Date(e.timestamp).getTime(), severity: e.level.toUpperCase(), attributes: { method: e.method, path: e.path, status: e.status, durationMs: e.durationMs }, })) return { url: `${cfg.endpoint}/v1/push`, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), } } ``` ## `defineDrain` (non-HTTP transports) If your destination requires gRPC, a vendor SDK, a queue client, a WebSocket, or a raw socket, drop one level lower with `defineDrain`. You own the transport; the toolkit still gives you config resolution, error isolation, and a consistent shape. ```typescript import { defineDrain } from 'evlog/toolkit' export const createCustomTransportDrain = () => defineDrain<{ apiKey: string }>({ name: 'custom', resolve: async () => ({ apiKey: process.env.MY_KEY! }), send: async (events, cfg) => { await myVendorSdk.publish(events, { token: cfg.apiKey }) }, }) ``` When you fall back to `defineDrain`, follow the same rules manually that `defineHttpDrain` enforces: wrap the transport in `try/catch`, log with `console.error('[evlog/] …')`, and never re-throw. ## DrainContext reference When evlog calls your drain through `evlog:drain`, it passes a `DrainContext` per event: ```typescript [types.ts] interface DrainContext { /** The complete wide event with all accumulated context */ event: WideEvent /** Request metadata */ request?: { method: string path: string requestId: string } /** Safe HTTP headers (sensitive headers filtered) */ headers?: Record } interface WideEvent { timestamp: string level: 'debug' | 'info' | 'warn' | 'error' service: string environment?: string version?: string region?: string commitHash?: string requestId?: string // ... plus all fields added via log.set() [key: string]: unknown } ``` In the batched form your `encode()` / `send()` receives, you get `WideEvent[]` directly (the toolkit unwraps `event` from each context). ## Toolkit helpers `evlog/toolkit` exposes the same helpers every built-in adapter uses. The ones relevant to drains: | Export | Purpose | | --------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | | `defineHttpDrain(spec)` | The HTTP recipe — auto retries, timeouts, identity headers, error isolation | | `defineDrain(spec)` | Same contract for non-HTTP transports | | `resolveAdapterConfig(ns, fields, overrides)` | Standard config priority chain (overrides → `runtimeConfig.evlog.` → env) | | `httpPost(opts)` | The retried POST helper used by every built-in HTTP adapter — handles timeout, retries, redacted error messages | | `composeDrains(drains)` | Combine multiple drains into one (errors isolated, runs concurrently with `Promise.allSettled`) | | `toTypedAttributeValue(value)` | Convert any value to the typed attribute shape used by Axiom / Sentry | | `toOtlpAttributeValue(value)` | Convert any value to the OTLP `AnyValue` shape (used by OTLP / HyperDX / PostHog logs) | | `OTEL_SEVERITY_NUMBER`, `OTEL_SEVERITY_TEXT` | OTEL log severity tables | ## Identity headers `defineHttpDrain` automatically tags every request with two headers so receivers can identify the traffic: | Header | Value | | ---------------- | ---------------------------------------------------------------------------- | | `User-Agent` | `evlog/` (Node / server runtimes only — browsers strip this header) | | `X-Evlog-Source` | The drain `name` you provided | If you build a drain on top of `httpPost` directly, you can override or suppress them — see [Identity headers](https://www.evlog.dev/extend/identity-headers). ## Error handling — already done for you `defineHttpDrain` enforces every best practice automatically: 1. **Never throws** — failures are caught and logged with the `[evlog/]` prefix. 2. **Retries** — defaults to 2 attempts on transient errors (configurable via `retries`). 3. **Timeouts** — defaults to 5000ms (configurable via `timeout`). 4. **Graceful degradation** — `resolve()` returning `null` makes the drain a no-op. If you fall back to `defineDrain`, follow the same rules manually. ## Publishing as a community package Recommended structure for a community drain: ```text my-evlog-drain/ ├─ src/ │ ├─ drain.ts # createMyDrain via defineHttpDrain │ └─ index.ts # re-exports ├─ test/ # vitest, mock fetch ├─ package.json # peerDependency: "evlog" └─ README.md ``` Add `evlog` as a `peerDependency` (not a `dependency`) — your package shouldn't pull in a copy of evlog at install time. ::callout{color="neutral" icon="i-lucide-heart"} Built something great? [Open a PR](https://github.com/hugorcd/evlog/pulls){rel=""nofollow""} to add a row to the Adapters table — the community will thank you. :: ## Next steps - [Drain Pipeline](https://www.evlog.dev/extend/drain-pipeline) — wrap your drain in batch + retry + fanout for production - [Adapters Overview](https://www.evlog.dev/integrate/adapters/overview) — see how the built-in adapters use `defineHttpDrain` - [Custom Enrichers](https://www.evlog.dev/extend/custom-enrichers) — same toolkit shape for derived event fields - [Custom Framework Integration](https://www.evlog.dev/extend/custom-framework) — same toolkit shape for HTTP frameworks - [Best Practices](https://www.evlog.dev/reference/best-practices) — security and production tips # Drain Pipeline In production, sending one HTTP request per emitted event doesn't scale. The drain pipeline buffers events and sends them in batches, retries on transient failures, drops the oldest events when the buffer overflows, and lets a single drain function fan out to several destinations in parallel. The same pipeline powers the [HTTP browser drain](https://www.evlog.dev/#http-drain-browser-to-server) used for client-side logs. :drain-pipeline-batching | You want to… | See | | --------------------------------------------------- | ------------------------------------------------------------------------------------- | | Wrap any drain in batch + retry + buffer | [Quick start](https://www.evlog.dev/#quick-start) | | Send each event to several destinations in parallel | [Fanout](https://www.evlog.dev/#fanout) | | Ship browser logs to your server endpoint | [HTTP drain (browser to server)](https://www.evlog.dev/#http-drain-browser-to-server) | | Tune batch size, retry strategy, buffer size | [Configuration](https://www.evlog.dev/#configuration) | ::prompt --- actions: - copy - cursor - claude description: Add the drain pipeline (batch + retry + fanout) icon: i-lucide-workflow --- Wrap my evlog drain in the shared pipeline so it batches, retries, and survives transient failures. - Import `createDrainPipeline` from `evlog/pipeline` - Wrap the underlying drain: `const drain = createDrainPipeline()(createAxiomDrain())` - Configure `batch` (size + intervalMs), `retry` (maxAttempts + backoff), and `maxBufferSize` (oldest events drop on overflow). Sane defaults: `{ batch: { size: 50, intervalMs: 5000 }, retry: { maxAttempts: 3 } }` - For multiple destinations, write a single drain function that fans out internally with `Promise.allSettled([drainA(batch), drainB(batch)])` and pass that to `pipeline(...)` — one shared buffer, one shared retry budget - On shutdown, call `drain.flush()` to push buffered events (frameworks with proper lifecycle do this automatically: Nitro `close` hook, Standalone before `process.exit`, serverless via `waitUntil(drain.flush())`) - Always use the pipeline in production — direct drains make one HTTP call per event and fall over fast Docs: {rel=""nofollow""} Adapters: {rel=""nofollow""} :: ## Quick start The pipeline wraps any drain. The wiring depends on your framework — pick the tab that matches yours; every other example below uses the same shape. ::code-group ```typescript [Nuxt / Nitro] // 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() const drain = pipeline(createAxiomDrain()) nitroApp.hooks.hook('evlog:drain', drain) nitroApp.hooks.hook('close', () => drain.flush()) }) ``` ```typescript [Next.js] // lib/evlog.ts import type { DrainContext } from 'evlog' import { createEvlog } from 'evlog/next' import { createDrainPipeline } from 'evlog/pipeline' import { createAxiomDrain } from 'evlog/axiom' const pipeline = createDrainPipeline() const drain = pipeline(createAxiomDrain()) export const { withEvlog, useLogger, log, createError } = createEvlog({ service: 'my-app', drain, }) export const flushEvlog = () => drain.flush() ``` ```typescript [Hono / Express / Fastify / Elysia / NestJS] import type { DrainContext } from 'evlog' import { createDrainPipeline } from 'evlog/pipeline' import { createAxiomDrain } from 'evlog/axiom' const pipeline = createDrainPipeline() const drain = pipeline(createAxiomDrain()) app.use(evlog({ drain })) // Hono / Express / Elysia // await app.register(evlog, { drain }) // Fastify // EvlogModule.forRoot({ drain }) // NestJS process.on('SIGTERM', () => drain.flush()) ``` ```typescript [Standalone] // index.ts — plain TypeScript / Bun / Node script import type { DrainContext } from 'evlog' import { initLogger } from 'evlog' import { createDrainPipeline } from 'evlog/pipeline' import { createAxiomDrain } from 'evlog/axiom' const pipeline = createDrainPipeline() const drain = pipeline(createAxiomDrain()) initLogger({ drain }) await drain.flush() // before exit ``` :: ::callout{color="warning" icon="i-lucide-alert-triangle"} Always flush the pipeline before the process exits ( `drain.flush()` ). On Nitro use the `close` hook; on standalone scripts call it before `process.exit` ; on serverless runtimes use `waitUntil(drain.flush())` . :: ## How it works Events are buffered as they arrive on `evlog:drain`. A batch flushes when either `batch.size` is reached or `batch.intervalMs` expires (whichever comes first). On failure, the same batch is retried with the configured backoff; once `retry.maxAttempts` is exhausted, `onDropped` is called with the lost events. The buffer is bounded by `maxBufferSize` — once full, the oldest events are dropped to keep memory flat. ## Configuration ```typescript [pipeline-config.ts] import type { DrainContext } from 'evlog' import { createDrainPipeline } from 'evlog/pipeline' import { createAxiomDrain } from 'evlog/axiom' const pipeline = createDrainPipeline({ batch: { size: 50, // Flush every 50 events intervalMs: 5000, // Or every 5 seconds, whichever comes first }, retry: { maxAttempts: 3, backoff: 'exponential', initialDelayMs: 1000, maxDelayMs: 30000, }, maxBufferSize: 1000, onDropped: (events, error) => { console.error(`[evlog] Dropped ${events.length} events:`, error?.message) }, }) export const drain = pipeline(createAxiomDrain()) ``` ### Options reference | 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 the initial one | | `retry.backoff` | `'exponential'` | `'exponential'` \| `'linear'` \| `'fixed'` | | `retry.initialDelayMs` | `1000` | Base delay for the 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 (overflow or retry exhaustion) | ### Backoff strategies | Strategy | Delay pattern | Use case | | ------------- | --------------- | ------------------------------------------------------------------ | | `exponential` | 1s, 2s, 4s, 8s… | Default. Best for transient failures that may need time to recover | | `linear` | 1s, 2s, 3s, 4s… | Predictable delay growth | | `fixed` | 1s, 1s, 1s, 1s… | Same delay every time. Useful for rate-limited APIs | ### Returned drain function The function returned by `pipeline(drain)` is hook-compatible and exposes: | Property | Type | Description | | --------------- | --------------------- | ----------------------------------- | | `drain(ctx)` | `(ctx: T) => void` | Push a single event into the buffer | | `drain.flush()` | `() => Promise` | Force-flush all buffered events | | `drain.pending` | `number` | Number of events currently buffered | ## Fanout :drain-fan-out In production, the same wide event often needs to reach more than one destination: a long-term store (Axiom), a metrics tool (Datadog), an error tracker (Sentry), and a local fs drain for incident replay. The pipeline batches once, then your drain fans out the batch to every destination in parallel via `Promise.allSettled` so a single slow / failing destination cannot block the others. ::prompt --- actions: - copy - cursor - claude description: Fan out evlog events to multiple destinations icon: i-lucide-share-2 --- Send each wide event to several destinations in parallel through a single drain pipeline. - Wrap a single `createDrainPipeline` from `evlog/pipeline` around a fan-out function that calls every destination drain inside `Promise.allSettled([drainA(batch), drainB(batch), …])` — `allSettled` so one failing drain doesn't reject the whole batch - Pick destinations by purpose: long-term store (Axiom / Better Stack / Datadog), error tracker (Sentry — typically `{ minLevel: 'error' }` so it doesn't get all events), local replay (`createFsDrain`) - Tune `batch.size`, `batch.intervalMs`, `retry.maxAttempts`, and `maxBufferSize` once at the pipeline level — applies to all destinations - For destinations that need different filtering, prefer per-drain `minLevel` / `filter` options over wrapping - Don't forget `drain.flush()` on shutdown — events buffered for fanout are lost on abrupt exit Docs: {rel=""nofollow""} :: ### The recipe ```ts import { createDrainPipeline } from 'evlog/pipeline' import { createAxiomDrain } from 'evlog/axiom' import { createDatadogDrain } from 'evlog/datadog' import { createSentryDrain } from 'evlog/sentry' import { createFsDrain } from 'evlog/fs' import type { DrainContext } from 'evlog' const pipeline = createDrainPipeline({ batch: { size: 50, intervalMs: 5000 }, retry: { maxAttempts: 3 }, maxBufferSize: 1000, }) const axiom = createAxiomDrain() const datadog = createDatadogDrain() const sentry = createSentryDrain({ minLevel: 'error' }) const fs = createFsDrain({ dir: '.evlog/logs', maxFiles: 14 }) export const drain = pipeline(async (batch) => { await Promise.allSettled([ axiom(batch), datadog(batch), sentry(batch), fs(batch), ]) }) ``` ### What you get - **Parallel dispatch** — every destination receives the batch concurrently via `Promise.allSettled` - **Tolerant fanout** — if Datadog's API throws, Axiom / Sentry / fs still complete; the pipeline only retries the whole batch when the wrapping function rejects - **Shared backpressure** — the buffer is sized once for the whole pipeline; if the wrapping drain falls behind, the oldest events are dropped consistently for every destination ### Per-drain filtering Wrap a destination drain so it only sees events you care about: ```ts import type { DrainContext } from 'evlog' const sentry = createSentryDrain({ dsn: process.env.SENTRY_DSN! }) async function sentryErrorsOnly(batch: DrainContext[]): Promise { const errors = batch.filter(c => c.event?.level === 'error') if (errors.length > 0) await sentry(errors) } export const drain = pipeline(async (batch) => { await Promise.allSettled([ axiom(batch), sentryErrorsOnly(batch), ]) }) ``` Most built-in drains expose `minLevel` directly, so you only need this pattern for non-level filters (path, custom field, etc.). ## Custom drain function You don't need an adapter. Pass any async function that accepts a batch: ```typescript [pipeline-custom.ts] import type { DrainContext } from 'evlog' import { createDrainPipeline } from 'evlog/pipeline' const pipeline = createDrainPipeline({ batch: { size: 100 } }) export 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)), }) }) ``` For anything more involved (config resolution, retries, identity headers), use [`defineHttpDrain`](https://www.evlog.dev/extend/custom-drains) instead and let the toolkit handle the boilerplate. ## HTTP drain (browser to server) The HTTP drain is a framework-agnostic transport for shipping client-side logs to your server. It composes on top of the same pipeline primitives, with browser-specific defaults (`fetch keepalive` + `sendBeacon` on `visibilitychange`). ::callout{color="neutral" icon="i-lucide-info"} The `evlog/browser` import path is **deprecated** and re-exports the same API as `evlog/http` . It will be removed in the next **major** release. Prefer `evlog/http` for new code. :: ::prompt --- actions: - copy - cursor - claude description: Set up the HTTP transport for client logs icon: i-lucide-globe --- Set up the HTTP transport so my browser logs are sent to my server. - Install evlog: pnpm add evlog - Import `createHttpLogDrain` from `evlog/http` (NOT `evlog/browser` — that's deprecated) - Create a drain with `{ endpoint: 'https://logs.example.com/v1/ingest' }` and an optional pipeline (`{ batch: { size, intervalMs } }`) - Pass the drain to `initLogger({ drain })` on the client side - The drain batches events and uses `fetch keepalive` + `sendBeacon` on `visibilitychange` - On the server, accept POST requests with a `DrainContext[]` body and forward them to my drain pipeline Docs: {rel=""nofollow""} :: ### Quick start ```typescript [app.ts] import { initLogger, log } from 'evlog' import { createHttpLogDrain } from 'evlog/http' const drain = createHttpLogDrain({ drain: { endpoint: 'https://logs.example.com/v1/ingest' }, }) initLogger({ drain }) log.info({ action: 'page_view', path: location.pathname }) ``` ### How it works (browser specifics) 1. `log.info()` / `log.warn()` / `log.error()` push events into a memory buffer 2. Events are batched by size (default 25) or time interval (default 2 s) 3. Batches are sent via `fetch` with `keepalive: true` so requests survive page navigation 4. When the page becomes hidden (tab switch, navigation), buffered events are flushed via `navigator.sendBeacon` as a fallback 5. Your server endpoint receives a `DrainContext[]` JSON array and processes it however you like ### Two-tier API #### `createHttpLogDrain(options)` High-level, pre-composed: creates a pipeline with batching, retry, and auto-flush on `visibilitychange`. Returns a `PipelineDrainFn` directly usable with `initLogger({ drain })`. ```typescript [app.ts] import { initLogger, log } from 'evlog' import { createHttpLogDrain } from 'evlog/http' const drain = createHttpLogDrain({ drain: { endpoint: 'https://logs.example.com/v1/ingest' }, pipeline: { batch: { size: 50, intervalMs: 5000 } }, }) initLogger({ drain }) log.info({ action: 'click', target: 'buy-button' }) ``` #### `createHttpDrain(config)` Low-level transport function. Use this when you want full control over the pipeline configuration: ```typescript [app.ts] import { createHttpDrain } from 'evlog/http' import { createDrainPipeline } from 'evlog/pipeline' import type { DrainContext } from 'evlog' const transport = createHttpDrain({ endpoint: 'https://logs.example.com/v1/ingest', }) const pipeline = createDrainPipeline({ batch: { size: 100, intervalMs: 10000 }, retry: { maxAttempts: 5 }, }) const drain = pipeline(transport) ``` ### Configuration reference #### `HttpDrainConfig` | Option | Default | Description | | ------------- | --------------- | -------------------------------------------------------------------------------------------------------------- | | `endpoint` | - | **(required)** Full URL of the server ingest endpoint | | `headers` | - | Custom headers sent with each `fetch` request (e.g. `Authorization`, `X-API-Key`) | | `timeout` | `5000` | Request timeout in milliseconds | | `useBeacon` | `true` | Use `sendBeacon` when the page is hidden | | `credentials` | `'same-origin'` | Fetch credentials mode (`'omit'`, `'same-origin'`, `'include'`). Set to `'include'` for cross-origin endpoints | #### `HttpLogDrainOptions` | Option | Default | Description | | ----------- | ---------------------------------------------------------------------- | ----------------------------------------------- | | `drain` | - | **(required)** `HttpDrainConfig` object | | `pipeline` | `{ batch: { size: 25, intervalMs: 2000 }, retry: { maxAttempts: 2 } }` | Pipeline configuration overrides | | `autoFlush` | `true` | Auto-register `visibilitychange` flush listener | ### sendBeacon fallback ::callout{color="info" icon="i-lucide-radio"} When `useBeacon` is enabled (the default) and the page becomes hidden, the drain automatically switches from `fetch` to `navigator.sendBeacon` . This ensures logs are delivered even when the user closes the tab or navigates away. :: `sendBeacon` has a browser-imposed payload limit (\~64 KB). If the payload exceeds this, the drain throws an error. Keep batch sizes reasonable (the default of 25 is well within limits). ### Authentication Pass custom headers to protect your ingest endpoint: ```typescript [app.ts] const drain = createHttpLogDrain({ drain: { endpoint: 'https://logs.example.com/v1/ingest', headers: { 'Authorization': 'Bearer ' + token, }, }, }) ``` ::callout{color="warning" icon="i-lucide-shield-alert"} `headers` are applied to `fetch` requests only. The `sendBeacon` API does not support custom headers, so when the page is hidden and `sendBeacon` is used, headers are not sent. If your endpoint requires authentication, validate via a session cookie (set `credentials: 'include'` for cross-origin endpoints) or disable `sendBeacon` with `useBeacon: false` . :: ### Server endpoint Your server needs a POST endpoint that accepts a `DrainContext[]` JSON body. Examples for common frameworks: ::code-group ```typescript [Express] app.post('/v1/ingest', express.json(), (req, res) => { for (const entry of req.body) { console.log('[BROWSER]', JSON.stringify(entry)) } res.sendStatus(204) }) ``` ```typescript [Hono] app.post('/v1/ingest', async (c) => { const body = await c.req.json() for (const entry of body) { console.log('[BROWSER]', JSON.stringify(entry)) } return c.body(null, 204) }) ``` :: See the full [browser example](https://github.com/hugorcd/evlog/tree/main/examples/browser){rel=""nofollow""} for a working Hono server + browser page. ## Common pitfalls - **Don't forget `drain.flush()` on shutdown** — buffered events are lost otherwise - **Tune `batch.size` to match your provider's recommended payload** — too small wastes overhead, too big risks rejection - **Don't run one pipeline per drain unless you need per-destination retries** — sharing one pipeline keeps batching + buffering coherent - **Don't fan out to a serverless-incompatible target without checking** — the [stream server](https://www.evlog.dev/extend/stream) reaches every connected client through the in-process stream; it's not a drain ## Next steps - [Custom Drains](https://www.evlog.dev/extend/custom-drains) — build a drain for any backend with `defineHttpDrain` / `defineDrain` - [Adapters Overview](https://www.evlog.dev/integrate/adapters/overview) — built-in adapters that work with the pipeline out of the box - [Best Practices](https://www.evlog.dev/reference/best-practices) — security and production tips - [Client logging](https://www.evlog.dev/use-cases/client-logging) — end-to-end browser → server flow # Configuration evlog has two configuration surfaces: **global options** set once at startup, and **middleware options** set per-framework integration. This page documents both. ## Global Options (`initLogger`) These options apply to all frameworks. Call `initLogger()` once at application startup for standalone frameworks (Hono, Express, Fastify, Elysia, NestJS, SvelteKit, Cloudflare Workers). For Nuxt and Nitro, these are set via module config and passed through automatically. ```typescript [src/index.ts] import { initLogger } from 'evlog' import { createAxiomDrain } from 'evlog/axiom' initLogger({ enabled: true, env: { service: 'my-api', environment: 'production' }, pretty: false, silent: false, stringify: true, minLevel: 'info', sampling: { rates: { info: 10 }, keep: [{ status: 400 }] }, drain: createAxiomDrain(), }) ``` | Option | Type | Default | Description | | ----------- | ------------------------------------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | | `enabled` | `boolean` | `true` | Enable/disable all logging globally. When `false`, all operations become no-ops | | `env` | `Partial` | Auto-detected | Environment context overrides (see below) | | `pretty` | `boolean` | `true` in dev | Pretty print with tree formatting. Auto-detected based on `NODE_ENV` | | `dev` | `'evlog' | 'nitro' | 'both' | object` | `'evlog'` in pretty dev | Dev terminal presets or `{ frameworkOverlay, prettyError }` — see [Dev terminal output](https://www.evlog.dev/#dev-terminal-output) | | `silent` | `boolean` | `false` | Suppress console output. Events are still built, sampled, and passed to drains | | `stringify` | `boolean` | `true` | Emit JSON strings when `pretty` is disabled. Set to `false` for Cloudflare Workers | | `minLevel` | `'debug' | 'info' | 'warn' | 'error'` | `'debug'` | Minimum severity for the global `log` API only (not `createLogger` / request wide events). Order: debug < info < warn < error | | `sampling` | `SamplingConfig` | `undefined` | Head and tail sampling configuration. See [Sampling](https://www.evlog.dev/learn/sampling) | | `redact` | `boolean | RedactConfig` | `true` in production | Enabled by default in production. `false` to disable. Object for fine-grained control. See [Auto-Redaction](https://www.evlog.dev/learn/redaction) | | `drain` | `(ctx: DrainContext) => void` | `undefined` | Drain callback for sending events to external services | `RedactConfig` fields (when `redact` is an object): `paths` (dot-notation with globs), `patterns` (regex on string values), `builtins`, `replacement` (string, or a function computing it from the matched value), `transform` (hook for conditional policies). Full table in [Auto-Redaction](https://www.evlog.dev/learn/redaction#configuration-reference). ### `minLevel` vs sampling - **`minLevel`** is a **hard threshold** on the simple `log.*` API: levels below the threshold are never emitted. It does **not** apply to wide events from `useLogger` / `createLogger().emit()` — use **`sampling.rates`** (and tail `keep`) for request volume. - **Head sampling** (`sampling.rates`) is **probabilistic** on what is already allowed by `minLevel` for simple logs. Evaluation order for `log.info` / `log.debug` / etc.: `enabled` → `minLevel` → head sampling → output. ### Dev terminal output Pretty error blocks run only when `pretty: true` (default in development). Production always emits JSON wide events — no stack snippets or disk reads. Use `dev` to control **two independent axes**: whether Nitro's Youch overlay runs, and how much stack detail evlog prints inside the wide event. **Presets** (recommended): | Preset | Nitro overlay | evlog error block | | --------------------------------- | ------------- | --------------------------------------------------------- | | `'evlog'` (default in pretty dev) | Off | Full — location, snippet, stack tail, Why/Fix | | `'nitro'` | On | Guidance only — message + Why/Fix/link (stack from Nitro) | | `'both'` | On | Full — evlog block + Nitro overlay (debug) | ```typescript [nuxt.config.ts] export default defineNuxtConfig({ modules: ['evlog/nuxt'], evlog: { pretty: true, dev: 'evlog', // or 'nitro' | 'both' }, }) ``` **Explicit object** (fine-grained): ```typescript [nuxt.config.ts] evlog: { dev: { frameworkOverlay: true, prettyError: { snippet: false, stackDepth: 0, compact: true, detail: 'guidance', // 'full' | 'guidance' }, }, } ``` See [Structured Errors — Development terminal output](https://www.evlog.dev/learn/structured-errors#development-terminal-output) for an example of the pretty error tree. ### Environment Context The `env` option controls the fields included in every log event. Most values are auto-detected from environment variables and `package.json`. | Field | Type | Default | Auto-detected from | | ------------- | -------- | --------------- | --------------------------------------------------- | | `service` | `string` | `'app'` | `SERVICE_NAME`, `package.json` name | | `environment` | `string` | `'development'` | `NODE_ENV` | | `version` | `string` | `undefined` | `APP_VERSION`, `package.json` version | | `commitHash` | `string` | `undefined` | `COMMIT_SHA`, `GIT_COMMIT`, `VERCEL_GIT_COMMIT_SHA` | | `region` | `string` | `undefined` | `FLY_REGION`, `AWS_REGION`, `VERCEL_REGION` | ### Silent Mode Use `silent` when your deployment platform captures stdout as its primary log ingestion (GCP Cloud Run, AWS Lambda, Fly.io, Railway, etc.) and you want a drain adapter to control the output format. ```typescript [src/index.ts] import { initLogger } from 'evlog' import { createAxiomDrain } from 'evlog/axiom' initLogger({ silent: process.env.NODE_ENV === 'production', drain: createAxiomDrain(), }) ``` ::callout{color="warning" icon="i-lucide-alert-triangle"} If `silent` is enabled without a drain, events are built and sampled but never output anywhere. evlog will warn you about this at startup. :: ## Middleware Options These options are passed to the framework middleware/plugin. They control per-request behavior: which routes to log, how to drain and enrich events, and custom tail sampling logic. ::code-group ```typescript [Next.js] // lib/evlog.ts import { createEvlog } from 'evlog/next' import { createAxiomDrain } from 'evlog/axiom' export const { withEvlog, useLogger, log, createError } = createEvlog({ service: 'my-app', include: ['/api/**'], exclude: ['/api/health'], routes: { '/api/auth/**': { service: 'auth' } }, drain: createAxiomDrain(), enrich: (ctx) => { ctx.event.region = process.env.FLY_REGION }, keep: (ctx) => { if (ctx.duration > 2000) ctx.shouldKeep = true }, }) ``` ```typescript [Hono] app.use(evlog({ include: ['/api/**'], exclude: ['/api/health'], routes: { '/api/auth/**': { service: 'auth' } }, drain: createAxiomDrain(), enrich: (ctx) => { ctx.event.region = process.env.FLY_REGION }, keep: (ctx) => { if (ctx.duration > 2000) ctx.shouldKeep = true }, })) ``` ```typescript [Express] app.use(evlog({ include: ['/api/**'], drain: createAxiomDrain(), enrich: (ctx) => { ctx.event.region = process.env.FLY_REGION }, })) ``` ```typescript [Fastify] await app.register(evlog, { include: ['/api/**'], drain: createAxiomDrain(), }) ``` :: | Option | Type | Default | Description | | --------- | ------------------------------------- | ----------- | --------------------------------------------------------------------- | | `include` | `string[]` | `undefined` | Route glob patterns to log. If not set, all routes are logged | | `exclude` | `string[]` | `undefined` | Route patterns to exclude. Exclusions take precedence over inclusions | | `routes` | `Record` | `undefined` | Route-specific service name overrides | | `drain` | `(ctx: DrainContext) => void` | `undefined` | Drain callback called with every emitted event | | `enrich` | `(ctx: EnrichContext) => void` | `undefined` | Enrich callback called after emit, before drain | | `keep` | `(ctx: TailSamplingContext) => void` | `undefined` | Custom tail sampling callback | ::callout{color="info" icon="i-lucide-info"} **Nuxt and Nitro** use module config and Nitro hooks ( `evlog:drain` , `evlog:enrich` , `evlog:emit:keep` ) instead of middleware options. See the [Nuxt](https://www.evlog.dev/integrate/frameworks/nuxt) and [Nitro](https://www.evlog.dev/integrate/frameworks/nitro) pages. :: ### Middleware drain vs global drain When a middleware `drain` is set, it takes precedence over the global drain from `initLogger()`. If no middleware drain is set, the global drain is used as fallback, with the benefit of receiving the full enriched event with request context (method, path, headers). ```typescript [src/index.ts] import { initLogger } from 'evlog' import { createAxiomDrain } from 'evlog/axiom' initLogger({ env: { service: 'my-api' }, drain: createAxiomDrain(), // fallback: used by singleton log API AND middleware (if no middleware drain) }) app.use(evlog({ // no drain here - falls back to globalDrain from initLogger, with full request context })) ``` ## Framework-Specific Options Some frameworks have additional options beyond the shared config: ### Nuxt The Nuxt module accepts all global options and middleware options in `nuxt.config.ts` under the `evlog` key, plus: | Option | Type | Default | Description | | ----------------------- | -------------------- | ---------------------- | --------------------------------------------------------------- | | `console` | `boolean` | `true` | Enable/disable browser console output (client-side only) | | `transport.enabled` | `boolean` | `false` | Send client logs to the server via API endpoint | | `transport.endpoint` | `string` | `'/api/_evlog/ingest'` | Custom transport endpoint | | `transport.credentials` | `RequestCredentials` | `'same-origin'` | Fetch credentials mode (`'include'` for cross-origin endpoints) | See the full [Nuxt configuration](https://www.evlog.dev/integrate/frameworks/nuxt#configuration). ### Nitro The Nitro module accepts `enabled`, `env`, `pretty`, `silent`, `sampling`, `include`, `exclude`, and `routes` in `nitro.config.ts`. Drain and enrichment are done via Nitro hooks. See [Nitro drain & enrichers](https://www.evlog.dev/integrate/frameworks/nitro#drain--enrichers). # Performance evlog adds **\~3µs of overhead per request**, that's 0.003ms, orders of magnitude below any HTTP framework or database call. Performance is tracked on every pull request via [CodSpeed](https://codspeed.io){rel=""nofollow""}. ## evlog vs alternatives All benchmarks run with JSON output to no-op destinations. pino writes to `/dev/null` (sync), winston writes to a no-op stream, consola uses a no-op reporter, evlog uses silent mode. ### Results :bench-bar-race | Scenario | evlog | pino | consola | winston | | --------------------- | ---------------: | -----: | --------: | ------: | | Simple string log | 1.83M ops/s | 1.09M | **2.79M** | 1.20M | | Structured (5 fields) | 1.64M ops/s | 716.1K | **1.71M** | 431.6K | | Deep nested log | **1.55M** ops/s | 464.9K | 1.01M | 164.0K | | Child / scoped logger | **1.70M** ops/s | 845.0K | 280.4K | 430.0K | | Wide event lifecycle | **1.58M** ops/s | 205.8K | — | 111.9K | | Burst (100 logs) | 17.8K ops/s | 10.3K | **39.4K** | 7.5K | | Logger creation | **16.85M** ops/s | 7.50M | 310.3K | 5.38M | evlog wins **4 out of 7** head-to-head comparisons, and the wins that matter most are decisive: **7.7x faster** than pino in the wide event pattern, **2.3x faster** logger creation, and **3.3x faster** deep nested logging. consola edges ahead on simple strings and burst (it uses a no-op reporter with no serialization), but evlog produces a single correlated event per request where traditional loggers emit N separate lines. ::callout{color="info" icon="i-lucide-info"} **Why this matters** : in the wide event pattern (one event per request, the real-world API shape), evlog is 7.7x faster than pino and 14.1x faster than winston while sending 75% less data to your log drain and giving you one queryable event instead of 4 disconnected lines. The 7.7x is not a brute-force win — pino doesn't try to accumulate context, so the comparison reflects an architectural difference, not a fairness issue. See [When evlog might not win](https://www.evlog.dev/#when-evlog-might-not-win) for the honest gaps. :: ### What is the "wide event lifecycle"? This benchmark simulates a real API request: ::code-group ```typescript [evlog (1 event)] const log = createLogger({ method: 'POST', path: '/api/checkout', requestId: 'req_abc' }) log.set({ user: { id: 'usr_123', plan: 'pro' } }) log.set({ cart: { items: 3, total: 9999 } }) log.set({ payment: { method: 'card', last4: '4242' } }) log.emit({ status: 200 }) ``` ```typescript [pino (4 log lines)] const child = pinoLogger.child({ method: 'POST', path: '/api/checkout', requestId: 'req_abc' }) child.info({ user: { id: 'usr_123', plan: 'pro' } }, 'user context') child.info({ cart: { items: 3, total: 9999 } }, 'cart context') child.info({ payment: { method: 'card', last4: '4242' } }, 'payment context') child.info({ status: 200 }, 'request complete') ``` :: Same CPU cost, but evlog gives you everything in one place. ## Why is evlog faster? The numbers above aren't magic, they come from deliberate architectural choices: **In-place mutations, not copies.** `log.set()` writes directly into the context object via a recursive `mergeInto` function. Other loggers clone objects on every call (object spread, `Object.assign`). evlog never allocates intermediate objects during context accumulation. **No serialization until drain.** Context stays as plain JavaScript objects throughout the request lifecycle. `JSON.stringify` runs exactly once, at emit time. Traditional loggers serialize on every `.info()` call, that's 4x serialization for 4 log lines. **Lazy allocation.** Timestamps, sampling context, and override objects are only created when actually needed. If tail sampling is disabled (the common case), its context object is never allocated. ISO timestamps cache the formatted second-prefix and only append milliseconds on each call (invalidated on second rollover), so hot emit paths avoid re-running `toISOString()` every time. **One event, not N lines.** For a typical request, pino emits 4+ JSON lines that all need serializing, transporting, and indexing. evlog emits one. That's 75% less work for your log drain, fewer bytes on the wire, and one row to query instead of four. **RegExp caching.** Glob patterns (used in sampling and route matching) are compiled once and cached. Repeated evaluations hit the cache instead of recompiling. ## When evlog might not win The benchmarks above measure CPU + serialization cost on the main thread, with no real I/O. That's the standard setup pino, winston, and logtape use for their own benchmarks — but it leaves out a few scenarios where another logger can edge ahead. Be honest about these: **Fire-and-forget hot paths with pino-via-worker-thread.** In production, pino is typically configured with a [worker-thread transport](https://getpino.io/#/docs/transports){rel=""nofollow""} (`pino-pretty`, `pino-loki`, vendor-specific transports). The serialization and I/O move off the main thread entirely. For a workload that emits hundreds of thousands of `log.info('foo')` lines per second with no context accumulation, pino-via-worker can hit \~2-3M ops/s on the main thread because it's just queueing. We can't benchmark that mode fairly inside a single-threaded vitest process, so it's not in our table — but it's a real scenario where pino is faster. **CLI / pretty-only output without serialization.** consola's no-op reporter mode in our benchmarks (`level: 4, reporters: [{ log: () => {} }]`) skips JSON serialization entirely. That's realistic if you're using consola for a CLI with terminal-only output, but it's why consola wins "simple string" and "burst" — it's not doing the same work. evlog and pino both serialize to JSON; consola in those benchmarks does not. If your use case is "pretty terminal output, no shipping logs anywhere", consola is genuinely lighter. **Single `log.info` calls, no context accumulation.** evlog and pino are roughly tied on `pino.info('hello')` vs `evlog.info('hello')` (1.83M vs 1.09M ops/s in our run, but the gap closes further if pino runs in async mode). evlog's \~7.7x advantage shows up specifically when you'd otherwise emit N separate lines for one logical operation. If you genuinely log one line per call and don't accumulate, the speed delta is much smaller — pick evlog for the API ergonomics (`log.set` + structured errors), not raw throughput. **Wall-clock variance is real.** Vitest bench numbers shift ±5-10% between runs on the same machine (thermal throttling, GC, other processes). The numbers above come from a single run on a MacBook; CI tracks regressions via [CodSpeed](https://codspeed.io){rel=""nofollow""}'s CPU-instruction counting (deterministic, ±0.5% noise floor) but the absolute hz values in this page are the wall-clock snapshot, not a guaranteed floor. The takeaway: **the wins are real for the wide event pattern**, but if your stack is "pure fire-and-forget pino with a worker transport", that's the one place we don't claim to beat. ## Real-world overhead For a typical API request: | Component | Cost | | ----------------- | ----------: | | Logger creation | 52ns | | 3x `set()` calls | 105ns | | `emit()` | 400ns | | Sampling | 22ns | | Enricher pipeline | 2.14µs | | **Total** | **\~2.7µs** | For context, a database query takes 1-50ms, an HTTP call takes 10-500ms. evlog's overhead is **invisible**. ## Bundle size Every entry point is tree-shakeable. You only pay for what you import. | Entry | Gzip | | ------------------------- | ------: | | core (`evlog`) | 510 B | | toolkit (`evlog/toolkit`) | 720 B | | utils | 1.58 kB | | error | 1.46 kB | | enrichers | 1.99 kB | | pipeline | 1.35 kB | | http | 1.22 kB | | browser | 289 B | | workers | 1.30 kB | | client | 128 B | A typical Node.js bundle (`initLogger` + `createLogger`) measures **\~6.3 kB gzip** end-to-end after tree-shaking; adding `createRequestLogger`, `createError`, `parseError`, and `useLogger` brings the bundle to **\~7.2 kB gzip**. Adapters and framework integrations sit on top: Hono is 617 B, Express 734 B, Axiom 1.48 kB. Bundle size is tracked on every PR and compared against the `main` baseline. ## Detailed benchmarks ### Logger creation | Operation | ops/sec | Mean | | --------------------------------------------------- | ------: | ---: | | `createLogger()` (no context) | 19.20M | 52ns | | `createLogger()` (shallow context) | 18.74M | 53ns | | `createLogger()` (nested context) | 17.70M | 56ns | | `createRequestLogger()` (method + path) | 16.91M | 59ns | | `createRequestLogger()` (method + path + requestId) | 12.67M | 79ns | ### Context accumulation (`log.set()`) | Operation | ops/sec | Mean | | ------------------------- | ------: | ----: | | Shallow merge (3 fields) | 9.56M | 105ns | | Shallow merge (10 fields) | 4.79M | 209ns | | Deep nested merge | 8.04M | 124ns | | 4 sequential calls | 7.05M | 142ns | ### Event emission (`log.emit()`) | Operation | ops/sec | Mean | | --------------------------------------- | ------: | ------: | | Emit minimal event | 2.72M | 400ns | | Emit with context | 2.28M | 400ns | | Full lifecycle (create + 3 sets + emit) | 2.04M | 500ns | | Emit with error | 65.9K | 15.17µs | ::callout{color="amber" icon="i-lucide-triangle-alert"} `emit with error` is slower because `Error.captureStackTrace()` is an expensive V8 operation (~15µs). This only triggers when errors are thrown. :: ### Payload scaling | Payload | ops/sec | Mean | | ------------------------- | ------: | -----: | | Small (2 fields) | 1.72M | 581ns | | Medium (50 fields) | 569.8K | 1.76µs | | Large (200 nested fields) | 131.2K | 7.62µs | ### Sampling | Operation | ops/sec | Mean | | -------------------------- | ------: | ----: | | Tail sampling (shouldKeep) | 44.97M | 22ns | | Full emit with head + tail | 7.01M | 143ns | ### Enrichers | Enricher | ops/sec | Mean | | ------------------------------ | ---------: | ---------: | | User Agent (Chrome) | 2.61M | 384ns | | Geo (Vercel) | 3.88M | 258ns | | Request Size | 12.37M | 81ns | | Trace Context | 4.35M | 230ns | | **All combined (all headers)** | **466.7K** | **2.14µs** | ### Error handling | Operation | ops/sec | Mean | | --------------------------- | ------: | -----: | | `createError()` | 232.2K | 4.31µs | | `parseError()` | 45.48M | 22ns | | Round-trip (create + parse) | 231.4K | 4.32µs | ### Middleware pipeline | Operation | ops/sec | Mean | | --------------------------------------------------- | ------: | -----: | | `resolveMiddlewarePluginRunner` (no plugins) | 37.70M | 27ns | | `resolveMiddlewarePluginRunner` (2 plugins, cached) | 32.26M | 31ns | | `createMiddlewareLogger` (no plugins, safe headers) | 4.41M | 227ns | | `createMiddlewareLogger` (2 plugins, cached merge) | 4.13M | 242ns | | Full request lifecycle (no plugins, no drain) | 993.7K | 1.01µs | | Full request lifecycle (2 plugins, sync drain) | 621.2K | 1.61µs | ## Methodology & trust ### Can you trust these numbers? Every benchmark in this page is **open source** and **reproducible**. The benchmark files live in [`packages/evlog/bench/`](https://github.com/hugorcd/evlog/tree/main/packages/evlog/bench){rel=""nofollow""}. You can read the exact code, run it on your machine, and verify the results. All libraries are tested under the same conditions: - **Same output mode**: JSON to a no-op destination (no disk or network I/O measured) - **Same warmup**: each benchmark runs for 500ms after JIT stabilization - **Same tooling**: [Vitest bench](https://vitest.dev/guide/features#benchmarking){rel=""nofollow""} powered by [tinybench](https://github.com/tinylibs/tinybench){rel=""nofollow""} - **Same machine**: when comparing libraries, all benchmarks run in the same process on the same hardware ### CI regression tracking Performance regressions are tracked on every pull request via two systems: - **[CodSpeed](https://codspeed.io){rel=""nofollow""}** runs all benchmarks using CPU instruction counting (not wall-clock timing). This eliminates noise from shared CI runners and produces deterministic, reproducible results. Regressions are flagged directly on the PR. - **Bundle size comparison** measures all entry points against the `main` baseline and posts a size delta report as a PR comment. ### Run it yourself ```bash [Terminal] cd packages/evlog pnpm run bench # all benchmarks pnpm exec vitest bench bench/comparison/ # vs alternatives only pnpm exec tsx bench/scripts/size.ts # bundle size ``` # Vite Plugin The `evlog/vite` plugin adds build-time DX features to any Vite-based project. It works with SvelteKit, Hono, Express, Fastify, Elysia, and any framework using Vite as its build tool. ::callout{color="info" icon="i-lucide-info"} **Nuxt users** : These features are already integrated into the `evlog/nuxt` module via `strip` and `sourceLocation` options. You don't need to install the Vite plugin separately. :: ## Quick Start ### 1. Install ::code-group ```bash [pnpm] pnpm add evlog ``` ```bash [bun] bun add evlog ``` ```bash [yarn] yarn add evlog ``` ```bash [npm] npm install evlog ``` :: ### 2. Add to `vite.config.ts` ```typescript [vite.config.ts] import { defineConfig } from 'vite' import evlog from 'evlog/vite' export default defineConfig({ plugins: [ evlog({ service: 'my-api', environment: 'production', }), ], }) ``` That's it. The plugin automatically: - Initializes the logger at compile time (no `initLogger()` call needed) - Strips `log.debug()` calls from production builds ## Features :vite-strip-build The plugin transforms your source at build time — `log.debug()` calls are deleted from the output, `__source: 'file:line'` is injected into object-form log calls, and `initLogger()` is wired in via Vite's `define` hook so you never have to call it yourself. ### Auto-initialization The plugin injects logger configuration at compile time via Vite's `define` hook. The `service`, `environment`, `pretty`, `silent`, `enabled`, and `sampling` options are serialized and injected at build time, so `log`, `createLogger()`, and `createRequestLogger()` work immediately without an `initLogger()` call. ### Debug stripping By default, all `log.debug()` calls are removed from production builds. This is a compile-time transformation, the calls are completely eliminated from the output, not just silenced. ```typescript [vite.config.ts] evlog({ service: 'my-api', // Default: strip debug logs in production builds // strip: ['debug'], // Strip debug and info in production: // strip: ['debug', 'info'], // Disable stripping: // strip: [], }) ``` Stripping only activates during `vite build` (not `vite dev`). ### Source location injection When enabled, the plugin injects `__source: 'file:line'` into object-form log calls so you know exactly which file and line produced each log entry. ```typescript [vite.config.ts] evlog({ service: 'my-api', sourceLocation: true, // Always inject // sourceLocation: 'dev', // Only in development }) ``` ### Auto-imports (opt-in) Automatically detect and import evlog symbols (`log`, `createEvlogError`, `parseError`, etc.) without manual import statements. Disabled by default. ```typescript [vite.config.ts] evlog({ service: 'my-api', autoImports: true, }) ``` When enabled, the plugin: 1. Scans your code for evlog symbols 2. Adds the correct `import` statements automatically 3. Generates a `.d.ts` file for TypeScript support ::callout{color="amber" icon="i-lucide-triangle-alert"} The auto-imported error constructor is `createEvlogError` , not `createError` . This avoids conflicts with framework-native `createError` (Nuxt, Nitro, h3). The standalone `createError` from `evlog` is still available via explicit import. :: ### Client-side injection When the `client` option is provided, the plugin injects a `