# evlog > A modern TypeScript logger for everything you ship. Simple structured logs, wide events, and structured errors in one API - for scripts, libraries, jobs, edge, and requests. ## Resources for Agents - [API catalog: every service document this site publishes](https://www.evlog.dev/.well-known/api-catalog) - [Sitemap (XML)](https://www.evlog.dev/sitemap.xml) - [Sitemap (Markdown): every page on the site](https://www.evlog.dev/sitemap.md) - [MCP server card: MCP endpoint at https://www.evlog.dev/mcp](https://www.evlog.dev/.well-known/mcp/server-card.json) - [MCP endpoint (streamable HTTP)](https://www.evlog.dev/mcp) - [llms.txt: index of the documentation for LLMs](https://www.evlog.dev/llms.txt) - [llms-full.txt: the full documentation as a single file](https://www.evlog.dev/llms-full.txt) - [Agent skills index: every skill published by this site](https://www.evlog.dev/.well-known/skills/index.json) - [Agent skill: analyze-logs](https://www.evlog.dev/.well-known/skills/analyze-logs/SKILL.md) - [Agent skill: build-audit-logs](https://www.evlog.dev/.well-known/skills/build-audit-logs/SKILL.md) - [Agent skill: review-logging-patterns](https://www.evlog.dev/.well-known/skills/review-logging-patterns/SKILL.md) - [OpenAPI document: every route this site serves to agents](https://www.evlog.dev/openapi.json) Every page on this site is available as raw markdown: append `.md` to its URL or send `Accept: text/markdown`. # Docs MCP > Connect Cursor, Claude Code, or VS Code to https://www.evlog.dev/mcp and your agent reads the current evlog docs, and prices your log bill, from the editor. An agent answers evlog questions from its training data, which can be a release behind the package you installed. The docs MCP server gives it the current pages instead. Add `https://www.evlog.dev/mcp` to your editor: no account, no key, read-only, over [streamable HTTP](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#streamable-http). ## Add it to your editor ### Cursor [Add to Cursor](https://www.evlog.dev/mcp/deeplink) opens Cursor with the server filled in. Every docs page carries the same link as **Add MCP Server** in the copy menu at the top. To add it by hand, create `.cursor/mcp.json` in your project, or `~/.cursor/mcp.json` for every project ([Cursor MCP docs](https://cursor.com/docs/mcp)): ```json [.cursor/mcp.json] { "mcpServers": { "evlog": { "url": "https://www.evlog.dev/mcp" } } } ``` ### Claude Code ```bash [Terminal] claude mcp add --transport http evlog https://www.evlog.dev/mcp ``` Add `--scope user` to make it available in every project ([Claude Code MCP docs](https://code.claude.com/docs/en/mcp)). ### VS Code [Install in VS Code](https://www.evlog.dev/mcp/deeplink?ide=vscode), run **MCP: Add Server** from the Command Palette and pick HTTP, or commit it for the team in `.vscode/mcp.json` ([VS Code MCP docs](https://code.visualstudio.com/docs/agent-customization/mcp-servers)): ```json [.vscode/mcp.json] { "servers": { "evlog": { "type": "http", "url": "https://www.evlog.dev/mcp" } } } ``` ### Any other client Point any MCP client that speaks streamable HTTP at `https://www.evlog.dev/mcp`. Opening that URL in a browser brings you back to this page. ## Tools All three are read-only. None of them reads your project: they take a page path or the numbers you pass.
Tool Returns Input
list-pages Every docs page: title, path, description, URL None
get-page The full markdown of one page, the same bytes as /raw/.md path , e.g. /start/installation
estimate-log-cost A month of logs before and after evlog: events, GB, cost, and the basis used requestsPerMonth , plus optional linesPerRequest (default 4), provider , perGb , perMillionIndexed , keepPercent (default 100)
`provider` is one of `datadog`, `grafana`, `sentry`, `posthog`, `betterstack`, `axiom`. The byte counts and the rates behind `estimate-log-cost` are on [Log cost](https://www.evlog.dev/reference/cost). ## Ask it something Once the server is connected, name it in the prompt so the agent reads the docs instead of guessing: ```text Using the evlog MCP, read the Nuxt integration page and add evlog to this app. ``` ```text Using the evlog MCP, what does 10 million requests a month at 4 log lines each cost on Datadog, and with evlog? ``` ## MCP, skills, or llms.txt
You want the agent to Use
Look up a page in the middle of a task This MCP server
Follow the evlog conventions and review logging code Agent skills , installed with npx skills add https://www.evlog.dev
Read the docs in a tool that has no MCP support llms.txt , or any page as markdown at /raw/.md
The server and the skills work together: the skills carry the patterns, the server carries the current API. ## FAQ No. The server only serves the public docs, so there is nothing to authenticate. No. `list-pages` and `get-page` read the docs, and `estimate-log-cost` computes from the numbers in its arguments. The tools take no other input. `list-pages` and `get-page` cache for one hour, so a docs change reaches the server within an hour of deploying. ## Next - [Agent skills](https://www.evlog.dev/reference/agent-skills): teach the agent the evlog conventions - [`evlog agents`](https://www.evlog.dev/cli/agents): write those conventions into `AGENTS.md` --- - [Agent Skills](https://www.evlog.dev/reference/agent-skills) - [evlog agents](https://www.evlog.dev/cli/agents) # Install evlog > Install evlog in your TypeScript project. Supports Nuxt, Next.js, SvelteKit, Hono, Express, Fastify, Elysia, NestJS, and standalone scripts. evlog supports Nuxt, Next.js, SvelteKit, Nitro, NestJS, and any TypeScript server framework. Add evlog ([https://www.evlog.dev](https://www.evlog.dev)) to this project. 1. Run `npx --yes evlog init --yes`. It detects the framework, installs evlog with this project's package manager, writes evlog.config.ts, wires the integration and adds the evlog rules to AGENTS.md. Do every manual step it prints, and fix what its final check flags. 2. If it reports the framework as unsupported, or you cannot run commands, install `evlog` and follow the framework's guide from [https://www.evlog.dev/llms.txt](https://www.evlog.dev/llms.txt). With no guide for it, use [https://www.evlog.dev/extend/custom-framework](https://www.evlog.dev/extend/custom-framework) for a server, or [https://www.evlog.dev/integrate/frameworks/standalone](https://www.evlog.dev/integrate/frameworks/standalone) for scripts and jobs. 3. In server code from now on: one wide event per request through the integration's logger and `log.set({ ... })`, `throw createError({ message, status, why, fix })` instead of `new Error()`, and no `console.log`. ## Install ```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. **Try evlog map.** The `evlog` executable comes with the package. Early days, but one command scores which entry points are still dark: ```bash npx evlog map # or: pnpm evlog map ``` See [CLI overview](https://www.evlog.dev/cli/overview) and [`evlog map`](https://www.evlog.dev/cli/map). ## Create the config Run `npx evlog init`, or add `evlog.config.ts` at the root of your app by hand: ```typescript [evlog.config.ts] import { defineEvlog } from 'evlog' export default defineEvlog({ service: 'my-app', }) ``` This one file holds every option: sampling, drains, enrichers, route filters. Your framework guide shows how it loads. See [Configuration](https://www.evlog.dev/reference/configuration). ## Wire it into your framework Then follow the setup guide for your framework: Module with auto-imported `useLogger` and `parseError`. `createEvlog()` factory with `withEvlog()` handler wrapper. Handle and handleError hooks with `event.locals.log`. Module for Nitro v2 and v3 with plugin-based hooks. Uses Nitro v3 module with async context. Middleware with `context.get(loggerContext)`. `EvlogModule.forRoot()` with global middleware. Middleware with `req.log`. Middleware with `c.get('log')`. Plugin with `request.log`. Plugin with `log` in route context. `withEvlog()` handler wrapper + `evlog()` procedure middleware. Beta. `routerLayer` for `HttpRouter`, `yield* EvlogLogger` in handlers. Factory for request-scoped loggers. 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). ## Type the fields you add evlog ships with full TypeScript type definitions. No additional configuration is required. 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 --- - [Frameworks](https://www.evlog.dev/integrate/frameworks/overview) # Introduction > Understand what happened in your app with evlog: structured logging, wide events, and errors that explain what went wrong and how to fix it. **evlog** is a TypeScript logging library that helps you understand what happened in your app. Start with structured logs, bring a request’s context together in one wide event, and turn failures into errors that explain what went wrong and how to fix it. New to structured logging in Node.js? [Structured logging in Node.js](https://www.evlog.dev/use-cases/structured-logging-nodejs) walks through it end to end. Use it in your next project or introduce it alongside your existing logger. The [Quick Start](https://www.evlog.dev/start/quick-start) gets you going; the [migration examples](https://www.evlog.dev/learn/simple-logging) show how to bring familiar logging patterns to evlog. Inspired by [Logging Sucks](https://loggingsucks.com/) by [Boris Tane](https://x.com/boristane). The separate [evlog CLI](https://www.evlog.dev/cli/overview) helps you explore your logs and spot missing logging patterns with static checks. ## 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: 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. Accumulate context over any unit of work (a request, script, or job) and emit once. The two modes coexist, and neither is an upgrade of the other. Errors that explain why they occurred and how to fix them. Human-readable in development, machine-parseable JSON in production. 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 runtime dependencies in the core package.** Start with the logger and add integrations as you need them. See [Performance](https://www.evlog.dev/reference/performance) for bundle sizes and benchmarks. - **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. ## Pick one of 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: ```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() ``` With a framework integration, the logger is created when the request starts and emitted when the response ends. A handler only adds what it learns: ```typescript [Nuxt]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 }) ``` ```typescript [Nitro]routes/api/checkout.post.ts import { defineHandler } from 'nitro/h3' import { useLogger } from 'evlog/nitro/v3' export default defineHandler(async (event) => { const log = useLogger(event) log.set({ user: { id: 1, plan: 'pro' } }) return { success: true } // auto-emitted on response end }) ``` ```typescript [Next.js]app/api/checkout/route.ts import { withEvlog, useLogger } from '@/lib/evlog' export const POST = withEvlog(async () => { const log = useLogger() log.set({ user: { id: 1, plan: 'pro' } }) return Response.json({ success: true }) // auto-emitted on response end }) ``` ```typescript [Hono]src/index.ts app.post('/api/checkout', async (c) => { const log = c.get('log') log.set({ user: { id: 1, plan: 'pro' } }) return c.json({ success: true }) // auto-emitted on response end }) ``` ```typescript [Express]src/index.ts app.post('/api/checkout', async (req, res) => { const log = req.log log.set({ user: { id: 1, plan: 'pro' } }) return res.json({ 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: ```typescript [services/checkout.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 one event beats ten lines 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 - **Try evlog:** follow [Installation](https://www.evlog.dev/start/installation) and the [Quick Start](https://www.evlog.dev/start/quick-start). - **Decide whether it fits your project:** read [Choosing a TypeScript Logger](https://www.evlog.dev/start/typescript-logging-library), or [Why start with evlog](https://www.evlog.dev/start/why-evlog) for a new application. - **Explore the concepts:** the [Logging Overview](https://www.evlog.dev/learn/overview) connects simple logs, wide events, and structured errors. --- - [Quick Start](https://www.evlog.dev/start/quick-start) - [GitHub](https://github.com/evloghq/evlog) # Quick Start > Get up and running with evlog in minutes. Learn the log API, createLogger for wide events, useLogger for requests, and structured errors. This guide covers the core APIs you'll use most often with evlog. Prefer to learn by running a complete server? Follow [Structured Logging in Node.js](https://www.evlog.dev/use-cases/structured-logging-nodejs), then return here for the API overview. In Nuxt, evlog **auto-imports** `useLogger`, `log`, and `parseError`. `createError` is imported from `evlog`: the auto-imported one is Nuxt's own, and it drops `why`, `fix`, and `link`. Get evlog running in my project in under 2 minutes. - Install evlog: pnpm add evlog - Create evlog.config.ts with export default defineEvlog({ service: 'my-app' }) - Detect my framework and wire up the matching integration so it loads evlog.config.ts - 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: [https://www.evlog.dev/start/quick-start](https://www.evlog.dev/start/quick-start) Frameworks: [https://www.evlog.dev/integrate/frameworks/overview](https://www.evlog.dev/integrate/frameworks/overview) ## Configure once in evlog.config.ts Every option lives in one file at the root of your app. `npx evlog init` writes it for you: ```typescript [evlog.config.ts] import { defineEvlog } from 'evlog' export default defineEvlog({ service: 'my-app', }) ``` Nuxt, Nitro and TanStack Start load it on their own; other frameworks import it in one line, shown in each [framework guide](https://www.evlog.dev/integrate/frameworks/overview). See [Configuration](https://www.evlog.dev/reference/configuration) for every option. ## log (Simple Logging) The simplest way to use evlog. Fire-and-forget structured logs, anywhere in your code: ```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 Coming from pino or consola, the default import is the same `log` API with no setup line: `import logger from 'evlog'`, then `logger.info('auth', 'User logged in')` prints right away. The object form takes the event alone, so pino's `logger.info(obj, msg)` becomes `logger.info(obj)`. The no-setup path has a price in production: without `initLogger()`, events carry no redaction and no drain, and the first emitted event logs a one-time warning pointing at `initLogger({ drain })`. 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`: ```typescript [scripts/sync-job.ts] import { initLogger, createLogger, toLoggerConfig } from 'evlog' import config from '../evlog.config' initLogger(toLoggerConfig(config)) 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 [my-app] 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' }) ``` 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: ```typescript [Nuxt]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 } }) ``` ```typescript [Nitro]routes/api/checkout.post.ts import { defineHandler } from 'nitro/h3' import { useLogger } from 'evlog/nitro/v3' export default defineHandler(async (event) => { const log = useLogger(event) log.set({ 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 } }) ``` ```typescript [Next.js]app/api/checkout/route.ts import { withEvlog, useLogger } from '@/lib/evlog' export const POST = withEvlog(async () => { const log = useLogger() log.set({ user: { id: 1, plan: 'pro' } }) log.set({ cart: { items: 3, total: 9999 } }) const order = await processCheckout() log.set({ orderId: order.id }) return Response.json({ success: true, orderId: order.id }) }) ``` ```typescript [Hono]src/index.ts app.post('/api/checkout', async (c) => { const log = c.get('log') log.set({ user: { id: 1, plan: 'pro' } }) log.set({ cart: { items: 3, total: 9999 } }) const order = await processCheckout() log.set({ orderId: order.id }) return c.json({ success: true, orderId: order.id }) }) ``` ```typescript [Express]src/index.ts app.post('/api/checkout', async (req, res) => { const log = req.log log.set({ user: { id: 1, plan: 'pro' } }) log.set({ cart: { items: 3, total: 9999 } }) const order = await processCheckout() log.set({ orderId: order.id }) return res.json({ 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 ``` `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 Map route patterns to service names in `evlog.config.ts`: ```typescript [evlog.config.ts] import { defineEvlog } from 'evlog' export default defineEvlog({ service: 'default-service', routes: { '/api/auth/**': { service: 'auth-service' }, '/api/payment/**': { service: 'payment-service' }, '/api/booking/**': { service: 'booking-service' }, }, }) ``` Nuxt, Nitro and TanStack Start pick it up; other frameworks pass it with `toMiddlewareOptions(config)`. Route mapping needs a framework integration: `initLogger` does not take `routes`. 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 } }) ``` **Priority order:** Explicit `useLogger` parameter > Route configuration > `service` in `evlog.config.ts` > Auto-detected from environment ## createError (Structured Errors) Use `createError()` to throw errors with actionable context: ```typescript [services/checkout.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: ```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 } } ``` See [Client Logging](https://www.evlog.dev/use-cases/client-logging) for drain configuration, identity context, and browser setup. ## How much of this do you need on day one? Two calls. Everything else on this page is available when you want it, and nothing breaks while you do not. `log.info()` behaves like `console.log`, so the first commit is a find-and-replace that changes no behaviour. From there, the two that pay for themselves are `log.set()`, which adds a field to the event already in flight, and `createError()`, which attaches a `why` and a `fix` to a failure instead of a bare stack. Migrate one handler at a time. A route still calling `console.log` next to a route using `log.set()` is a normal intermediate state, not a broken one: they write to the same place and neither knows about the other. Pick the handler you last debugged in production and start there, or let [`evlog map`](https://www.evlog.dev/cli/map) rank them for you. Typed fields, catalogs, sampling and drains are all opt-in, and each one is worth reaching for the day you feel its absence rather than the day you install. ## 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 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 --- - [Logging Overview](https://www.evlog.dev/learn/overview) - [Structured Errors](https://www.evlog.dev/learn/structured-errors) # Choosing a TypeScript Logger > Choose a TypeScript logging library around the way you debug. See where evlog fits, what to keep from your current setup, and how to try it. A TypeScript logging library should make the next bug easier to understand. If you spend more time piecing together requests than fixing them, evlog brings the context you collect into one event: who made the request, what it did, and how it ended. This guide helps you decide where evlog fits in your project. For a working example, follow [Structured Logging in Node.js](https://www.evlog.dev/use-cases/structured-logging-nodejs). ## Choose around the questions you ask your logs A script may only need to report progress. Use evlog’s [simple logging API](https://www.evlog.dev/learn/simple-logging) for individual structured messages. When a checkout crosses authentication, inventory, and payment code, the useful answer is usually the whole operation. [Wide events](https://www.evlog.dev/learn/wide-events) let each step add context to the same event. You can then filter failed checkouts by customer, payment provider, or order amount without first joining separate messages. If your current logger already gives you the answers you need, you can keep it. evlog becomes useful when you want context accumulation, request lifecycle handling, and [structured errors](https://www.evlog.dev/learn/structured-errors) together. The feature comparison above covers the differences with other loggers, including the features you may want to retain. ## Let TypeScript keep your context consistent As more people add logging, a field named `orderId` in one place can become `orderID` in another. evlog lets you opt into a shared context type, so your editor and compiler catch those mismatches while you work. For example, this checkout logger accepts a known set of outcomes and a numeric payment amount: ```typescript [typed-context.ts] import { createLogger, initLogger } from 'evlog' initLogger({ env: { service: 'checkout' }, pretty: false }) type CheckoutContext = { orderId: string payment: { chargeId: string, amount: number } outcome: 'paid' | 'declined' } const log = createLogger() log.set({ orderId: 'order-123' }) log.set({ payment: { chargeId: 'ch_123', amount: 2999 } }) log.emit({ outcome: 'paid' }) ``` Type checking catches an amount passed as a string or an outcome outside that union in `set()` and `emit()`. Fields can be added progressively; the type does not require every field before emission or validate incoming data. [Typed Fields](https://www.evlog.dev/learn/typed-fields) covers the typing patterns and framework setup. ## Bring your existing setup with you Start with one route or background job. Keep your existing logger elsewhere. Use the resulting events to investigate a real issue before adopting evlog more widely. The migration examples in Simple Logging map familiar calls from `console.log`, Pino, Consola, and Winston to evlog. The APIs differ, so use those examples when updating calls. If you combine several records into one event, update the queries that read them too. You can keep your observability backend when it has a supported [drain adapter](https://www.evlog.dev/integrate/adapters/overview). The logger collects context in your application; the adapter sends it to the service where you search and store your events. ## Try it in your project Use the Quick Start above to learn the APIs, then choose your [framework integration](https://www.evlog.dev/integrate/frameworks/overview). For scripts and background jobs, [Standalone TypeScript](https://www.evlog.dev/integrate/frameworks/standalone) covers initialization and delivery. For bundle sizes and benchmark conditions, see [Performance](https://www.evlog.dev/reference/performance). --- - [Feature Comparison](https://www.evlog.dev/reference/vs-other-loggers) - [Quick Start](https://www.evlog.dev/start/quick-start) # Why start with evlog > The cheapest moment to add structured logging is before the first request. Pick evlog on day zero and the rest of the system inherits it. 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. 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 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). 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). 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). `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). 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). Built-in batching, retry with exponential backoff, fan-out to multiple destinations. No drain plumbing to write yourself. ### Beyond the logger The primitives are table stakes, and 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, and 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, and 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 is 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. 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 is the same `log` you already use, with a reserved `audit` field. See [Audit Logs](https://www.evlog.dev/use-cases/audit/overview). 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]nuxt.config.ts import { createAxiomDrain } from 'evlog/axiom' import { createSentryDrain } from 'evlog/sentry' export default defineNuxtConfig({ evlog: { drain: { adapters: [ createAxiomDrain({ apiKey: '...', dataset: 'app' }), createSentryDrain({ dsn: '...' }), ], }, }, }) ``` ```typescript [Nitro]nitro.config.ts import { defineConfig } from 'nitro' import evlog from 'evlog/nitro/v3' import { composeDrains } from 'evlog/toolkit' import { createAxiomDrain } from 'evlog/axiom' import { createSentryDrain } from 'evlog/sentry' export default defineConfig({ modules: [evlog({ drain: composeDrains([ createAxiomDrain({ apiKey: '...', dataset: 'app' }), createSentryDrain({ dsn: '...' }), ]), })], }) ``` ```typescript [Next.js]lib/evlog.ts import { createEvlog } from 'evlog/next' import { composeDrains } from 'evlog/toolkit' import { createAxiomDrain } from 'evlog/axiom' import { createSentryDrain } from 'evlog/sentry' export const { withEvlog, useLogger, log, createError } = createEvlog({ service: 'my-app', drain: composeDrains([ createAxiomDrain({ apiKey: '...', dataset: 'app' }), createSentryDrain({ dsn: '...' }), ]), }) ``` ```typescript [SvelteKit]src/hooks.server.ts import { createEvlogHooks } from 'evlog/sveltekit' import { composeDrains } from 'evlog/toolkit' import { createAxiomDrain } from 'evlog/axiom' import { createSentryDrain } from 'evlog/sentry' export const { handle, handleError } = createEvlogHooks({ drain: composeDrains([ createAxiomDrain({ apiKey: '...', dataset: 'app' }), createSentryDrain({ dsn: '...' }), ]), }) ``` ```typescript [TanStack Start]nitro.config.ts import { defineConfig } from 'nitro' import evlog from 'evlog/nitro/v3' import { composeDrains } from 'evlog/toolkit' import { createAxiomDrain } from 'evlog/axiom' import { createSentryDrain } from 'evlog/sentry' export default defineConfig({ modules: [evlog({ drain: composeDrains([ createAxiomDrain({ apiKey: '...', dataset: 'app' }), createSentryDrain({ dsn: '...' }), ]), })], }) ``` ```typescript [React Router]app/root.tsx import { evlog } from 'evlog/react-router' import { composeDrains } from 'evlog/toolkit' import { createAxiomDrain } from 'evlog/axiom' import { createSentryDrain } from 'evlog/sentry' export const middleware: Route.MiddlewareFunction[] = [ evlog({ drain: composeDrains([ createAxiomDrain({ apiKey: '...', dataset: 'app' }), createSentryDrain({ dsn: '...' }), ]), }), ] ``` ```typescript [Hono]src/index.ts import { evlog } from 'evlog/hono' import { composeDrains } from 'evlog/toolkit' import { createAxiomDrain } from 'evlog/axiom' import { createSentryDrain } from 'evlog/sentry' app.use(evlog({ drain: composeDrains([ createAxiomDrain({ apiKey: '...', dataset: 'app' }), createSentryDrain({ dsn: '...' }), ]), })) ``` ```typescript [Express]src/index.ts import { evlog } from 'evlog/express' import { composeDrains } from 'evlog/toolkit' import { createAxiomDrain } from 'evlog/axiom' import { createSentryDrain } from 'evlog/sentry' app.use(evlog({ drain: composeDrains([ createAxiomDrain({ apiKey: '...', dataset: 'app' }), createSentryDrain({ dsn: '...' }), ]), })) ``` ```typescript [Fastify]src/index.ts import { evlog } from 'evlog/fastify' import { composeDrains } from 'evlog/toolkit' import { createAxiomDrain } from 'evlog/axiom' import { createSentryDrain } from 'evlog/sentry' await app.register(evlog, { drain: composeDrains([ createAxiomDrain({ apiKey: '...', dataset: 'app' }), createSentryDrain({ dsn: '...' }), ]), }) ``` ```typescript [Elysia]src/index.ts import { evlog } from 'evlog/elysia' import { composeDrains } from 'evlog/toolkit' import { createAxiomDrain } from 'evlog/axiom' import { createSentryDrain } from 'evlog/sentry' app.use(evlog({ drain: composeDrains([ createAxiomDrain({ apiKey: '...', dataset: 'app' }), createSentryDrain({ dsn: '...' }), ]), })) ``` ```typescript [NestJS]src/app.module.ts import { EvlogModule } from 'evlog/nestjs' import { composeDrains } from 'evlog/toolkit' import { createAxiomDrain } from 'evlog/axiom' import { createSentryDrain } from 'evlog/sentry' EvlogModule.forRoot({ drain: composeDrains([ createAxiomDrain({ apiKey: '...', dataset: 'app' }), createSentryDrain({ dsn: '...' }), ]), }) ``` ```typescript [oRPC]server/orpc.ts import { withEvlog } from 'evlog/orpc' import { composeDrains } from 'evlog/toolkit' import { createAxiomDrain } from 'evlog/axiom' import { createSentryDrain } from 'evlog/sentry' const handler = withEvlog(new RPCHandler(router), { drain: composeDrains([ createAxiomDrain({ apiKey: '...', dataset: 'app' }), createSentryDrain({ dsn: '...' }), ]), }) ``` ```typescript [Cloudflare Workers]src/worker.ts import { withEvlog } from 'evlog/workers' import { composeDrains } from 'evlog/toolkit' import { createAxiomDrain } from 'evlog/axiom' import { createSentryDrain } from 'evlog/sentry' export default withEvlog(handler, { drain: composeDrains([ createAxiomDrain({ apiKey: '...', dataset: 'app' }), createSentryDrain({ dsn: '...' }), ]), }) ``` ```typescript [Standalone]src/index.ts import { initLogger } from 'evlog' import { composeDrains } from 'evlog/toolkit' import { createAxiomDrain } from 'evlog/axiom' import { createSentryDrain } from 'evlog/sentry' initLogger({ drain: composeDrains([ createAxiomDrain({ apiKey: '...', 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), [Sazabi](https://www.evlog.dev/integrate/adapters/cloud/sazabi), 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 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: [https://www.evlog.dev/start/why-evlog](https://www.evlog.dev/start/why-evlog) Frameworks: [https://www.evlog.dev/integrate/frameworks/overview](https://www.evlog.dev/integrate/frameworks/overview) Catalogs: [https://www.evlog.dev/learn/catalogs](https://www.evlog.dev/learn/catalogs) AI SDK: [https://www.evlog.dev/use-cases/ai-sdk/overview](https://www.evlog.dev/use-cases/ai-sdk/overview) Audit: [https://www.evlog.dev/use-cases/audit/overview](https://www.evlog.dev/use-cases/audit/overview) ## 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 --- - [Installation](https://www.evlog.dev/start/installation) - [Audit & Compliance](https://www.evlog.dev/use-cases/audit/overview) - [vs pino, winston, consola](https://www.evlog.dev/reference/vs-other-loggers) # Catalogs > Scale typed error and audit catalogs from one file to a monorepo: conventions, an npm packaging recipe, and type augmentation. 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. 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: [https://www.evlog.dev/learn/catalogs](https://www.evlog.dev/learn/catalogs) 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. ```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, with 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 ``` **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 [services/refund.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, because 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 **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`. **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. **Never override the code at the call site.** The catalog defines the code identity, and 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. **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. --- - [Structured Errors](https://www.evlog.dev/learn/structured-errors) - [Audit Logs](https://www.evlog.dev/use-cases/audit/overview) # Lifecycle > An evlog event from creation to drain, across all three modes, through sampling, enrichment, and delivery. 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. ## Follow an event through each 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 [After emit](https://www.evlog.dev/learn/wide-events#after-emit-sealing-and-background-work) in Wide events. ## Trace a request, 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]nuxt.config.ts export default defineNuxtConfig({ modules: ['evlog/nuxt'], evlog: { include: ['/api/**'], }, }) ``` ```typescript [Nitro]nitro.config.ts import { defineConfig } from 'nitro' import evlog from 'evlog/nitro/v3' export default defineConfig({ modules: [evlog({ include: ['/api/**'] })], }) ``` ```typescript [Next.js]lib/evlog.ts import { createEvlog } from 'evlog/next' export const { withEvlog, useLogger, log, createError } = createEvlog({ service: 'my-app', include: ['/api/**'], }) ``` ```typescript [SvelteKit]src/hooks.server.ts import { createEvlogHooks } from 'evlog/sveltekit' export const { handle, handleError } = createEvlogHooks({ include: ['/api/**'] }) ``` ```typescript [TanStack Start]nitro.config.ts import { defineConfig } from 'nitro' import evlog from 'evlog/nitro/v3' export default defineConfig({ modules: [evlog({ include: ['/api/**'] })], }) ``` ```typescript [React Router]app/root.tsx import { evlog } from 'evlog/react-router' export const middleware: Route.MiddlewareFunction[] = [ evlog({ include: ['/api/**'] }), ] ``` ```typescript [Hono]src/index.ts import { evlog } from 'evlog/hono' app.use(evlog({ include: ['/api/**'] })) ``` ```typescript [Express]src/index.ts import { evlog } from 'evlog/express' app.use(evlog({ include: ['/api/**'] })) ``` ```typescript [Fastify]src/index.ts import { evlog } from 'evlog/fastify' await app.register(evlog, { include: ['/api/**'] }) ``` ```typescript [Elysia]src/index.ts import { evlog } from 'evlog/elysia' app.use(evlog({ include: ['/api/**'] })) ``` ```typescript [NestJS]src/app.module.ts import { EvlogModule } from 'evlog/nestjs' EvlogModule.forRoot({ include: ['/api/**'] }) ``` ```typescript [oRPC]server/orpc.ts import { withEvlog } from 'evlog/orpc' const handler = withEvlog(new RPCHandler(router), { include: ['/api/**'] }) ``` ```typescript [Cloudflare Workers]src/worker.ts import { withEvlog } from 'evlog/workers' export default withEvlog(handler, { include: ['/api/**'] }) ``` ```typescript [Standalone]src/index.ts import { initLogger } from 'evlog' initLogger({ 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 } }) ``` Only plain objects merge. A class instance or a `Date` is treated as a value: the next `set()` on that key replaces it, and evlog never writes into the object you passed. 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 [Nuxt]server/plugins/evlog.ts export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:emit:keep', (ctx) => { if (ctx.context.user?.premium) { ctx.shouldKeep = true } }) }) ``` ```typescript [Nitro]server/plugins/evlog.ts import { definePlugin } from 'nitro' export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:emit:keep', (ctx) => { if (ctx.context.user?.premium) { ctx.shouldKeep = true } }) }) ``` ```typescript [Next.js]lib/evlog.ts import { createEvlog } from 'evlog/next' export const { withEvlog, useLogger, log, createError } = createEvlog({ service: 'my-app', keep: (ctx) => { if (ctx.context.user?.premium) { ctx.shouldKeep = true } }, }) ``` ```typescript [SvelteKit]src/hooks.server.ts import { createEvlogHooks } from 'evlog/sveltekit' export const { handle, handleError } = createEvlogHooks({ keep: (ctx) => { if (ctx.context.user?.premium) { ctx.shouldKeep = true } }, }) ``` ```typescript [TanStack Start]server/plugins/evlog.ts import { definePlugin } from 'nitro' export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:emit:keep', (ctx) => { if (ctx.context.user?.premium) { ctx.shouldKeep = true } }) }) ``` ```typescript [React Router]app/root.tsx import { evlog } from 'evlog/react-router' export const middleware: Route.MiddlewareFunction[] = [ evlog({ keep: (ctx) => { if (ctx.context.user?.premium) { ctx.shouldKeep = true } }, }), ] ``` ```typescript [Hono]src/index.ts import { evlog } from 'evlog/hono' app.use(evlog({ keep: (ctx) => { if (ctx.context.user?.premium) { ctx.shouldKeep = true } }, })) ``` ```typescript [Express]src/index.ts import { evlog } from 'evlog/express' app.use(evlog({ keep: (ctx) => { if (ctx.context.user?.premium) { ctx.shouldKeep = true } }, })) ``` ```typescript [Fastify]src/index.ts import { evlog } from 'evlog/fastify' await app.register(evlog, { keep: (ctx) => { if (ctx.context.user?.premium) { ctx.shouldKeep = true } }, }) ``` ```typescript [Elysia]src/index.ts import { evlog } from 'evlog/elysia' app.use(evlog({ keep: (ctx) => { if (ctx.context.user?.premium) { ctx.shouldKeep = true } }, })) ``` ```typescript [NestJS]src/app.module.ts import { EvlogModule } from 'evlog/nestjs' EvlogModule.forRoot({ keep: (ctx) => { if (ctx.context.user?.premium) { ctx.shouldKeep = true } }, }) ``` ```typescript [oRPC]server/orpc.ts import { withEvlog } from 'evlog/orpc' const handler = withEvlog(new RPCHandler(router), { keep: (ctx) => { if (ctx.context.user?.premium) { ctx.shouldKeep = true } }, }) ``` ```typescript [Cloudflare Workers]src/worker.ts import { withEvlog } from 'evlog/workers' export default withEvlog(handler, { keep: (ctx) => { if (ctx.context.user?.premium) { ctx.shouldKeep = true } }, }) ``` ```typescript [Standalone]src/index.ts import { initLogger } from 'evlog' initLogger({ 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 logger is sealed at this point. Nothing is printed yet: the console line waits for enrichers, so the terminal shows the same event drains receive. ### 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, parentSpanId) 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. Once they return, the event is printed to the console, pretty-formatted in development and as JSON in production. No configuration needed. The fields enrichers added (`userAgent`, `geo`, signal verdicts) are on that line, and on any platform that reads stdout. ### 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 [Nuxt]server/plugins/evlog.ts import { createAxiomDrain } from 'evlog/axiom' export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createAxiomDrain()) }) ``` ```typescript [Nitro]server/plugins/evlog.ts import { definePlugin } from 'nitro' import { createAxiomDrain } from 'evlog/axiom' export default definePlugin((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 [SvelteKit]src/hooks.server.ts import { createEvlogHooks } from 'evlog/sveltekit' import { createAxiomDrain } from 'evlog/axiom' export const { handle, handleError } = createEvlogHooks({ drain: createAxiomDrain() }) ``` ```typescript [TanStack Start]server/plugins/evlog.ts import { definePlugin } from 'nitro' import { createAxiomDrain } from 'evlog/axiom' export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createAxiomDrain()) }) ``` ```typescript [React Router]app/root.tsx import { evlog } from 'evlog/react-router' import { createAxiomDrain } from 'evlog/axiom' export const middleware: Route.MiddlewareFunction[] = [ evlog({ drain: createAxiomDrain() }), ] ``` ```typescript [Hono]src/index.ts import { evlog } from 'evlog/hono' import { createAxiomDrain } from 'evlog/axiom' app.use(evlog({ drain: createAxiomDrain() })) ``` ```typescript [Express]src/index.ts import { evlog } from 'evlog/express' import { createAxiomDrain } from 'evlog/axiom' app.use(evlog({ drain: createAxiomDrain() })) ``` ```typescript [Fastify]src/index.ts import { evlog } from 'evlog/fastify' import { createAxiomDrain } from 'evlog/axiom' await app.register(evlog, { drain: createAxiomDrain() }) ``` ```typescript [Elysia]src/index.ts import { evlog } from 'evlog/elysia' import { createAxiomDrain } from 'evlog/axiom' app.use(evlog({ drain: createAxiomDrain() })) ``` ```typescript [NestJS]src/app.module.ts import { EvlogModule } from 'evlog/nestjs' import { createAxiomDrain } from 'evlog/axiom' EvlogModule.forRoot({ drain: createAxiomDrain() }) ``` ```typescript [oRPC]server/orpc.ts import { withEvlog } from 'evlog/orpc' import { createAxiomDrain } from 'evlog/axiom' const handler = withEvlog(new RPCHandler(router), { drain: createAxiomDrain() }) ``` ```typescript [Cloudflare Workers]src/worker.ts import { withEvlog } from 'evlog/workers' import { createAxiomDrain } from 'evlog/axiom' export default withEvlog(handler, { drain: createAxiomDrain() }) ``` ```typescript [Standalone]src/index.ts import { initLogger } from 'evlog' import { createAxiomDrain } from 'evlog/axiom' initLogger({ 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. ## Know when each hook runs
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 console output and drain Add derived context to the event
3 evlog:drain After enrichment Send event to external services
## See where the error path diverges 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
## Follow a simple log call 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. ## Follow a standalone wide event 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 --- - [Wide Events](https://www.evlog.dev/learn/wide-events) - [Sampling](https://www.evlog.dev/learn/sampling) # Learn evlog > The mental model: three logging modes, the wide event lifecycle, sampling, typed fields, and redaction. Read it in order if you are new. 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. All three modes coexist in the same logger. Pick per call. There is no upgrade path, no advanced mode, no toggle to flip. Same drains, same redaction, same types underneath. 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 A fully-featured general-purpose logger. Replaces `console.log`, consola, pino, or winston with `log.info`, `log.error`, `log.warn`, `log.debug`, with the same level filtering, drain pipeline, redaction, and pretty/JSON output. Accumulate context over a unit of work (a script, job, queue task, or request) then emit a single comprehensive event. Auto-managed wide events scoped to HTTP requests. Framework middleware creates the logger and emits it for you. ## Compare the three at a glance ### 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. ```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 [Nuxt]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 }) ``` ```typescript [Nitro]routes/api/checkout.post.ts import { defineHandler } from 'nitro/h3' import { useLogger } from 'evlog/nitro/v3' export default defineHandler(async (event) => { const log = useLogger(event) log.set({ user: { id: 1, plan: 'pro' } }) return { success: true } // auto-emitted on response end }) ``` ```typescript [Next.js]app/api/checkout/route.ts import { withEvlog, useLogger } from '@/lib/evlog' export const POST = withEvlog(async () => { const log = useLogger() log.set({ user: { id: 1, plan: 'pro' } }) return Response.json({ success: true }) // auto-emitted on response end }) ``` ```typescript [Hono]src/index.ts app.post('/api/checkout', async (c) => { const log = c.get('log') log.set({ user: { id: 1, plan: 'pro' } }) return c.json({ success: true }) // auto-emitted on response end }) ``` ```typescript [Express]src/index.ts app.post('/api/checkout', async (req, res) => { const log = req.log log.set({ user: { id: 1, plan: 'pro' } }) return res.json({ success: true }) // auto-emitted on response end }) ``` `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. ## Choose the mode for the job
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 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 Pretty in dev, structured in CI, one summary event for the whole run
Published library createLogger only — never initLogger — see 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 One wide event per job run, perfect for retry analysis
Cloudflare Worker / edge function createWorkersLogger(req) or createRequestLogger — see Cloudflare Workers Per-request event, no process globals required
AWS Lambda initLogger once + createLogger per invocation — see 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 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 Host app owns initLogger / drain; packages use createLogger or accept a logger
None of these is an "upgrade" of another. Use `log` and `createLogger` in the same file when it makes sense, since they share the global drain, redaction, and types. ## What all three share 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 ## Where to go from here 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 find missing evlog patterns in supported entry points. --- - [Simple Logging](https://www.evlog.dev/learn/simple-logging) - [Wide Events](https://www.evlog.dev/learn/wide-events) - [Lifecycle](https://www.evlog.dev/learn/lifecycle) # Auto-Redaction > Automatically scrub PII from wide events before console output and drains. Built-in smart masking for credit cards, emails, IPs, phone numbers, JWTs, and more. 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. ## Turn it off, or narrow it If you need to disable redaction in production: ```typescript [Nuxt]nuxt.config.ts export default defineNuxtConfig({ modules: ['evlog/nuxt'], evlog: { redact: false, }, }) ``` ```typescript [Next.js]lib/evlog.ts import { createEvlog } from 'evlog/next' export const { withEvlog, useLogger } = createEvlog({ service: 'my-app', redact: false, }) ``` ```typescript [Standalone]src/index.ts import { initLogger } from 'evlog' initLogger({ env: { service: 'my-app' }, redact: false, }) ``` You can also enable redaction explicitly in development with `redact: true`. ## Mask enough to still debug 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
`127.0.0.1` and `0.0.0.0` are excluded from IPv4 masking since they are not real client addresses. ## Secrets by key name A password or an API token has no recognizable shape, so the `secrets` built-in looks at the **key** instead of the value. Any key ending in `password`, `passwd`, `secret` or `token` (case-insensitive, at any depth) has its value replaced with `replacement`, `[REDACTED]` by default:
Key Redacted
password , newPassword , user_password Yes
secret , clientSecret , client_secret Yes
token , accessToken , refresh_token , API_TOKEN Yes
totalTokens , tokensPerSecond , tokenType No
Secrets are replaced in full, never partially masked: unlike a card number, no part of a password is safe or useful to keep. Booleans and `null` are left alone, so a flag like `hasPassword: true` stays readable. It is still a safety net. Choose the fields you pass to `log.set()` rather than relying on it. ## Configuration ### Path Patterns Use a single `paths` array with dot-notation and globs. A bare segment like `password` is shorthand for `**.password`, so 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'] })`, the 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**, of 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, and 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. 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`. ## Where redaction runs in the pipeline 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. **Enrich**: enrichers add their fields to the masked event 6. **Console output**: the event printed to stdout 7. **Drain**: the same event sent to external services Redaction runs once, before enrichers. Fields an enricher adds afterwards are not scrubbed, so an enricher that reads a header or a session must mask what it writes. Stdout and drains receive the same event, so anything that needs to happen before output belongs in `transform` or a function-valued `replacement`. Redaction runs synchronously on the event loop when the event is emitted. Hono emits before it returns the response, so the caller waits for it, except for a streaming response, which Hono returns first and emits when the stream ends. Express emits once the response has finished. The cost of the built-in patterns grows linearly with the length of the values you log, whatever characters they contain. ## A configuration that ships Redaction is already on by default in production. Combine with sampling for a typical setup: ```typescript [Nuxt]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 [Next.js]lib/evlog.ts 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 [Standalone]src/index.ts import { initLogger } from 'evlog' initLogger({ env: { service: 'my-app' }, sampling: { rates: { info: 10, debug: 0 }, keep: [{ status: 400 }, { duration: 1000 }], }, }) ``` ## See what a redacted event looks like 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 --- - [Best Practices](https://www.evlog.dev/reference/best-practices) - [Configuration](https://www.evlog.dev/reference/configuration) # Sampling > Two-tier sampling: head sampling drops noise by level, and tail sampling keeps an event once its outcome is known. 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. 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: [https://www.evlog.dev/learn/sampling](https://www.evlog.dev/learn/sampling) Best practices: [https://www.evlog.dev/reference/best-practices](https://www.evlog.dev/reference/best-practices) ## Drop noise before it is built Head sampling randomly keeps a percentage of logs per level. It runs **before** the request completes, acting as a coin flip at emission time. ```typescript [Nuxt]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 [Next.js]lib/evlog.ts import { createEvlog } from 'evlog/next' export const { withEvlog, useLogger } = createEvlog({ service: 'my-app', sampling: { rates: { info: 10, warn: 50, debug: 0, error: 100, }, }, }) ``` ```typescript [Standalone]src/index.ts 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. The same rates apply to browser logs received through the [client ingest endpoint](https://www.evlog.dev/integrate/frameworks/nuxt#client-transport): a client event sampled out is dropped before enrichment and draining. Tail sampling does not apply to client events, since they carry no status or duration. Head sampling is random. A `10%` rate means roughly 1 in 10 info logs are kept, not exactly 1 in 10. ## Keep an event once you know its outcome 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). ### Conditions you can match on
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/**' )
## Combine head and tail sampling 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. ```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) ``` ## Write your own keep rule For conditions beyond status, duration, and path, use the `evlog:emit:keep` hook in Nuxt/Nitro or the `keep` callback in other frameworks. ```typescript [Nuxt]server/plugins/sampling.ts export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:emit:keep', (ctx) => { if (ctx.context.user?.plan === 'enterprise') { ctx.shouldKeep = true } }) }) ``` ```typescript [Next.js]lib/evlog.ts 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 [SvelteKit]src/hooks.server.ts import { createEvlogHooks } from 'evlog/sveltekit' export const { handle, handleError } = createEvlogHooks({ keep(ctx) { if (ctx.context.user?.plan === 'enterprise') { ctx.shouldKeep = true } }, }) ``` ```typescript [TanStack Start]server/plugins/evlog.ts import { definePlugin } from 'nitro' export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:emit:keep', (ctx) => { if (ctx.context.user?.plan === 'enterprise') { ctx.shouldKeep = true } }) }) ``` ```typescript [React Router]app/root.tsx import { evlog } from 'evlog/react-router' export const middleware: Route.MiddlewareFunction[] = [ evlog({ keep(ctx) { if (ctx.context.user?.plan === 'enterprise') { ctx.shouldKeep = true } }, }), ] ``` ```typescript [Standalone]src/index.ts import { evlog } from 'evlog/hono' app.use(evlog({ keep(ctx) { if (ctx.context.user?.plan === 'enterprise') { ctx.shouldKeep = true } }, })) ``` ```typescript [Express]src/index.ts import { evlog } from 'evlog/express' app.use(evlog({ keep(ctx) { if (ctx.context.user?.plan === 'enterprise') { ctx.shouldKeep = true } }, })) ``` ```typescript [Fastify]src/index.ts import { evlog } from 'evlog/fastify' await app.register(evlog, { keep(ctx) { if (ctx.context.user?.plan === 'enterprise') { ctx.shouldKeep = true } }, }) ``` ```typescript [Elysia]src/index.ts import { evlog } from 'evlog/elysia' app.use(evlog({ keep(ctx) { if (ctx.context.user?.plan === 'enterprise') { ctx.shouldKeep = true } }, })) ``` ```typescript [NestJS]src/app.module.ts import { EvlogModule } from 'evlog/nestjs' EvlogModule.forRoot({ keep(ctx) { if (ctx.context.user?.plan === 'enterprise') { ctx.shouldKeep = true } }, }) ``` ```typescript [oRPC]server/orpc.ts import { withEvlog } from 'evlog/orpc' const handler = withEvlog(new RPCHandler(router), { keep(ctx) { if (ctx.context.user?.plan === 'enterprise') { ctx.shouldKeep = true } }, }) ``` ```typescript [Cloudflare Workers]src/worker.ts import { withEvlog } from 'evlog/workers' export default withEvlog(handler, { 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
## A configuration that ships A typical production configuration that balances cost and visibility: ```typescript [Nuxt]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 [Next.js]lib/evlog.ts 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 [Standalone]src/index.ts 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/**' }, ], }, }) ``` 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 --- - [Best Practices](https://www.evlog.dev/reference/best-practices) - [Wide Events](https://www.evlog.dev/learn/wide-events) # Simple Logging > Use evlog for individual structured messages, with the same drains and redaction as wide events, and adapt existing logger call sites. 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. 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). 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') ``` `env.service` defaults to `'app'` if not specified. Only set it if you want a custom service name. `log` also works with no `initLogger()` call at all: every call prints with the defaults. That is fine for a scratch script, and it is how the default import below behaves. In production it means no redaction and no drain, so the first emitted event logs a one-time warning pointing at `initLogger({ drain })`. ## Call it two ways ### 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 ``` ### Interpolate values into the message When the message contains `%s`, `%d`, or `%j` and extra arguments follow, evlog formats it before emitting. Call sites that come from pino or consola keep their printf style instead of becoming template strings: ```typescript [src/index.ts] log.info('auth', 'User %s logged in from %s', 'alice', '10.0.0.7') log.info('payment', 'Retrying charge for order %d', 4091) log.info('debug', 'Payload %j', { orderId: 4091, total: 9999 }) ``` ```bash [Output (Pretty)] 10:23:45.612 [auth] User alice logged in from 10.0.0.7 10:23:45.613 [payment] Retrying charge for order 4091 10:23:45.614 [debug] Payload {"orderId":4091,"total":9999} ``` The same form works without a tag: when the first string contains a specifier, evlog treats it as the message and the call as untagged. ```typescript [src/index.ts] log.info('user %s logged in', 'alice') // emits [log] user alice logged in ```
Specifier Formats the argument as
%s string, objects and arrays as JSON
%d number, coerced with Number() , bigints as 10n
%j JSON, with a string fallback for values JSON cannot serialize
Interpolation runs only when extra arguments are present. A message with a `%` and no arguments is logged unchanged; once arguments are involved, write `%%` for a literal percent. Formatting applies to the message string only. Field values in a structured event are never treated as format input. ### 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 ``` **Tagged logs** are optimized for console readability. **Structured events** (object form) produce full wide events that flow through the drain pipeline to external services. ## Choose a level, and what it costs
Level Method When to use
fatal log.fatal() Unrecoverable failures: the request or process cannot continue. Always kept, even when sampling would drop it
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
trace log.trace() Very fine-grained details below debug . Dropped by default; opt in with sampling.rates.trace
`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. ## Patterns worth copying ### 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 } } ``` ## Send these logs to a drain 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. ```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, with no formatter, transport, or peer-dep wiring: ```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. If you want the drop-in feel of `import pino from 'pino'` while you evaluate evlog, the default export is the same `log` API with no setup line: ```typescript [src/index.ts] import logger from 'evlog' logger.info('checkout', 'Starting checkout') logger.info({ event: 'checkout_started' }) ``` Every call prints immediately. Once you want a drain, redaction, or wide events, add the `initLogger` line and keep calling the same methods; the default import and `log` are the same object, so nothing else moves. 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, so 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](https://www.evlog.dev/learn/wide-events) - [Configuration](https://www.evlog.dev/reference/configuration) # Structured Errors > Create errors that explain why they occurred and how to fix them. Add actionable context with why, fix, and link fields for humans and AI agents. evlog provides a `createError()` function that creates errors with rich, actionable context. 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: [https://www.evlog.dev/learn/structured-errors](https://www.evlog.dev/learn/structured-errors) ## Why Structured Errors? Traditional errors are often unhelpful: ```typescript [services/checkout.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: ```typescript [services/checkout.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" } } ``` ## Say why it broke and how to fix it
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)
data No Extra payload returned to the client (see below)
internal No Backend-only context (see below)
## Extra payload for the client (`data`) `why`, `fix`, and `link` are strings a human reads. When the client needs values it branches on, an order id, a retry deadline, a list of invalid fields, pass them as `data`: ```typescript throw createError({ code: 'PAYMENT_DECLINED', message: 'Payment could not be completed', status: 402, fix: 'Try another payment method', data: { orderId: 'ord_8x2k', retryAfter: 30, }, }) ``` The payload is merged into the response body's `data` object next to `code`, `why`, `fix`, and `link`, so the client reads it as one object: ```typescript const { data } = parseError(error) if (typeof data?.retryAfter === 'number') scheduleRetry(data.retryAfter) ``` Those four guidance keys win on a collision, so `data: { code: 'x' }` never overwrites the `code` you passed. This is the same field h3's `createError` uses, and evlog's Nitro error handler forwards it for h3 errors too. Anything the client must never see goes in `internal` instead. ## 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**. ## Throw an error that explains itself ### Simple Error ```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 ```typescript [services/checkout.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 [services/checkout.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 }) } ``` ## How errors are logged An `Error` you put in a log is serialized into the event, the same way `log.error(err)` serializes it. The server and the browser share this serializer. - `log.error(err)`, and an `Error` anywhere inside a `log.*({ ... })` event, is logged with its `name`, `message`, `stack`, its own fields, and `cause`. Cycles are logged as `'[Circular]'`. - On the server, `log.set()` serializes an `Error` the same way. The browser logger has no `log.set()`. - `why`, `fix` and `link` are logged on any error that carries them, not only on `EvlogError`. - An object (not an `Error`) that defines its own `toJSON` is kept as it is, so that method decides how it is written out. In the browser, an `Error` in an event used to be logged as `{}`. Now it is logged with its fields: ```typescript [pages/checkout.vue] const err = Object.assign(new Error('connection reset'), { code: 'ECONNRESET' }) log.error({ action: 'checkout', error: err }) ``` ```json [Logged event (trimmed)] { "action": "checkout", "error": { "name": "Error", "message": "connection reset", "stack": "Error: connection reset\n at ...", "code": "ECONNRESET" } } ``` ### Errors that wrap another error h3 keeps the original error as `cause` and takes over its message and stack when you pass that error to `createError`. evlog logs the pair once: the original error, with the wrapper's `statusCode`, `statusMessage` and `data` added. The message, stack and `cause` are not repeated. ```typescript [server/api/checkout.post.ts] import { createError } from 'h3' try { await stripe.charges.create(charge) } catch (err) { throw createError(err) // the logged error has statusCode 500 } ``` When you pass an options object instead, h3 keeps that object as `cause`. evlog drops it when every field in it is already on the error, so the logged error keeps `statusCode: 402` and has no `cause`: ```typescript [server/api/pay.post.ts] throw createError({ statusCode: 402, message: 'Payment could not be completed' }) ``` ### Custom `toJSON` A custom error can define its own `toJSON`. For an `Error`, evlog reads it only to decide which payload fields to leave out: - If `toJSON` leaves out the `data` or `internal` key, that field is not logged. - If `toJSON` keeps the key but changes its value (for example `data: '[REDACTED]'` or `data: undefined`), the raw value is still logged. So do not use `toJSON` to redact. Use [Redaction](https://www.evlog.dev/learn/redaction) instead. `EvlogError` keeps `internal` in logs even though its `toJSON`, which shapes the HTTP response, leaves it out. ## 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. ```typescript [services/checkout.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. ```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. ## Show the user what they can act on Use `parseError()` to extract all fields from caught errors: ```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 ```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 ```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. ```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. ```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. **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. **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. 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 --- - [Wide Events](https://www.evlog.dev/learn/wide-events) - [Best Practices](https://www.evlog.dev/reference/best-practices) # Typed Fields > Add compile-time type safety to your wide events with TypeScript module augmentation. Prevent typos and ensure consistent field names across your codebase. 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. ## 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 then catches a typo or an unknown field at compile time, which is the only place a field-name mistake is cheap: once the event is in your drain the wrong key is already indexed, already queried, and already in someone's dashboard. ## Internal Fields Some fields evlog sets itself. `status` and `service` are always accepted whatever your type says, through `InternalFields`: ```typescript [services/checkout.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 [Nuxt]server/api/example.ts import { useLogger } from 'evlog' const log = useLogger(event) log.set({ anything: true, nested: { deep: 'value' } }) // OK ``` ```typescript [Next.js]app/api/example/route.ts import { useLogger } from '@/lib/evlog' const log = useLogger() log.set({ anything: true, nested: { deep: 'value' } }) // OK ``` ```typescript [Hono]src/index.ts const log = c.get('log') log.set({ anything: true, nested: { deep: 'value' } }) // OK ``` ```typescript [Express]src/index.ts const log = req.log log.set({ anything: true, nested: { deep: 'value' } }) // OK ``` Typed fields are fully opt-in. ## Nuxt Auto-Import Typed fields with `useLogger` need an explicit import. The auto-import cannot carry excess property checking through a generic, a TypeScript limitation rather than a module one. ```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) ``` Untyped usage keeps the auto-import. Add the explicit one only where you pass a generic. ## Outside Nuxt The same generic works with `createRequestLogger` and `createWorkersLogger`: ```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 --- - [Wide Events](https://www.evlog.dev/learn/wide-events) - [Best Practices](https://www.evlog.dev/reference/best-practices) # Wide Events > Accumulate context over any unit of work and emit a single comprehensive event. Works for HTTP requests, scripts, background jobs, queue workers, and workflows. A wide event is one structured log event per unit of work, such as an HTTP request, a script run or a queue job. It carries every field that work touched: who called, what they sent, what happened and how it ended. The same pattern is known as [canonical log lines](https://stripe.com/blog/canonical-log-lines) at Stripe. 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. 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), because wide events apply just as cleanly outside of request lifecycles. 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: [https://www.evlog.dev/learn/wide-events](https://www.evlog.dev/learn/wide-events) Best practices: [https://www.evlog.dev/reference/best-practices](https://www.evlog.dev/reference/best-practices) ## Why Wide Events? 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: ```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. ## Accumulate context, emit once ### `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() ``` 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 [Nuxt]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 }) ``` ```typescript [Nitro]routes/api/checkout.post.ts import { defineHandler } from 'nitro/h3' import { useLogger } from 'evlog/nitro/v3' export default defineHandler(async (event) => { const log = useLogger(event) log.set({ user: { id: 1, plan: 'pro' } }) log.set({ cart: { items: 3, total: 9999 } }) return { success: true } // auto-emitted on response end }) ``` ```typescript [Next.js]app/api/checkout/route.ts import { withEvlog, useLogger } from '@/lib/evlog' export const POST = withEvlog(async () => { const log = useLogger() log.set({ user: { id: 1, plan: 'pro' } }) log.set({ cart: { items: 3, total: 9999 } }) return Response.json({ success: true }) // auto-emitted on response end }) ``` ```typescript [Hono]src/index.ts app.post('/api/checkout', async (c) => { const log = c.get('log') log.set({ user: { id: 1, plan: 'pro' } }) log.set({ cart: { items: 3, total: 9999 } }) return c.json({ success: true }) // auto-emitted on response end }) ``` ```typescript [Express]src/index.ts app.post('/api/checkout', async (req, res) => { const log = req.log log.set({ user: { id: 1, plan: 'pro' } }) log.set({ cart: { items: 3, total: 9999 } }) return res.json({ success: true }) // auto-emitted on response end }) ``` `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 the forked logger. When `fn` completes (or throws), that logger 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. `log.fork()` returns a promise that resolves once the child event has drained, and it never rejects. Leave it unawaited for background work. Await it when the child carries a write that must land before the response, such as an audit event routed to `auditOnly(sink, { await: true })`: ```typescript await log.fork('orders.cancel', async () => { useLogger().audit({ action: 'orders.cancelled', actor: { type: 'user', id: user.id }, target: { type: 'order', id: order.id }, }) }) ``` With `waitUntil` set (Next.js, Cloudflare Workers, Vercel Edge), an unawaited fork is registered with it when you call `log.fork()`, so the runtime stays alive until the child drains. **Not available yet:** Hono (no `useLogger` without `c.get('log')` + ALS) and Nitro/Nuxt `useLogger(event)`, so 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 }) }) ``` ## Read a wide event field by field 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: ```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', }) ``` 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 [services/checkout.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 [services/checkout.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: ```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 [services/orders.ts] // Avoid generic keys log.set({ data: { id: 123 } }) // Use specific, descriptive keys log.set({ order: { id: 123, status: 'pending' } }) ``` ### Group Related Data ```typescript [services/checkout.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: ```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: ```typescript [Nuxt]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 } }) ``` ```typescript [Nitro]routes/api/checkout.post.ts import { defineHandler } from 'nitro/h3' import { useLogger } from 'evlog/nitro/v3' export default defineHandler(async (event) => { const log = useLogger(event) 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 } }) ``` ```typescript [Next.js]app/api/checkout/route.ts import { withEvlog, useLogger } from '@/lib/evlog' export const POST = withEvlog(async () => { const log = useLogger() try { const result = await processPayment(cart) return Response.json(result) } catch (err) { log.set({ error: { message: err.message, code: err.code, type: err.constructor.name, }, }) throw err } }) ``` ```typescript [Hono]src/index.ts app.post('/api/checkout', async (c) => { const log = c.get('log') try { const result = await processPayment(cart) return c.json(result) } catch (err) { log.set({ error: { message: err.message, code: err.code, type: err.constructor.name, }, }) throw err } }) ``` ```typescript [Express]src/index.ts app.post('/api/checkout', async (req, res) => { const log = req.log try { const result = await processPayment(cart) return res.json(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. ## Choose how the event is rendered evlog automatically switches between formats based on environment: pretty in development, JSON in production. This is the default behavior, no configuration needed. ```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" } } ``` ## FAQ Structured logging is the format: key-value fields, usually JSON, instead of free text. A wide event is a structured log with a scope. One unit of work produces one event, so the fields that would be spread over several lines sit on the same record and can be queried together. No. A wide event is a log record, while a span is a timed operation inside a trace. evlog does not create spans or metrics. It can attach the incoming `traceparent` to the event and export it over OTLP, so the event lands next to the spans your stack already emits ([evlog and OpenTelemetry](https://www.evlog.dev/compare/opentelemetry)). Usually less. Line-per-step logging repeats the level, timestamp and request context on every line. On the checkout request measured in [Log cost](https://www.evlog.dev/reference/cost), one event is 56% fewer bytes and 75% fewer events than four lines. To cut volume further, [sampling](https://www.evlog.dev/learn/sampling) drops routine events and can keep every error and slow request. With a framework integration, the middleware creates a logger per request and emits it when the response ends, including when the handler throws. Next.js navigation signals such as `redirect()` and `notFound()` are rethrown without an event. `useLogger(event)` retrieves that logger. In scripts and jobs, `createLogger()` gives you the same logger and you call `log.emit()` yourself. After emit the logger is sealed, and `log.fork()` covers background work that needs its own event. IDs and business context grouped by entity (`user`, `cart`, `payment`), plus the outcome. Log IDs rather than whole objects, and leave secrets out. Redaction is on by default in production once `initLogger()` has run, so a script that only calls `createLogger()` has none ([Redaction](https://www.evlog.dev/learn/redaction)). [Best Practices](https://www.evlog.dev/reference/best-practices) covers naming and what to skip. ## 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](https://www.evlog.dev/learn/structured-errors) - [Best Practices](https://www.evlog.dev/reference/best-practices) # evlog agents > Teach the agents working in your repository how to use evlog: a short block of conventions in AGENTS.md, plus the published skills. 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 ✓ installed the evlog skills for Claude Code, Codex and the others ran npx --yes skills add https://www.evlog.dev --agent universal --agent claude-code --global --yes ``` ## 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` is not written. Most agents read `AGENTS.md` directly, and Claude Code does too unless a `CLAUDE.md` sits above it. If you want an explicit pointer, add a one-line `@AGENTS.md` yourself. An existing `CLAUDE.md` is never touched. The block is short on purpose, because 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, and 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), 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 ✓ skills already installed · .agents/skills, .claude/skills npx skills update to refresh them ``` Interactively, the skills CLI can still ask questions of its own, so `evlog agents` hands over the terminal rather than answering on your behalf. The agents are passed explicitly (the shared `.agents/skills` directory, which Codex and the other agents that read it use, and Claude Code), so the skills CLI does not install into every agent it detects. 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 --agent universal --agent claude-code --global`, plus `--skill` when you asked for a selection, `--local` in place of `--global` when you asked for a project install, and a trailing `--yes` when the run is non-interactive. 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) at run time. That is the point, since skills guidance tracks the docs site rather than 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. `--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`, which never touches 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`. ## 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 ; skip only the skill installation, so nothing is spawned
--local Install the skills into this project instead of for every project (the default is global)
--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
Earlier versions took `--global`. Global is now the default, so that flag is gone. ```bash [Terminal] evlog agents --skills review-logging-patterns evlog agents --local evlog agents --no-skills evlog agents --dry-run ``` If the skills CLI fails, from no network or 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` write lands in the same plan as the evlog wiring, in one list with 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 --- - [Agent Skills](https://www.evlog.dev/reference/agent-skills) # evlog map in CI > Gate a pull request on your observability score with --min-score. The JSON contract, exit codes, GitHub Actions, and jq recipes. A score you look at once is a nice afternoon. A score in CI is what stops the next handler from shipping dark. ## Fail the build on a regression `--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: [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. **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 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, which suits a pull request that 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. ## What each exit code means
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
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. ## Run it in GitHub Actions The [evlog action](https://github.com/evloghq/action) runs `evlog map`, compares the pull request against its base, draws each finding on the diff, writes the report to the job summary, and keeps one comment on the pull request up to date: ```yaml [.github/workflows/observability.yml] name: Observability on: pull_request permissions: contents: read pull-requests: write jobs: map: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - uses: evloghq/action@v1 with: min-score: 80 ``` Nothing has to be committed and no token has to be created. On a pull request the action checks the base branch out next to the workspace and scans it, so a check that used to pass and no longer does fails the job and lands as an error on the line that broke it. `pull-requests: write` is only for the comment; without it the action says so in a notice and the annotations and summary still stand, which is what pull requests from forks get. Each action release pins the `@evlog/cli` it was tested with, so `@v1` is deterministic: a CLI release never moves a verdict under your gate, and upgrading the action is the pull request where a moved score is the point. The inputs, outputs (`score`, `delta`, `regressions`, `passed`, `results`) and recipes are in the [action's README](https://github.com/evloghq/action#readme). ### Without the action The CLI does the same from a `run:` step, for workflows that take no third-party actions: ```yaml [.github/workflows/observability.yml] - uses: actions/setup-node@v5 with: node-version: 22 - run: npx evlog map --format github --min-score 80 --no-write ``` `--format github` writes [workflow commands](https://docs.github.com/en/actions/reference/workflow-commands-for-github-actions) to stdout, so the runner puts each finding on its line in the pull request diff rather than in the job log. The human report still goes to stderr, and the exit code is the same, so `--min-score` and `--baseline` still fail the job. `--no-write` keeps the job from producing an `evlog.map.json` nobody will read. With `--baseline`, the findings are the regressions, as errors: the checks this pull request broke, nothing that was already on the map. `git:` reads a committed `evlog.map.json` at that ref, which is the one thing the action spares you: ```yaml [.github/workflows/observability.yml] - run: npx evlog map --format github --baseline git:origin/main --no-write ``` ```text ::error file=src/checkout.ts,line=12,title=evlog map%3A audit::has logger + context but no log.audit() — sensitive route needs audit trail ::error title=evlog map::score 74/100 (good): 18 instrumented, 2 partial, 9 dark; regressed against git:origin/main ``` Without a baseline, the findings are the report's FIX FIRST list, worst entry point first, as warnings. The list stops at `--limit` (default 10, which is how many annotations GitHub keeps per step before dropping the rest), and the closing notice says how many were left out: ```text ::warning file=src/payments.ts,line=3,title=evlog map%3A audit::has logger + context but no log.audit() — sensitive route needs audit trail ::warning file=src/health.ts,line=5,title=evlog map%3A wide-event::handler adds nothing to its request event — only method, path and status are recorded ::notice title=evlog map::score 76/100 (good): 19 instrumented, 2 partial, 8 dark; 13 more findings not shown ``` An annotation is only drawn inline when its line is part of the diff; the full list is under the job's Annotations on the Checks tab. ### 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. The action pins the CLI for you. Running the CLI yourself, without `@evlog/cli` installed, the `evlog` executable fetches it on every run, so the job would follow the latest release. Install it 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`. ## Consume the JSON output `--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: ```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' ``` ## Where the map file lives 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 ```
`--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. ## Gate every package in a monorepo `evlog map` scans one app at a time. The action takes one directory or glob per line and scans each as its own package, with its own row in the report and paths that resolve from the repository root: ```yaml [.github/workflows/observability.yml] - uses: evloghq/action@v1 with: packages: | apps/* packages/api min-score: 70 ``` With the CLI alone, a matrix does the same and lets each app carry its own threshold, which is usually what you want: the app that takes payments should be held higher than the marketing site. ```yaml [.github/workflows/observability.yml] jobs: map: runs-on: ubuntu-latest strategy: matrix: include: - { app: apps/web, min: 80 } - { app: apps/admin, min: 60 } steps: - uses: actions/checkout@v5 - uses: actions/setup-node@v5 with: node-version: 22 - run: npx evlog map --cwd ${{ matrix.app }} --min-score ${{ matrix.min }} --no-write ``` ## 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 --- - [Scoring](https://www.evlog.dev/cli/scoring) - [Rules](https://www.evlog.dev/cli/rules) # evlog.config > One evlog.config.ts sets what evlog map checks, where evlog logs reads, and the sampling, redaction and drains your app applies. Without a config file, the CI gate is a flag on every `evlog map` run, a check you decided not to care about is disabled file by file, and sampling and redaction live in whichever file calls `initLogger`. `evlog.config.ts` holds all of it, and a preset carries it from one repository to the next. The CLI reads the file without running it. The app runs it: the Nuxt and Nitro modules load it on their own, and any other app imports it. Here an app builds on a shared preset, turns a check back on, and leaves its dev routes out of the map: ```ts [evlog.config.ts] import { defineEvlog } from 'evlog' import { createAxiomDrain } from 'evlog/axiom' import preset from './evlog.preset' export default defineEvlog({ extends: preset, service: 'checkout', drain: createAxiomDrain(), sampling: { rates: { info: 25 } }, map: { rules: { 'audit-coverage': 'on' }, ignore: ['src/routes/_dev/**'], }, logs: { limit: 100 }, }) ``` ```ts [evlog.preset.ts] import { defineEvlog } from 'evlog' export default defineEvlog({ sampling: { rates: { info: 10, debug: 0 } }, redact: { paths: ['user.password', 'card.number'] }, map: { rules: { 'error-catalog': 'off', 'audit-coverage': 'off' }, minScore: 70 }, }) ``` `evlog config` prints the merged result grouped by what each setting does, with the line it is written on, and fills in what evlog uses where the file says nothing: ```bash [Terminal] evlog config ``` ```text [Output] evlog.config.ts · extends ./evlog.preset → evlog.preset.ts Service service checkout evlog.config.ts:7 environment from NODE_ENV default Sampling trace 0% default debug 0% evlog.preset.ts:4 info 25% evlog.config.ts:9 warn 100% default error 100% default fatal always default Redaction redact on evlog.preset.ts:5 builtins creditCard, email, ipv4, phone, jwt, bearer, iban, secrets default paths user.password evlog.preset.ts:5 card.number evlog.preset.ts:5 Pipeline drain createAxiomDrain() evlog.config.ts:8 CLI · read by evlog map and evlog logs map.rules.error-catalog 'off' evlog.preset.ts:6 map.rules.audit-coverage 'on' evlog.config.ts:11 map.minScore 70 evlog.preset.ts:6 map.ignore ['src/routes/_dev/**'] evlog.config.ts:12 logs.limit 100 evlog.config.ts:14 ``` Each redaction path keeps the line of the file that adds it, so a path the preset redacts never looks like the app's own. With `routes`, the Service section becomes Services and lists each route's service in the order evlog matches them, the first match winning. A rate that changes nothing gets a warning under its row, such as a `fatal` rate, since fatal events are always kept. A value the file computes, like `createAxiomDrain()`, is shown as the code that produces it. `--json` returns the settings written in the files as `cli` and `app` lists of `{ path, value, source }`, one entry per item of `redact.paths`, `redact.patterns` and `sampling.keep`, with a computed value written as `{ "runtime": "createAxiomDrain()" }` and a regular expression as `{ "regexp": "/acct_\\w+/g" }`. `moduleOptions` is `{ file, readable }` when `nuxt.config.ts` or `nitro.config.ts` passes options to the evlog module, with `readable` false when they are computed at runtime, and `null` otherwise. A setting those options replace carries `overrides: { value, source }` with the value of the file. ## Where evlog finds the file The CLI and the Nuxt and Nitro modules look for `evlog.config.ts`, `evlog.config.mts`, `evlog.config.js` and `evlog.config.mjs`, in that order, starting in the app's package and walking up to the workspace root. The first file found applies on its own. Configs do not cascade, so an app with its own `evlog.config.ts` ignores the one at the root unless it extends it. Paths in `map.ignore`, `map.baseline` and `logs.dir` are relative to the package being mapped or read, not to the config file. One config at the root of a monorepo therefore fits every app in it. ## Gate the map from the config `evlog map` reads the `map` section, and a flag passed on the command line wins over the same setting.
Setting Accepts Flag What it does
map.rules { [id]: 'on' | 'off' } none Turns a check off for every entry point, or back on when the preset turned it off
map.ignore list of globs none Leaves the entry points whose file matches out of the map
map.minScore whole number from 0 to 100 --min-score Exits 1 when the global score is below it
map.baseline true , a path, or git: --baseline Exits 1 on a regression against the committed map, true meaning evlog.map.json
`map.ignore` matches the file an entry point is declared in. A Hono app that declares several routes in one file leaves them all out with one glob, and cannot leave out one of them alone. The ids are the ones on [Rules](https://www.evlog.dev/cli/rules). Every check can be turned off except `wide-event` and `context`, because the map sorts entry points into instrumented, partial and dark by them. Leave those entry points out with `map.ignore` instead. A check turned off in the config becomes `n/a` on every entry point, with `turned off in evlog.config` as its message in `--json`. The report says what the config changed above the score, and names the setting the gate came from: ```text [Output] evlog.config.ts: error-catalog off, 1 entry point ignored █▀█ ▀▀█ score /100 checkout · Hono █▀█ ▀█ ▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▱▱▱ 2 entry points scanned ▀▀▀ ▀▀▀ good ▆█ GATE score 83 meets map.minScore 70 — exit code 0 ``` `evlog map --min-score 95` on the same project gates on 95 and says `--min-score 95`. To turn a check off for one handler rather than the whole project, keep using a [disable comment](https://www.evlog.dev/cli/rules#disabling-a-check) next to the code. ## Read logs from another directory `evlog logs` reads the `logs` section, and its flags win the same way.
Setting Accepts Flag What it does
logs.dir a path --dir Reads this directory instead of .evlog/logs
logs.limit whole number of 1 or more --limit Shows at most this many events
`--format`, `--verbose`, and `--limit` on `evlog map` stay flags only. They describe one run, not the project. ## Write values the CLI can read The CLI parses `evlog.config.ts` and never runs it, so every value under `map` and `logs` has to be a literal, a `const`, or a value imported from a local file. A call, an environment variable, or a value imported from a package stops the command: ```text [Output] logs.limit in evlog.config.ts:14 is computed at runtime → Write the value inline, as a const, or import it from a local file ``` The rest of the file is for the app and can compute anything: a drain, an `enrich` function, a sampling rate read from `process.env`. The CLI lists those values without evaluating them. ## Share settings with `extends` `extends` takes another config, imported from a local file or from a package. A preset published to npm is an ordinary module whose default export is `defineEvlog({ ... })`, so a team installs it and extends it: ```ts [evlog.config.ts] import { defineEvlog } from 'evlog' import preset from '@acme/evlog-preset' export default defineEvlog({ extends: preset, service: 'checkout', }) ``` The CLI follows the package's `exports` to the file it ships and reads it the same way, so the preset's `map` and `logs` have to be literals too. Settings merge by kind:
In the config Result
A scalar or a function: service , drain , enrich , keep The config's value replaces the preset's
An object: sampling.rates , routes , env , map.rules Merged key by key, the config winning on each key
redact.paths , redact.patterns , sampling.keep The preset's entries, then the config's
Any other list: map.ignore , include , exclude The config's list replaces the preset's
plugins Merged by name , a config plugin replacing the preset plugin of the same name
redact: false Redaction off
redact: true The preset's redact settings, unchanged
Redaction paths and kept events add up rather than being replaced, so an app that lists its own paths cannot drop the ones the preset redacts. A config extends one level only. When `evlog.preset.ts` itself extends a config, extending it fails and names both files: ```text [Output] ./evlog.preset extends another config, so evlog.config.ts:6 cannot extend it → Extend the config it extends directly, or copy the settings you need into one of the two files ``` Every setting is then at most one file away from where it applies, and `evlog config` names that file. `extends` takes the config itself, the value the app merges at runtime, so a path string is refused rather than resolved: ```text [Output] extends in evlog.config.ts:4 is the path './evlog.preset', not a config → Import the config from that path as base, then set extends: base ``` ### Publish a preset for your organization A preset package is one module and a `package.json`. Ship it as JavaScript, so every app can bundle it without compiling a dependency, and list `evlog` as a peer so the preset and the app share one copy: ```json [package.json] { "name": "@acme/evlog-preset", "type": "module", "exports": "./index.mjs", "peerDependencies": { "evlog": ">=2.31.0" } } ``` ```js [index.mjs] import { defineEvlog } from 'evlog' export default defineEvlog({ sampling: { rates: { info: 10 } }, redact: { paths: ['user.email', 'card.number'] }, map: { minScore: 70 }, }) ``` Every repository that extends it starts from the same redaction, sampling and CI gate. A new rule rolls out as a version bump of the preset, and an app that needs an exception writes it in its own config, where `evlog config` shows it next to the setting it overrides. ## Use the config in your app The app runs the file, so the values the CLI only lists, a drain, an `enrich` function, a rate read from `process.env`, apply there. `map` and `logs` are left out. How the file reaches the app depends on the framework:
Framework How the config applies
Nuxt, Nitro, TanStack Start The evlog module finds and loads it
Eve agents defineEvlogHook(config) in agent/hooks/evlog.ts
Next.js createEvlog(config) and createInstrumentation(toLoggerConfig(config))
Any other framework toLoggerConfig(config) where you call initLogger , toMiddlewareOptions(config) where you register the middleware
### Nuxt and Nitro load it for you The module looks the file up the same way the CLI does and bundles it into the server, so `import.meta.dev` and `process.env` work in it as they do in server code. Options passed to the module override the file, merged with the `extends` rules: ```ts [nuxt.config.ts] export default defineNuxtConfig({ modules: ['evlog/nuxt'], evlog: { sampling: { rates: { info: 100 } }, }, }) ``` This app keeps every info event whatever the file says, and the other rates in the file still apply. `evlog config` reads these options too. A note under the file name says they override it, and each setting they replace shows the value that wins, the line that sets it, and the value of the file under it: ```text [Output] evlog.config.ts · extends ./evlog.preset → evlog.preset.ts nuxt.config.ts passes options to the evlog module, and they override this file Sampling trace 0% default debug 0% evlog.preset.ts:4 info 100% nuxt.config.ts:4 overrides 25 at evlog.config.ts:9 warn 100% default error 100% default fatal always default ``` Options computed at runtime, like a value read from a function call, keep the note but are not listed, since `evlog config` reads the files without running them. The `drain`, `enrich` and `keep` of the file run next to the `evlog:drain`, `evlog:enrich` and `evlog:emit:keep` hooks. A server plugin hooked there keeps working, and an event reaches both drains. A drain wrapped in [`createDrainPipeline`](https://www.evlog.dev/extend/drain-pipeline) is flushed when the server closes, so the file needs no `close` hook. On Nuxt the browser logger also takes `enabled`, `pretty` and `minLevel` from the file. The `evlog` key in `nuxt.config.ts` and the `NUXT_PUBLIC_EVLOG_*` variables still win, so `NUXT_PUBLIC_EVLOG_MIN_LEVEL=debug` lowers the threshold of a single deployment. The browser reads `console` and `transport` from `nuxt.config.ts` only. ### Other apps import it `toLoggerConfig` keeps the options `initLogger` takes, and `toMiddlewareOptions` keeps the ones a framework middleware takes: ```ts [src/index.ts] import { Hono } from 'hono' import { initLogger, toLoggerConfig, toMiddlewareOptions } from 'evlog' import { evlog, type EvlogVariables } from 'evlog/hono' import config from '../evlog.config' initLogger(toLoggerConfig(config)) const app = new Hono() app.use(evlog(toMiddlewareOptions(config))) ``` [`evlog init`](https://www.evlog.dev/cli/init#per-framework) writes this wiring for Next.js, Hono, Express and Fastify. An Eve agent spreads the config into its hook and adds its own options, see [Eve](https://www.evlog.dev/use-cases/eve). The [`evlog/vite`](https://www.evlog.dev/reference/vite-plugin) plugin reads the serializable settings from the file (`service`, `environment`, `sampling` and the rest). Its auto-init is serialized at build time and cannot carry a drain, so import the config where you call `initLogger` too. ## When the config cannot be read `evlog map` and `evlog config` exit 1 on a config they cannot read, and `evlog logs` exits 2. [`evlog doctor`](https://www.evlog.dev/cli/doctor) reports the same error as a failing `config` check. Each error carries a code from the CLI's catalog:
Code Raised when
cli.CONFIG_PARSE_FAILED The file has a syntax error
cli.CONFIG_NO_EXPORT There is no default export of an object or defineEvlog({ ... })
cli.CONFIG_NOT_STATIC The default export, extends , or a map or logs value is computed at runtime
cli.CONFIG_INVALID A setting is misspelt, has the wrong type, or turns off wide-event or context
cli.CONFIG_EXTENDS_NOT_FOUND The extends import does not lead to a file
cli.CONFIG_EXTENDS_DEPTH The extended config extends another one
cli.CONFIG_EXTENDS_STRING extends is a path string instead of an imported config
A misspelt key is an error rather than a setting quietly ignored: ```text [Output] logs.limt in evlog.config.ts:14 is not a setting; expected dir, limit → Use a setting and a value the config reference lists ``` ## Next - [Rules](https://www.evlog.dev/cli/rules): the ids `map.rules` takes, and what each check expects - [CI](https://www.evlog.dev/cli/ci): gate a pull request on the score --- - [Map rules](https://www.evlog.dev/cli/rules) - [Sampling](https://www.evlog.dev/learn/sampling) # evlog doctor > Diagnose an evlog setup — Node version, project resolution, the installed evlog version, the detected framework, and whether local logs are being written. `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 22 or later Older than 22 — 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
config evlog.config.ts was read, and the config it extends was found The file cannot be read: a syntax error, a misspelt setting, or a CLI setting computed at runtime (fail). The check is omitted when there is no config
logs A local drain exists, or the fs drain is wired (the directory appears on first write) Never — the check is omitted when no local drain 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 drain 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 drain before any event. A project with no local drain gets no `logs` check at all. ## What the exit code means `0` when nothing failed, `1` when any check failed. Warnings do not fail the command, because a project that has not written logs yet is not broken. ## Read the result as 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 --- - [Installation](https://www.evlog.dev/start/installation) # evlog init > Wire evlog into an existing app. Installs the package, registers the integration, and writes evlog.config.ts with the destinations you pick. `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 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 │ │ create evlog.config.ts │ │ update nuxt.config.ts │ │ create .env.example │ │ │ ├───────────────────────────╯ │ ◇ Apply? │ Yes │ ◇ 4 ok · 1 warn · 0 fail │ ◇ Set the drain environment variables — .env ────────╮ │ │ │ AXIOM_DATASET= # dataset to write to │ │ AXIOM_API_KEY= # API token with ingest permission│ │ │ │ Axiom and Sentry send nothing until these exist. │ │ Put the values in .env, or your hosting provider's │ │ environment settings │ │ (Axiom: https://evlog.dev/integrate/adapters/cloud │ │ /axiom · Sentry: https://evlog.dev/integrate/adapt │ │ ers/cloud/sentry) │ │ │ ├─────────────────────────────────────────────────────╯ │ │ 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. **Writes evlog.config.ts** with your service name and destinations, dev and production branched in one place, plus batching, enrichers and sampling when you asked for them. The app reads it at runtime, and [`evlog config`](https://www.evlog.dev/cli/config) shows what the CLI reads from it. 5. **Teaches your AI agents**, if you let it. The evlog conventions as a block in `AGENTS.md`, 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 ``` **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. - An `evlog.config.ts` you already have is read, never rewritten. A setting the run picked that your file lacks, a drain for instance, becomes a manual step. The service it names, under `service` or `env.service`, becomes the default answer, so the plan shows the name your events carry. 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 · evlog.config.ts already exists · .env.example already lists the adapter keys ``` Use `--dry-run` first if you would rather see the plan before anything is touched. ## Your settings land in evlog.config.ts Whatever you answered, service name, destinations, enrichers, sampling, is written to one file at the root of the app, the same one the CLI reads: ```typescript [evlog.config.ts] import { defineEvlog } from 'evlog' import { createFsDrain } from 'evlog/fs' /** * Settings for checkout, read by the app and by the evlog CLI. * https://evlog.dev/cli/config */ export default defineEvlog({ service: 'checkout', // Local NDJSON under .evlog/logs, in development only. drain: process.env.NODE_ENV === 'production' ? undefined : createFsDrain(), }) ``` The framework files only point at it. The Nuxt and Nitro modules find the file on their own, and options you pass to the module override it. On Next.js, Hono, Express and Fastify the generated `evlog.ts` imports it. ## Per framework ### Nuxt Adds `'evlog/nuxt'` to `modules`. `useLogger` and `parseError` are auto-imported from there, and `createError` is imported from `evlog`. ```typescript [nuxt.config.ts] export default defineNuxtConfig({ modules: ['@nuxt/ui', 'evlog/nuxt'], }) ``` ### 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()], }) ``` ### Next.js Creates `instrumentation.ts` and `lib/evlog.ts`, under `src/` when your app lives there, since Next only loads the one that matches. `lib/evlog.ts` starts the logger and builds the handler wrapper from the config: ```typescript [lib/evlog.ts] import { toLoggerConfig } from 'evlog' import { createEvlog } from 'evlog/next' import { createInstrumentation } from 'evlog/next/instrumentation/create' import config from '../evlog.config' /** Loaded by instrumentation.ts when the server starts. */ export const { register, onRequestError } = createInstrumentation({ ...toLoggerConfig(config), captureOutput: true, }) export const { withEvlog, useLogger, log, createError } = createEvlog(config) ``` `init` writes the config import relative to the file it creates, so `src/lib/evlog.ts` imports `../../evlog.config`. Wrapping handlers stays yours: Next has no ambient request logger, so each route opts in with `withEvlog()`. `init` prints the snippet. ### 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. ### Hono Creates `src/evlog.ts` (or `evlog.ts` when there is no `src/`), since Hono has no config file to append to. It starts the logger from `evlog.config.ts` and exports a middleware built from the same file. Registering it stays yours: your app entry is application code, so `init` prints the two lines to paste. ```typescript [src/evlog.ts] import { initLogger, toLoggerConfig, toMiddlewareOptions } from 'evlog' import { evlog } from 'evlog/hono' import config from '../evlog.config' initLogger(toLoggerConfig(config)) /** Register once, before your routes: `app.use(evlogMiddleware)`. */ export const evlogMiddleware = evlog(toMiddlewareOptions(config)) ``` The config import is relative to the file `init` creates, so a root-level `evlog.ts` imports `./evlog.config`. ### Express Same shape as Hono: `src/evlog.ts` starts the logger from the config and exports an `evlogMiddleware` built from `evlog/express`, and the `app.use(evlogMiddleware)` line is left for you to paste before your routes. ### Fastify `src/evlog.ts` re-exports the `evlog` plugin next to an `evlogOptions` object read from the config, so the registration is one line in your entry file: `await app.register(evlog, evlogOptions)`. ## 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 drain (`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
sazabi Sazabi SAZABI_INTAKE_URL
loki Grafana Loki LOKI_ENDPOINT
clickhouse ClickHouse CLICKHOUSE_ENDPOINT , and the evlog_events table created first
none Pretty console output only nothing
With one destination on each side, the generated config picks the drain in one line: ```typescript [evlog.config.ts] export default defineEvlog({ service: 'checkout', drain: process.env.NODE_ENV === 'production' ? createAxiomDrain() : createFsDrain(), }) ``` Picking several production destinations fans the same event out to each. The generated config branches on the environment in one place, so "which drain runs where" is never something to reconstruct: ```typescript [evlog.config.ts] const drains = process.env.NODE_ENV === 'production' ? [pipeline(createAxiomDrain()), pipeline(createSentryDrain())] : [createFsDrain()] ``` Every framework branches on `process.env.NODE_ENV`, not `import.meta.dev`: Nitro inlines it as a literal in its production build, and `process.env` type-checks in a fresh Nuxt project before `nuxi prepare` has run. Batching wraps the network sends only, because buffering a local file write adds latency to the one loop where you want the event on screen immediately. **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 [evlog.config.ts] export default defineEvlog({ 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`. An `evlog.config.ts` at the workspace root is where settings shared by every app belong. Each app's config then `extends` it and leaves out what the root already sets, so a drain configured once at the root reaches every app: ```typescript [apps/shop/evlog.config.ts] import { defineEvlog } from 'evlog' import base from '../../evlog.config' export default defineEvlog({ extends: base, service: 'shop', }) ``` A config extends one level. When the root config already extends a preset of its own, `init` writes nothing for the app and prints its settings as a manual step instead. ## The local drain The filesystem drain, and only that one, is **gated to development** in `evlog.config.ts`: ```typescript [evlog.config.ts] export default defineEvlog({ service: 'api', // Local NDJSON under .evlog/logs, in development only. drain: process.env.NODE_ENV === 'production' ? undefined : createFsDrain(), }) ``` 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. An app set up by an earlier version of `init` has `server/plugins/evlog-drain.ts` and `evlog-enrich.ts`. They keep working, and the new config leaves `drain` or `enrich` out while they exist, so no event is sent twice. ## Flags
Flag What it does
--yes , -y Skip every question and take the defaults
--framework Override detection ( nuxt , nitro , next , tanstack-start , hono , express , fastify )
--service Service name on every wide event (default: the one your evlog.config.ts sets, else your package name, unscoped)
--drain Development drain: 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 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 --- - [Installation](https://www.evlog.dev/start/installation) - [evlog map](https://www.evlog.dev/cli/map) # evlog logs > Read the wide events your app wrote to .evlog/logs from the terminal — the latest requests, the failures, the slowest, or one request in full. `evlog logs` reads the events the [file system drain](https://www.evlog.dev/integrate/adapters/self-hosted/fs) wrote and shows them the way you would want to read them: one line per request, the thing that mattered at the end of it, and one request in full when you have its id. It reads files; it never touches the app. ```bash [Terminal] evlog logs ``` ```text [Output] 6 events · .evlog/logs 08:00:00 GET /api/health 200 2ms 10:00:00 POST /api/checkout 402 412ms ✗ Error: Payment processing failed · Card declined by issuer 10:30:00 GET /api/reports 200 1.2s report.id=r-1 11:00:00 POST /api/refund 200 80ms audit billing.refund user:usr_42 → invoice:inv_1 success 11:30:00 GET /api/items 500 30ms 11:45:00 GET /api/items warn 700ms user.id=usr_7 evlog logs errors — the 2 that failed · evlog logs — one request in full · evlog logs slow — worst first ``` The newest event is at the bottom, like `tail`. Nothing is written: the fs drain writes on every request and this reads what it wrote, both the compact and the `pretty: true` layout, across the dated files. ## Five views
Command Shows
evlog logs The last 50 events, oldest first
evlog logs errors Events that failed: a 5xx status, an error or fatal level, or an error block
evlog logs slow Events over 500ms, worst first ( --over 1s to move the bar)
evlog logs Every event carrying that id, in full: request, error with why and fix , audit record, then the business fields. The first block of a UUID is enough
evlog logs stats The shape of the traffic: per route, count, errors, p50 and p95, routes with the most errors first; then counts by status class and by level
## Filters Filters compose with any view.
Flag What it does
--since Only events after: a duration back from now ( 15m , 2h , 3d ) or a date ( 2026-10-01 , 2026-10-01T09:00 )
--until Only events before, same spellings
--level Only these levels: trace , debug , info , warn , error , fatal
--path Only requests on this exact path
--status Only this status ( 404 ) or class ( 4xx )
--where Only events where a field matches, see below; repeat the flag for several clauses, all must hold
--over For slow : what a request has to exceed (default 500ms )
--limit Most events to show (default 50)
--dir Read another directory (default: the project's .evlog/logs ; in a workspace with no logs at the root, every apps/*/.evlog/logs and the like, merged by time with the app in a column)
--url Read the memory drain 's dev endpoint instead of files: a JSON array of events, or { "events": [...] }
-f , --follow Keep reading as the app writes, like tail -f ; Ctrl-C stops
--json The events as JSON on stdout
--cwd Another project in the workspace
A time, level, status or limit that cannot be read stops the command (exit 2) rather than silently widening the query. `--dir` and `--limit` can be set once for the project in [`evlog.config.ts`](https://www.evlog.dev/cli/config), as `logs.dir` and `logs.limit`. The flag wins when both are set. ```bash [Terminal] evlog logs errors --since 1h evlog logs slow --over 2s --path /api/reports evlog logs --status 5xx --level error,fatal --limit 20 evlog logs -f --path /api/checkout ``` ### `--where`: any field on the event A clause is a dotted field, an operator, and a value. Numbers compare as numbers, everything else as text, and the field can sit anywhere in the event, which is the point of a wide event: the business fields are there to be queried.
Clause Matches when
user.id=usr_42 the field equals the value ( true / false and numbers are read as such)
audit.outcome!=success the field differs
payment.amount>5000 greater; also >= , < , <=
error.message~declined the field matches the regular expression, case-insensitive; an object is matched as JSON
audit the field is present
!error the field is absent
```bash [Terminal] evlog logs --where 'payment.amount>5000' --where audit.outcome=failure evlog logs errors --where 'error.data.why~card declined' evlog logs stats --where user.plan=pro evlog logs -f --where '!error' --where 'durationMs>1000' ``` Quote the whole clause whenever it carries `>`, `<` or `!`, which the shell reads as redirection or history before `evlog` ever sees them, and whenever the value has a space: `'payment.amount>5000'`, `'!error'`, `'error.data.why~card declined'`. ## For agents `--json` returns one envelope: the directory, the view, how many events matched, and the events shown. ```bash [Terminal] evlog logs errors --since 30m --json ``` ```json { "schemaVersion": 2, "dir": "/app/.evlog/logs", "view": "errors", "count": 2, "matched": 2, "events": [ { "timestamp": "…", "path": "/api/checkout", "status": 402, "error": { "data": { "why": "…", "fix": "…" } } } ] } ``` `stats --json` carries `stats` (`total`, `errors`, `byRoute`, `byStatus`, `byLevel`) in place of `events`. With `--follow --json`, each new event is one JSON line on stdout, since a stream has no end. The [`analyze-logs` skill](https://www.evlog.dev/reference/agent-skills) calls `evlog logs` first and falls back to reading the files only when the CLI is not available. ## A workspace, and a running app Run from a monorepo root with no `.evlog/logs` of its own, it reads every app that has one (`apps/*`, `packages/*`, `examples/*`, `services/*`), merges the events by time, and shows the app in a column. `--cwd apps/web` reads one app; `--dir` reads one directory. An app on the [memory drain](https://www.evlog.dev/integrate/adapters/self-hosted/memory) (Cloudflare Workers, where there is no file system) has no files to read, but it can expose `readMemoryLogs()` on a dev route. Point `--url` at it; every view and filter works the same, and `-f` polls it once a second. A failed poll is a gap rather than the end, since the app restarting under a follower is normal; if the endpoint stays quiet the run says so once and keeps trying. ```bash [Terminal] evlog logs errors --url http://localhost:8787/_evlog/logs ``` ## What it will not do - **It reads local files and a dev endpoint.** A remote drain (Axiom, Datadog, …) has its own query language and UI; this command does not wrap them. ## Next - [File system drain](https://www.evlog.dev/integrate/adapters/self-hosted/fs): what writes the files, rotation, `pretty` - [`evlog map`](https://www.evlog.dev/cli/map): which handlers emit a wide event at all --- - [File system adapter](https://www.evlog.dev/integrate/adapters/self-hosted/fs) # evlog map > Scan supported entry points for evlog logging patterns, inspect the static observability score, and prioritize fixes without running your handlers. `evlog map` shows which supported entry points are missing evlog logging patterns. It checks your source for wide events, context, structured errors, and other [logging rules](https://www.evlog.dev/cli/rules). The selected framework adapter discovers the entry points it recognizes, such as API handlers, pages that fetch, and middleware. The report scores their checks and names three priorities to inspect. With the same source, CLI version, and adapter, the checks produce the same result. The scan does not execute handlers or inspect traffic. A high score does not prove that logs reach your drain or contain enough context to diagnose a failure. Read the [observability score guide](https://www.evlog.dev/cli/observability-score) for supported frameworks and the limits of the result. ```bash [Terminal] evlog map ``` **Early days.** The foundation is solid (AST-based, tested, with a versioned JSON contract), but the rule set is young and only seven 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. Run `npx evlog map` in this project and read the report. - Fix the entry points listed under FIX FIRST, in order - For each one, run `npx evlog 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 map` and confirm the score went up Docs: [https://www.evlog.dev/cli/map](https://www.evlog.dev/cli/map) Rules: [https://www.evlog.dev/cli/rules](https://www.evlog.dev/cli/rules) ## Adding it to an app already in production Nothing about `evlog map` assumes a fresh project. On a supported framework it reads source, not traffic, so an app that shipped three years ago is scored the same way a scaffold is: recognized entry points, recognized patterns, same static checks. You get the list before the next incident rather than during it. Unsupported Node.js apps, and wrappers the adapter does not recognize, sit outside that score. See [Observability Score](https://www.evlog.dev/cli/observability-score). The work is the same four steps whatever the age of the codebase: 1. Run `npx evlog map`. No install, no deploy, no agent watching a live process. 2. Read the **FIX FIRST** block. It is at most three entry points, ranked by what they touch: money, auth, and PII come before a health check. 3. Fix those three, running `npx evlog map ` on each to see the shape the rules expected. Nothing else on the list has to move yet. 4. Commit `evlog.map.json` and gate on it with [`--baseline`](https://www.evlog.dev/cli/ci#the-ratchet---baseline). From that commit on, recognized regressions need a review. A large codebase usually scores badly on the first run, and that number is not the point. The gate is the ratchet, not the score: a legacy app that starts at 34 and never regresses is in better shape than a new one at 80 that drifts down for a year. ## A deterministic static check `evlog map` is not a benchmark or a flaky measure of your app at runtime. It is a **deterministic static check** of agreed logging conventions on the entry points its adapters recognize: the kind of check you can gate a pull request on. Deterministic means the same source, CLI version, and adapter produce the same score on any machine, on every run. The rule engine reads your source with an AST and applies a versioned rule set, so nothing depends on timing, traffic, or environment. Three things follow: - **Gates without flakiness.** Because the result cannot vary run to run, `--min-score ` behaves like a real test: a green gate means the recognized requirements cleared the floor you set, not that the runner got lucky. A deterministic score is the only kind you should ever block a pull request on. - **It ratchets.** `--baseline` compares against the committed `evlog.map.json` and fails on recognized requirement or score regressions on existing entries, exactly like a snapshot test. A refactor that quietly drops a `log.set` cannot slip past a review that ignores the diff. See [the ratchet](https://www.evlog.dev/cli/ci#the-ratchet---baseline) and the baseline limits on the [Observability Score](https://www.evlog.dev/cli/observability-score) page. - **It is audit-ready.** The map is a versioned, reproducible artifact: same revision, same `evlog.map.json`, so a score from three weeks ago means the same checks today. You can cite it in a review and anyone can regenerate it. It also reports **before any traffic exists**. Unlike an agent that has to watch a live app, `evlog map` finds missing recognized patterns the moment the code is written, which is when fixing them is cheap. Use it as a day-one convention check so you are not discovering those gaps mid-incident. ## The report This is a real run against the evlog playground, a Nuxt app with 29 entry points: ```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. 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#mark-what-touches-money-auth-or-pii), 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**, which is 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 ``` ```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. ```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, wrapped ones included ``` ```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. 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). A check the whole project does without, like `error-catalog` in an app with no error catalog, is turned off once in [`evlog.config.ts`](https://www.evlog.dev/cli/config) with `map.rules`. `map.ignore` leaves whole entry points out, by their file. The report says how many it left out, and `--json` carries the count as `ignored`. ## 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 against the committed map (path, or git: )
--baseline-label the ref What to call the baseline in the report and annotations, for a map copied to a temp file
--framework detected Force nuxt , nitro , next , tanstack-start , hono , express , or fastify
--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 (same as --format json )
--format human github writes workflow annotations to stdout
--limit 10 Most annotations --format github emits; GitHub keeps ten per step
`--min-score` and `--baseline` can be set once for the project in `evlog.config.ts`, as `map.minScore` and `map.baseline`. The flag wins when both are set. ## 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#where-the-map-file-lives). 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 re-exports, not your functions.** Helpers re-exported through a local module or a [package that wraps evlog](https://www.evlog.dev/cli/rules#how-evlog-helpers-are-recognised) are resolved, so `import { useLogger } from '@/lib/evlog'` and `import { withEvlog } from '@acme/logger'` count. A helper you renamed or wrapped in a function of your own (`withRequestLog(handler)`) is not recognised. - **Seven frameworks.** Nuxt, Nitro, Next.js App Router, TanStack Start, Hono, Express, and Fastify have adapters. Other frameworks that evlog integrates with are not scanned yet. - **Hono routes are read from literal registrations.** `app.get('/path', handler)` with an inline or named function counts. A computed path, a controller-style handler (`app.get('/users', users.list)`), and the prefix a sub-app is mounted under (`app.route('/api', users)` reports the child paths without `/api`) are not resolved. - **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 --- - [Rules](https://www.evlog.dev/cli/rules) - [Scoring](https://www.evlog.dev/cli/scoring) # Observability Score with evlog map > Inspect supported entry points against evlog logging rules, understand the score, and fix a concrete gap before adding a CI gate. Before changing your logging, inspect which supported entry points meet evlog's logging checks. `evlog map` reads source files and checks six requirements, including missing context and structured errors. Its score summarizes those static checks. ## Read a real report Run it from the application package, not a monorepo root without a detected framework. The [supported scope](#check-the-supported-scope) section lists the frameworks. This command inspects the project without writing `evlog.map.json`. The examples on this page were checked with CLI 0.10.0 (`evlog --version`): ```bash [Terminal] npx evlog map --no-write ``` This output comes from the repository's Next.js example at commit `e154fa9c`. It has five detected entry points, two with failed requirements: ```text [evlog map] evlog map v0.10.0 ──────────────────────────── █▀█ ▀▀█ score /100 evlog-nextjs-example · Next.js █▀█ █ ▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▱▱▱ 5 entry points scanned ▀▀▀ ▀ good ▆▆█▁█ COVERAGE ● API handlers ▰▰▰▰▰▰▰▰▰▱ 92 1 of 3 have gaps ● Pages ▰▰▰▰▰▰▰▰▱▱ 80 1 swallows fetch errors ● Money & auth ▰▰▰▰▰▰▰▰▱▱ 75 missing audit trails FIX FIRST 1. POST /api/checkout $ — moves money with no audit trail app/api/checkout/route.ts:3 · evlog.dev/use-cases/audit/overview 2. PAGE / — swallows fetch errors — users see a blank page app/page.tsx:1 · evlog.dev/learn/lifecycle ✓ Already solid: /api/error · /api/health ▲ 87 → 100 by fixing the 2 above ──────────────────────────── how this score works → evlog.dev/cli/scoring ▸ evlog map --all every entry point · evlog map inspect one --min-score 80 CI gate ``` The `audit` check passes when a route flagged for money or auth calls `log.audit()`, and the [Audit logs](https://www.evlog.dev/use-cases/audit/overview) overview covers the trail itself. The 87/100 score is a weighted average over the four scored entry points. The exempt evlog ingest route is counted in the five scanned but left out of the average. It does not mean 87% of the application's execution paths are covered. `FIX FIRST` highlights requirements to inspect, and the projected 100 means those static requirements would pass. It does not prove delivery to a backend or usefulness during an incident. The [Scoring reference](https://www.evlog.dev/cli/scoring) owns the weights, aggregation formula, grades, and sensitivity classification. [Rules](https://www.evlog.dev/cli/rules) explains what each check recognizes. ## Check the supported scope The CLI currently has adapters for Nuxt, Nitro, Next.js, TanStack Start, Hono, Express, and Fastify. An unsupported Node.js project cannot be scored by these adapters. You can scan a supported project without installing evlog in it. However, checks such as `wide-event` and `context` recognize evlog conventions. Existing Pino or LogTape instrumentation can receive a low score even when it produces useful logs. Use the report to plan evlog adoption, not to rank logging libraries. The scan does not execute handlers or require traffic. It only sees entry points and code patterns its adapter recognizes. Before you act on the number, check the detected framework and entry-point count above. ## Fix one context gap In a Next.js App Router application with evlog installed, this handler is wrapped for logging but adds no application context. The `@/lib/evlog` import comes from `lib/evlog.ts`, which you create with `createEvlog()` from `evlog/next`, as the [Next.js guide](https://www.evlog.dev/integrate/frameworks/nextjs) shows: ```typescript [app/api/hello/route.ts, before] import { withEvlog } from '@/lib/evlog' export const GET = withEvlog(async (request: Request) => { const name = new URL(request.url).searchParams.get('name') ?? 'world' return Response.json({ message: `Hello ${name}` }) }) ``` Inspect that file directly: ```bash [Terminal] npx evlog map app/api/hello/route.ts --no-write ``` The `context` requirement fails because the handler has no recognized `set()` call. Attach a useful decision without recording the user's name: ```typescript [app/api/hello/route.ts, after] import { useLogger, withEvlog } from '@/lib/evlog' export const GET = withEvlog(async (request: Request) => { const log = useLogger() const name = new URL(request.url).searchParams.get('name') ?? 'world' log.set({ greeting: { personalized: name !== 'world' } }) return Response.json({ message: `Hello ${name}` }) }) ``` Run the same command again. The `context` requirement now passes, removing its 15-point penalty. In this isolated example the route moves from 85 to 100. In a larger application the global change depends on all scored entries and their weights. A passing check establishes that the pattern exists. It does not judge whether `greeting.personalized` is the field your incident investigation needs. Review the event output and required business fields separately. ## Know what a high score leaves untested - Source patterns do not prove runtime execution or complete error-path coverage. - Money and authentication flags come from imports and route paths. The personal-data flag needs a field name such as `email` or `phone` and a write call such as `create` or `update` in the same file. Inspect the reported reasons for money, authentication, or personal-data flags. - With no scorable entry points, the human report shows no score, only `--` and "nothing to scan". The JSON output still sets `map.score` to 100, next to `grade: "unscored"`, so read the grade, not the number. An empty scan is no evidence of instrumentation, so inspect the scope first. - Logging through wrappers or abstractions the analyzer does not recognize can produce false positives. Look at the code before applying a suggestion. A high score is useful for checking agreed conventions alongside runtime tests and [sampled production events](https://www.evlog.dev/learn/sampling). ## Add a CI gate after reviewing the baseline Pin the CLI version and keep the analyzed package and the [`map.ignore` and `map.rules` settings](https://www.evlog.dev/cli/config) in `evlog.config.ts` consistent. Rule changes can change a score without an application edit. Commit a reviewed `evlog.map.json` baseline and use `--baseline` to detect recognized requirement or score regressions on existing entries. A bare `--baseline` reads the working-tree `evlog.map.json`, so a pull request that commits a regenerated map is compared with itself. With the default shallow checkout (fetch depth 1), the checkout fetches only the commit that triggered the run, so the base branch is absent and must be fetched first, while a full-history checkout already has it and can skip the fetch. On a pull request, compare against the base branch instead. The base branch must already contain a reviewed `evlog.map.json`, and without one the command exits 1 with "No evlog.map.json in origin/ , and the ratchet needs a committed map", which is a setup gap rather than a regression: ```bash [Terminal] git fetch origin npx evlog map --baseline git:origin/ ``` Replace `` with the branch the pull request targets. A regression against the baseline, or a score under `--min-score`, exits 1 and fails the job. `--baseline` does not track every context field: removing one `set()` call may still leave another call that satisfies `context`. New entries also need review. Add `--min-score` when you want a floor on the current aggregate score, understanding that an average can hide one weak route. The [CI guide](https://www.evlog.dev/cli/ci) covers exit codes and gate configuration. Use the [map reference](https://www.evlog.dev/cli/map) for command options and report formats. --- - [evlog map](https://www.evlog.dev/cli/map) - [Scoring](https://www.evlog.dev/cli/scoring) # evlog CLI > Use the evlog CLI to check logging patterns in supported source files, diagnose your setup, and control telemetry. Includes flags and exit codes. The `evlog` executable ships with the `evlog` package. It runs separately from your application: use it locally or in CI to inspect your source, configure evlog, and work with telemetry. Start with [`evlog map`](https://www.evlog.dev/cli/map) to check supported entry points for evlog logging patterns and get a static observability score with suggested fixes. It writes `evlog.map.json` unless you pass `--no-write`. The score describes recognized source patterns, not the logs your handlers produce in production. [`evlog init`](https://www.evlog.dev/cli/init) adds evlog configuration to the app. [`evlog logs`](https://www.evlog.dev/cli/logs) reads the events the fs drain wrote: the latest requests, the failures, the slowest, or one request in full. [`evlog agents`](https://www.evlog.dev/cli/agents) writes logging conventions for the AI agents working in the repository. [`evlog.config.ts`](https://www.evlog.dev/cli/config) holds the settings `evlog map` and `evlog logs` apply, next to the sampling and redaction the app imports. **Early days.** `evlog map` has adapters for Nuxt, Nitro, Next.js App Router, TanStack Start, Hono, Express, and Fastify. Each adapter recognizes specific entry-point shapes. Check the detected framework and entry-point count before interpreting the score. Rules can change between releases, so [pin the version](https://www.evlog.dev/cli/ci#pin-the-version) when you gate CI on the number. ```bash [pnpm] pnpm evlog map ``` ```bash [bun] bunx evlog map ``` ```bash [npm] npx evlog map ``` The executable runs [`@evlog/cli`](https://npmjs.com/package/@evlog/cli) when it is installed and otherwise fetches it with the package manager that launched it, so `evlog` adds no dependency to your project and `npx evlog init` works on a project that has nothing yet. A global `@evlog/cli` hands off to the copy the project installs, so a project always runs the version it pins. Install the CLI for a pinned, instant run, which is what CI wants: ```bash [pnpm] pnpm add -D @evlog/cli ``` ```bash [bun] bun add -d @evlog/cli ``` ```bash [npm] npm install --save-dev @evlog/cli ``` The CLI requires Node 22 or later. ## Commands Install evlog, register the framework integration, and write a local drain. Write the evlog conventions into AGENTS.md and install the agent skills. Score wide-event coverage across every entry point, and name the ones to fix first. Check that evlog is installed, resolvable, and writing logs where you expect. Show the evlog.config that applies here and where each setting comes from. 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, so 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
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, at no score cost and with 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 map](https://www.evlog.dev/cli/map) - [Rules](https://www.evlog.dev/cli/rules) # Map rules > Every check evlog map runs: six requirements that move the score, four suggestions that never do, and what satisfies each one. 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. **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
`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. ## 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 ``` ```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#mark-what-touches-money-auth-or-pii) 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, so `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. ```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, since 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, since 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. - **Re-exported from a package**: `import { withEvlog } from '@acme/logger'` counts when `@acme/logger` is a dependency of the app, declares `evlog` in `dependencies` or `peerDependencies`, and forwards evlog's export from one of its `exports` entries. That is the shape of a shared logger in a workspace, or one published inside an organisation. The map reads the installed package through `node_modules`, follows its relative re-exports, and also reads its built output (`export { withEvlog$1 as withEvlog }`). An app that reaches evlog only through such a package counts as having evlog installed. - **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/evloghq/evlog/issues) for. Include the entry point's output and the handler, and the fix lands in one rule. In the meantime, [disable the check](#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 --- - [Scoring](https://www.evlog.dev/cli/scoring) - [evlog map](https://www.evlog.dev/cli/map) # Map scoring > How evlog map turns rule results into a score: per-entry weights, the project average, grade thresholds, and how routes are flagged. 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, because 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`. 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: ## How the project score is computed 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 scorable entry points currently scores 100. This is an empty result, not evidence of complete instrumentation. Check the detected framework and entry count before interpreting the score. ## How a grade is assigned
Score Grade
90–100 excellent
70–89 good
50–69 needs work
0–49 at risk
## How a file is classified Alongside the score, each entry point is classified, which 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` means the analyzer did not recognize the required evlog instrumentation or page error handling. Other loggers or unsupported abstractions may still produce useful evidence. Inspect the code before treating the classification as an absence of logs. ## Mark what touches money, auth, or PII 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 --- - [Rules](https://www.evlog.dev/cli/rules) - [CI](https://www.evlog.dev/cli/ci) # CLI telemetry > What the evlog CLI collects about its own usage, how to see it, and the three ways to turn it off — DO_NOT_TRACK, EVLOG_TELEMETRY=0, or evlog telemetry disable. 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. 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 drain, 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, and 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, so `evlog map` on its own sends an empty `flags` object. The machine id is a hash, and it is omitted entirely in ephemeral CI. 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
--- - [Telemetry for your own CLI](https://www.evlog.dev/use-cases/telemetry/overview) # Axiom Adapter > Send wide events to Axiom for powerful querying, dashboards, and alerting. Zero-config setup with environment variables and automatic batching. [Axiom](https://axiom.co) is a cloud-native logging platform that indexes every field of an event, so any of them is queryable without a schema. The evlog Axiom adapter sends your wide events directly to Axiom datasets. 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: [https://www.evlog.dev/integrate/adapters/cloud/axiom](https://www.evlog.dev/integrate/adapters/cloud/axiom) Framework setup: [https://www.evlog.dev/integrate/frameworks/overview](https://www.evlog.dev/integrate/frameworks/overview) ## 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) 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 ``` 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 ```typescript [Nuxt]server/plugins/evlog-drain.ts import { createAxiomDrain } from 'evlog/axiom' export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createAxiomDrain()) }) ``` ```typescript [Nitro]server/plugins/evlog-drain.ts import { definePlugin } from 'nitro' import { createAxiomDrain } from 'evlog/axiom' export default definePlugin((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 [SvelteKit]src/hooks.server.ts import { createEvlogHooks } from 'evlog/sveltekit' import { createAxiomDrain } from 'evlog/axiom' export const { handle, handleError } = createEvlogHooks({ drain: createAxiomDrain() }) ``` ```typescript [TanStack Start]server/plugins/evlog-drain.ts import { definePlugin } from 'nitro' import { createAxiomDrain } from 'evlog/axiom' export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createAxiomDrain()) }) ``` ```typescript [React Router]app/root.tsx import { evlog } from 'evlog/react-router' import { createAxiomDrain } from 'evlog/axiom' export const middleware: Route.MiddlewareFunction[] = [ evlog({ drain: createAxiomDrain() }), ] ``` ```typescript [Hono]src/index.ts import { createAxiomDrain } from 'evlog/axiom' app.use(evlog({ drain: createAxiomDrain() })) ``` ```typescript [Express]src/index.ts import { createAxiomDrain } from 'evlog/axiom' app.use(evlog({ drain: createAxiomDrain() })) ``` ```typescript [Fastify]src/index.ts import { createAxiomDrain } from 'evlog/axiom' await app.register(evlog, { drain: createAxiomDrain() }) ``` ```typescript [Elysia]src/index.ts import { createAxiomDrain } from 'evlog/axiom' app.use(evlog({ drain: createAxiomDrain() })) ``` ```typescript [NestJS]src/app.module.ts import { createAxiomDrain } from 'evlog/axiom' EvlogModule.forRoot({ drain: createAxiomDrain() }) ``` ```typescript [oRPC]server/orpc.ts import { withEvlog } from 'evlog/orpc' import { createAxiomDrain } from 'evlog/axiom' const handler = withEvlog(new RPCHandler(router), { drain: createAxiomDrain() }) ``` ```typescript [Cloudflare Workers]src/worker.ts import { withEvlog } from 'evlog/workers' import { createAxiomDrain } from 'evlog/axiom' export default withEvlog(handler, { 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 [Nuxt]server/plugins/evlog-drain.ts import { createAxiomDrain } from 'evlog/axiom' export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createAxiomDrain({ dataset: 'production-logs', timeout: 10000, })) }) ``` ```typescript [Nitro]server/plugins/evlog-drain.ts import { definePlugin } from 'nitro' import { createAxiomDrain } from 'evlog/axiom' export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createAxiomDrain({ dataset: 'production-logs', timeout: 10000, })) }) ``` ```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({ dataset: 'production-logs', timeout: 10000, }), }) ``` ```typescript [SvelteKit]src/hooks.server.ts import { createEvlogHooks } from 'evlog/sveltekit' import { createAxiomDrain } from 'evlog/axiom' export const { handle, handleError } = createEvlogHooks({ drain: createAxiomDrain({ dataset: 'production-logs', timeout: 10000, }), }) ``` ```typescript [TanStack Start]server/plugins/evlog-drain.ts import { definePlugin } from 'nitro' import { createAxiomDrain } from 'evlog/axiom' export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createAxiomDrain({ dataset: 'production-logs', timeout: 10000, })) }) ``` ```typescript [React Router]app/root.tsx import { evlog } from 'evlog/react-router' import { createAxiomDrain } from 'evlog/axiom' export const middleware: Route.MiddlewareFunction[] = [ evlog({ drain: createAxiomDrain({ dataset: 'production-logs', timeout: 10000, }), }), ] ``` ```typescript [Hono]src/index.ts import { evlog } from 'evlog/hono' import { createAxiomDrain } from 'evlog/axiom' app.use(evlog({ drain: createAxiomDrain({ dataset: 'production-logs', timeout: 10000, }), })) ``` ```typescript [Express]src/index.ts import { evlog } from 'evlog/express' import { createAxiomDrain } from 'evlog/axiom' app.use(evlog({ drain: createAxiomDrain({ dataset: 'production-logs', timeout: 10000, }), })) ``` ```typescript [Fastify]src/index.ts import { evlog } from 'evlog/fastify' import { createAxiomDrain } from 'evlog/axiom' await app.register(evlog, { drain: createAxiomDrain({ dataset: 'production-logs', timeout: 10000, }), }) ``` ```typescript [Elysia]src/index.ts import { evlog } from 'evlog/elysia' import { createAxiomDrain } from 'evlog/axiom' app.use(evlog({ drain: createAxiomDrain({ dataset: 'production-logs', timeout: 10000, }), })) ``` ```typescript [NestJS]src/app.module.ts import { EvlogModule } from 'evlog/nestjs' import { createAxiomDrain } from 'evlog/axiom' EvlogModule.forRoot({ drain: createAxiomDrain({ dataset: 'production-logs', timeout: 10000, }), }) ``` ```typescript [oRPC]server/orpc.ts import { withEvlog } from 'evlog/orpc' import { createAxiomDrain } from 'evlog/axiom' const handler = withEvlog(new RPCHandler(router), { drain: createAxiomDrain({ dataset: 'production-logs', timeout: 10000, }), }) ``` ```typescript [Cloudflare Workers]src/worker.ts import { withEvlog } from 'evlog/workers' import { createAxiomDrain } from 'evlog/axiom' export default withEvlog(handler, { drain: createAxiomDrain({ dataset: 'production-logs', timeout: 10000, }), }) ``` ```typescript [Standalone]src/index.ts import { initLogger } from 'evlog' import { createAxiomDrain } from 'evlog/axiom' initLogger({ 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
retries number 2 Number of retry attempts on transient failures
### Timestamps Axiom's event timestamp field is `_time`. The adapter sends the event's own `timestamp` field as the timestamp via the `timestamp-field` ingest parameter, so events keep their original time even when batched or retried. Time-based queries and monitors should bin on `_time`. ## 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 _time, 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(_time, 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 --- - [Axiom Dashboard](https://app.axiom.co) - [OTLP Adapter](https://www.evlog.dev/integrate/adapters/hybrid/otlp) # Better Stack Adapter > Send wide events to Better Stack (formerly Logtail) for log management, alerting, and dashboards. Zero-config setup with environment variables. [Better Stack](https://betterstack.com) is a log management platform with search, alerting, and dashboards. The evlog Better Stack adapter sends your wide events to the Better Stack HTTP ingestion API. 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: [https://www.evlog.dev/integrate/adapters/cloud/better-stack](https://www.evlog.dev/integrate/adapters/cloud/better-stack) Framework setup: [https://www.evlog.dev/integrate/frameworks/overview](https://www.evlog.dev/integrate/frameworks/overview) ## 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) 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 ``` 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 ```typescript [Nuxt]server/plugins/evlog-drain.ts import { createBetterStackDrain } from 'evlog/better-stack' export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createBetterStackDrain()) }) ``` ```typescript [Nitro]server/plugins/evlog-drain.ts import { definePlugin } from 'nitro' import { createBetterStackDrain } from 'evlog/better-stack' export default definePlugin((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 [SvelteKit]src/hooks.server.ts import { createEvlogHooks } from 'evlog/sveltekit' import { createBetterStackDrain } from 'evlog/better-stack' export const { handle, handleError } = createEvlogHooks({ drain: createBetterStackDrain() }) ``` ```typescript [TanStack Start]server/plugins/evlog-drain.ts import { definePlugin } from 'nitro' import { createBetterStackDrain } from 'evlog/better-stack' export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createBetterStackDrain()) }) ``` ```typescript [React Router]app/root.tsx import { evlog } from 'evlog/react-router' import { createBetterStackDrain } from 'evlog/better-stack' export const middleware: Route.MiddlewareFunction[] = [ evlog({ drain: createBetterStackDrain() }), ] ``` ```typescript [Hono]src/index.ts import { createBetterStackDrain } from 'evlog/better-stack' app.use(evlog({ drain: createBetterStackDrain() })) ``` ```typescript [Express]src/index.ts import { createBetterStackDrain } from 'evlog/better-stack' app.use(evlog({ drain: createBetterStackDrain() })) ``` ```typescript [Fastify]src/index.ts import { createBetterStackDrain } from 'evlog/better-stack' await app.register(evlog, { drain: createBetterStackDrain() }) ``` ```typescript [Elysia]src/index.ts import { createBetterStackDrain } from 'evlog/better-stack' app.use(evlog({ drain: createBetterStackDrain() })) ``` ```typescript [NestJS]src/app.module.ts import { createBetterStackDrain } from 'evlog/better-stack' EvlogModule.forRoot({ drain: createBetterStackDrain() }) ``` ```typescript [oRPC]server/orpc.ts import { withEvlog } from 'evlog/orpc' import { createBetterStackDrain } from 'evlog/better-stack' const handler = withEvlog(new RPCHandler(router), { drain: createBetterStackDrain() }) ``` ```typescript [Cloudflare Workers]src/worker.ts import { withEvlog } from 'evlog/workers' import { createBetterStackDrain } from 'evlog/better-stack' export default withEvlog(handler, { 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 [Nuxt]server/plugins/evlog-drain.ts import { createBetterStackDrain } from 'evlog/better-stack' export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createBetterStackDrain({ apiKey: 'my-key', timeout: 10000, })) }) ``` ```typescript [Nitro]server/plugins/evlog-drain.ts import { definePlugin } from 'nitro' import { createBetterStackDrain } from 'evlog/better-stack' export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createBetterStackDrain({ apiKey: 'my-key', timeout: 10000, })) }) ``` ```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({ apiKey: 'my-key', timeout: 10000, }), }) ``` ```typescript [SvelteKit]src/hooks.server.ts import { createEvlogHooks } from 'evlog/sveltekit' import { createBetterStackDrain } from 'evlog/better-stack' export const { handle, handleError } = createEvlogHooks({ drain: createBetterStackDrain({ apiKey: 'my-key', timeout: 10000, }), }) ``` ```typescript [TanStack Start]server/plugins/evlog-drain.ts import { definePlugin } from 'nitro' import { createBetterStackDrain } from 'evlog/better-stack' export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createBetterStackDrain({ apiKey: 'my-key', timeout: 10000, })) }) ``` ```typescript [React Router]app/root.tsx import { evlog } from 'evlog/react-router' import { createBetterStackDrain } from 'evlog/better-stack' export const middleware: Route.MiddlewareFunction[] = [ evlog({ drain: createBetterStackDrain({ apiKey: 'my-key', timeout: 10000, }), }), ] ``` ```typescript [Hono]src/index.ts import { evlog } from 'evlog/hono' import { createBetterStackDrain } from 'evlog/better-stack' app.use(evlog({ drain: createBetterStackDrain({ apiKey: 'my-key', timeout: 10000, }), })) ``` ```typescript [Express]src/index.ts import { evlog } from 'evlog/express' import { createBetterStackDrain } from 'evlog/better-stack' app.use(evlog({ drain: createBetterStackDrain({ apiKey: 'my-key', timeout: 10000, }), })) ``` ```typescript [Fastify]src/index.ts import { evlog } from 'evlog/fastify' import { createBetterStackDrain } from 'evlog/better-stack' await app.register(evlog, { drain: createBetterStackDrain({ apiKey: 'my-key', timeout: 10000, }), }) ``` ```typescript [Elysia]src/index.ts import { evlog } from 'evlog/elysia' import { createBetterStackDrain } from 'evlog/better-stack' app.use(evlog({ drain: createBetterStackDrain({ apiKey: 'my-key', timeout: 10000, }), })) ``` ```typescript [NestJS]src/app.module.ts import { EvlogModule } from 'evlog/nestjs' import { createBetterStackDrain } from 'evlog/better-stack' EvlogModule.forRoot({ drain: createBetterStackDrain({ apiKey: 'my-key', timeout: 10000, }), }) ``` ```typescript [oRPC]server/orpc.ts import { withEvlog } from 'evlog/orpc' import { createBetterStackDrain } from 'evlog/better-stack' const handler = withEvlog(new RPCHandler(router), { drain: createBetterStackDrain({ apiKey: 'my-key', timeout: 10000, }), }) ``` ```typescript [Cloudflare Workers]src/worker.ts import { withEvlog } from 'evlog/workers' import { createBetterStackDrain } from 'evlog/better-stack' export default withEvlog(handler, { drain: createBetterStackDrain({ apiKey: 'my-key', timeout: 10000, }), }) ``` ```typescript [Standalone]src/index.ts import { initLogger } from 'evlog' import { createBetterStackDrain } from 'evlog/better-stack' initLogger({ 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's log search takes a query over any field: - **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 --- - [Better Stack Dashboard](https://logs.betterstack.com) - [Axiom Adapter](https://www.evlog.dev/integrate/adapters/cloud/axiom) # Datadog Adapter > Send wide events to Datadog Logs via the native HTTP intake API. Supports all Datadog sites and DD_* environment variables. [Datadog](https://www.datadoghq.com) is a monitoring and security platform. The evlog Datadog adapter sends your wide events to [Datadog Logs](https://docs.datadoghq.com/logs/) 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). 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: [https://www.evlog.dev/integrate/adapters/cloud/datadog](https://www.evlog.dev/integrate/adapters/cloud/datadog) Framework setup: [https://www.evlog.dev/integrate/frameworks/overview](https://www.evlog.dev/integrate/frameworks/overview) ## 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) 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 ```typescript [Nuxt]server/plugins/evlog-drain.ts import { createDatadogDrain } from 'evlog/datadog' export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createDatadogDrain()) }) ``` ```typescript [Nitro]server/plugins/evlog-drain.ts import { definePlugin } from 'nitro' import { createDatadogDrain } from 'evlog/datadog' export default definePlugin((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 [SvelteKit]src/hooks.server.ts import { createEvlogHooks } from 'evlog/sveltekit' import { createDatadogDrain } from 'evlog/datadog' export const { handle, handleError } = createEvlogHooks({ drain: createDatadogDrain() }) ``` ```typescript [TanStack Start]server/plugins/evlog-drain.ts import { definePlugin } from 'nitro' import { createDatadogDrain } from 'evlog/datadog' export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createDatadogDrain()) }) ``` ```typescript [React Router]app/root.tsx import { evlog } from 'evlog/react-router' import { createDatadogDrain } from 'evlog/datadog' export const middleware: Route.MiddlewareFunction[] = [ evlog({ drain: createDatadogDrain() }), ] ``` ```typescript [Hono]src/index.ts import { createDatadogDrain } from 'evlog/datadog' app.use(evlog({ drain: createDatadogDrain() })) ``` ```typescript [Express]src/index.ts import { createDatadogDrain } from 'evlog/datadog' app.use(evlog({ drain: createDatadogDrain() })) ``` ```typescript [Fastify]src/index.ts import { createDatadogDrain } from 'evlog/datadog' await app.register(evlog, { drain: createDatadogDrain() }) ``` ```typescript [Elysia]src/index.ts import { createDatadogDrain } from 'evlog/datadog' app.use(evlog({ drain: createDatadogDrain() })) ``` ```typescript [NestJS]src/app.module.ts import { createDatadogDrain } from 'evlog/datadog' EvlogModule.forRoot({ drain: createDatadogDrain() }) ``` ```typescript [oRPC]server/orpc.ts import { withEvlog } from 'evlog/orpc' import { createDatadogDrain } from 'evlog/datadog' const handler = withEvlog(new RPCHandler(router), { drain: createDatadogDrain() }) ``` ```typescript [Cloudflare Workers]src/worker.ts import { withEvlog } from 'evlog/workers' import { createDatadogDrain } from 'evlog/datadog' export default withEvlog(handler, { 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 [Nuxt]server/plugins/evlog-drain.ts import { createDatadogDrain } from 'evlog/datadog' export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createDatadogDrain({ apiKey: '***', site: 'us5.datadoghq.com', timeout: 10000, })) }) ``` ```typescript [Nitro]server/plugins/evlog-drain.ts import { definePlugin } from 'nitro' import { createDatadogDrain } from 'evlog/datadog' export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createDatadogDrain({ apiKey: '***', site: 'us5.datadoghq.com', timeout: 10000, })) }) ``` ```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({ apiKey: '***', site: 'us5.datadoghq.com', timeout: 10000, }), }) ``` ```typescript [SvelteKit]src/hooks.server.ts import { createEvlogHooks } from 'evlog/sveltekit' import { createDatadogDrain } from 'evlog/datadog' export const { handle, handleError } = createEvlogHooks({ drain: createDatadogDrain({ apiKey: '***', site: 'us5.datadoghq.com', timeout: 10000, }), }) ``` ```typescript [TanStack Start]server/plugins/evlog-drain.ts import { definePlugin } from 'nitro' import { createDatadogDrain } from 'evlog/datadog' export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createDatadogDrain({ apiKey: '***', site: 'us5.datadoghq.com', timeout: 10000, })) }) ``` ```typescript [React Router]app/root.tsx import { evlog } from 'evlog/react-router' import { createDatadogDrain } from 'evlog/datadog' export const middleware: Route.MiddlewareFunction[] = [ evlog({ drain: createDatadogDrain({ apiKey: '***', site: 'us5.datadoghq.com', timeout: 10000, }), }), ] ``` ```typescript [Hono]src/index.ts import { evlog } from 'evlog/hono' import { createDatadogDrain } from 'evlog/datadog' app.use(evlog({ drain: createDatadogDrain({ apiKey: '***', site: 'us5.datadoghq.com', timeout: 10000, }), })) ``` ```typescript [Express]src/index.ts import { evlog } from 'evlog/express' import { createDatadogDrain } from 'evlog/datadog' app.use(evlog({ drain: createDatadogDrain({ apiKey: '***', site: 'us5.datadoghq.com', timeout: 10000, }), })) ``` ```typescript [Fastify]src/index.ts import { evlog } from 'evlog/fastify' import { createDatadogDrain } from 'evlog/datadog' await app.register(evlog, { drain: createDatadogDrain({ apiKey: '***', site: 'us5.datadoghq.com', timeout: 10000, }), }) ``` ```typescript [Elysia]src/index.ts import { evlog } from 'evlog/elysia' import { createDatadogDrain } from 'evlog/datadog' app.use(evlog({ drain: createDatadogDrain({ apiKey: '***', site: 'us5.datadoghq.com', timeout: 10000, }), })) ``` ```typescript [NestJS]src/app.module.ts import { EvlogModule } from 'evlog/nestjs' import { createDatadogDrain } from 'evlog/datadog' EvlogModule.forRoot({ drain: createDatadogDrain({ apiKey: '***', site: 'us5.datadoghq.com', timeout: 10000, }), }) ``` ```typescript [oRPC]server/orpc.ts import { withEvlog } from 'evlog/orpc' import { createDatadogDrain } from 'evlog/datadog' const handler = withEvlog(new RPCHandler(router), { drain: createDatadogDrain({ apiKey: '***', site: 'us5.datadoghq.com', timeout: 10000, }), }) ``` ```typescript [Cloudflare Workers]src/worker.ts import { withEvlog } from 'evlog/workers' import { createDatadogDrain } from 'evlog/datadog' export default withEvlog(handler, { drain: createDatadogDrain({ apiKey: '***', site: 'us5.datadoghq.com', timeout: 10000, }), }) ``` ```typescript [Standalone]src/index.ts import { initLogger } from 'evlog' import { createDatadogDrain } from 'evlog/datadog' initLogger({ 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
## Requests Each batch is sent as gzip-compressed JSON with `Content-Encoding: gzip`, as Datadog recommends. The Logs intake accepts at most 1000 entries per request, so a larger batch goes out as several requests of up to 1000, one after another. If one of them fails, the requests already accepted stay accepted, and a retry of the whole batch can send those entries twice. The intake also rejects a request above 5 MB uncompressed with a `413`. The adapter splits by entry count only, so a batch of large events can still reach that limit: lower the [drain pipeline](https://www.evlog.dev/extend/drain-pipeline)'s `batch.size` (default 50) if your events are heavy. ## 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](#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/) 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, so 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), which parses the incoming W3C `traceparent` header into `event.traceId`. The span id in that header belongs to the caller, so it goes to `event.parentSpanId`, and `dd.span_id` is only set when something else provides `event.spanId`: ```typescript [Nuxt]server/plugins/evlog.ts import { createDefaultEnrichers } from 'evlog/enrichers' const enrich = createDefaultEnrichers() export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:enrich', enrich) }) ``` ```typescript [Nitro]server/plugins/evlog.ts import { definePlugin } from 'nitro' import { createDefaultEnrichers } from 'evlog/enrichers' const enrich = createDefaultEnrichers() export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:enrich', enrich) }) ``` ```typescript [Next.js]lib/evlog.ts import { createEvlog } from 'evlog/next' import { createDefaultEnrichers } from 'evlog/enrichers' const enrich = createDefaultEnrichers() export const { withEvlog, useLogger, log, createError } = createEvlog({ service: 'my-app', enrich: enrich, }) ``` ```typescript [SvelteKit]src/hooks.server.ts import { createEvlogHooks } from 'evlog/sveltekit' import { createDefaultEnrichers } from 'evlog/enrichers' const enrich = createDefaultEnrichers() export const { handle, handleError } = createEvlogHooks({ enrich: enrich }) ``` ```typescript [TanStack Start]server/plugins/evlog.ts import { definePlugin } from 'nitro' import { createDefaultEnrichers } from 'evlog/enrichers' const enrich = createDefaultEnrichers() export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:enrich', enrich) }) ``` ```typescript [React Router]app/root.tsx import { evlog } from 'evlog/react-router' import { createDefaultEnrichers } from 'evlog/enrichers' const enrich = createDefaultEnrichers() export const middleware: Route.MiddlewareFunction[] = [ evlog({ enrich: enrich }), ] ``` ```typescript [Hono]src/index.ts import { evlog } from 'evlog/hono' import { createDefaultEnrichers } from 'evlog/enrichers' const enrich = createDefaultEnrichers() app.use(evlog({ enrich: enrich })) ``` ```typescript [Express]src/index.ts import { evlog } from 'evlog/express' import { createDefaultEnrichers } from 'evlog/enrichers' const enrich = createDefaultEnrichers() app.use(evlog({ enrich: enrich })) ``` ```typescript [Fastify]src/index.ts import { evlog } from 'evlog/fastify' import { createDefaultEnrichers } from 'evlog/enrichers' const enrich = createDefaultEnrichers() await app.register(evlog, { enrich: enrich }) ``` ```typescript [Elysia]src/index.ts import { evlog } from 'evlog/elysia' import { createDefaultEnrichers } from 'evlog/enrichers' const enrich = createDefaultEnrichers() app.use(evlog({ enrich: enrich })) ``` ```typescript [NestJS]src/app.module.ts import { EvlogModule } from 'evlog/nestjs' import { createDefaultEnrichers } from 'evlog/enrichers' const enrich = createDefaultEnrichers() EvlogModule.forRoot({ enrich: enrich }) ``` ```typescript [oRPC]server/orpc.ts import { withEvlog } from 'evlog/orpc' import { createDefaultEnrichers } from 'evlog/enrichers' const enrich = createDefaultEnrichers() const handler = withEvlog(new RPCHandler(router), { enrich: enrich }) ``` ```typescript [Cloudflare Workers]src/worker.ts import { withEvlog } from 'evlog/workers' import { createDefaultEnrichers } from 'evlog/enrichers' const enrich = createDefaultEnrichers() export default withEvlog(handler, { enrich: enrich }) ``` ```typescript [Standalone]src/index.ts import { initLogger } from 'evlog' import { createDefaultEnrichers } from 'evlog/enrichers' const enrich = createDefaultEnrichers() initLogger({ 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 const span = tracer.scope().active()?.context() log.set({ traceId: span?.toTraceId(), spanId: span?.toSpanId() }) ``` `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 --- - [Datadog Logs](https://docs.datadoghq.com/logs/) - [OTLP Adapter](https://www.evlog.dev/integrate/adapters/hybrid/otlp) # PostHog Adapter > Send wide events to PostHog Logs via OTLP for structured log querying, debugging, and observability in your PostHog dashboard. [PostHog](https://posthog.com) is an open-source product analytics platform. The evlog PostHog adapter sends your wide events to [PostHog Logs](https://posthog.com/docs/logs) via the standard OTLP format, giving you a dedicated log viewer with filtering, search, and tail mode using your existing PostHog API key. 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: [https://www.evlog.dev/integrate/adapters/cloud/posthog](https://www.evlog.dev/integrate/adapters/cloud/posthog) Framework setup: [https://www.evlog.dev/integrate/frameworks/overview](https://www.evlog.dev/integrate/frameworks/overview) ## 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) 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 ```typescript [Nuxt]server/plugins/evlog-drain.ts import { createPostHogDrain } from 'evlog/posthog' export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createPostHogDrain()) }) ``` ```typescript [Nitro]server/plugins/evlog-drain.ts import { definePlugin } from 'nitro' import { createPostHogDrain } from 'evlog/posthog' export default definePlugin((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 [SvelteKit]src/hooks.server.ts import { createEvlogHooks } from 'evlog/sveltekit' import { createPostHogDrain } from 'evlog/posthog' export const { handle, handleError } = createEvlogHooks({ drain: createPostHogDrain() }) ``` ```typescript [TanStack Start]server/plugins/evlog-drain.ts import { definePlugin } from 'nitro' import { createPostHogDrain } from 'evlog/posthog' export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createPostHogDrain()) }) ``` ```typescript [React Router]app/root.tsx import { evlog } from 'evlog/react-router' import { createPostHogDrain } from 'evlog/posthog' export const middleware: Route.MiddlewareFunction[] = [ evlog({ drain: createPostHogDrain() }), ] ``` ```typescript [Hono]src/index.ts import { createPostHogDrain } from 'evlog/posthog' app.use(evlog({ drain: createPostHogDrain() })) ``` ```typescript [Express]src/index.ts import { createPostHogDrain } from 'evlog/posthog' app.use(evlog({ drain: createPostHogDrain() })) ``` ```typescript [Fastify]src/index.ts import { createPostHogDrain } from 'evlog/posthog' await app.register(evlog, { drain: createPostHogDrain() }) ``` ```typescript [Elysia]src/index.ts import { createPostHogDrain } from 'evlog/posthog' app.use(evlog({ drain: createPostHogDrain() })) ``` ```typescript [NestJS]src/app.module.ts import { createPostHogDrain } from 'evlog/posthog' EvlogModule.forRoot({ drain: createPostHogDrain() }) ``` ```typescript [oRPC]server/orpc.ts import { withEvlog } from 'evlog/orpc' import { createPostHogDrain } from 'evlog/posthog' const handler = withEvlog(new RPCHandler(router), { drain: createPostHogDrain() }) ``` ```typescript [Cloudflare Workers]src/worker.ts import { withEvlog } from 'evlog/workers' import { createPostHogDrain } from 'evlog/posthog' export default withEvlog(handler, { 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 [Nuxt]server/plugins/evlog-drain.ts import { createPostHogDrain } from 'evlog/posthog' export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createPostHogDrain({ host: 'https://eu.i.posthog.com', timeout: 10000, })) }) ``` ```typescript [Nitro]server/plugins/evlog-drain.ts import { definePlugin } from 'nitro' import { createPostHogDrain } from 'evlog/posthog' export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createPostHogDrain({ host: 'https://eu.i.posthog.com', timeout: 10000, })) }) ``` ```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({ host: 'https://eu.i.posthog.com', timeout: 10000, }), }) ``` ```typescript [SvelteKit]src/hooks.server.ts import { createEvlogHooks } from 'evlog/sveltekit' import { createPostHogDrain } from 'evlog/posthog' export const { handle, handleError } = createEvlogHooks({ drain: createPostHogDrain({ host: 'https://eu.i.posthog.com', timeout: 10000, }), }) ``` ```typescript [TanStack Start]server/plugins/evlog-drain.ts import { definePlugin } from 'nitro' import { createPostHogDrain } from 'evlog/posthog' export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createPostHogDrain({ host: 'https://eu.i.posthog.com', timeout: 10000, })) }) ``` ```typescript [React Router]app/root.tsx import { evlog } from 'evlog/react-router' import { createPostHogDrain } from 'evlog/posthog' export const middleware: Route.MiddlewareFunction[] = [ evlog({ drain: createPostHogDrain({ host: 'https://eu.i.posthog.com', timeout: 10000, }), }), ] ``` ```typescript [Hono]src/index.ts import { evlog } from 'evlog/hono' import { createPostHogDrain } from 'evlog/posthog' app.use(evlog({ drain: createPostHogDrain({ host: 'https://eu.i.posthog.com', timeout: 10000, }), })) ``` ```typescript [Express]src/index.ts import { evlog } from 'evlog/express' import { createPostHogDrain } from 'evlog/posthog' app.use(evlog({ drain: createPostHogDrain({ host: 'https://eu.i.posthog.com', timeout: 10000, }), })) ``` ```typescript [Fastify]src/index.ts import { evlog } from 'evlog/fastify' import { createPostHogDrain } from 'evlog/posthog' await app.register(evlog, { drain: createPostHogDrain({ host: 'https://eu.i.posthog.com', timeout: 10000, }), }) ``` ```typescript [Elysia]src/index.ts import { evlog } from 'evlog/elysia' import { createPostHogDrain } from 'evlog/posthog' app.use(evlog({ drain: createPostHogDrain({ host: 'https://eu.i.posthog.com', timeout: 10000, }), })) ``` ```typescript [NestJS]src/app.module.ts import { EvlogModule } from 'evlog/nestjs' import { createPostHogDrain } from 'evlog/posthog' EvlogModule.forRoot({ drain: createPostHogDrain({ host: 'https://eu.i.posthog.com', timeout: 10000, }), }) ``` ```typescript [oRPC]server/orpc.ts import { withEvlog } from 'evlog/orpc' import { createPostHogDrain } from 'evlog/posthog' const handler = withEvlog(new RPCHandler(router), { drain: createPostHogDrain({ host: 'https://eu.i.posthog.com', timeout: 10000, }), }) ``` ```typescript [Cloudflare Workers]src/worker.ts import { withEvlog } from 'evlog/workers' import { createPostHogDrain } from 'evlog/posthog' export default withEvlog(handler, { drain: createPostHogDrain({ host: 'https://eu.i.posthog.com', timeout: 10000, }), }) ``` ```typescript [Standalone]src/index.ts import { initLogger } from 'evlog' import { createPostHogDrain } from 'evlog/posthog' initLogger({ 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
timeout number 5000 Request timeout in milliseconds
retries number 2 Retry attempts on transient failures
## What happens between a log and PostHog 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 ## Choose 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 [Nuxt]server/plugins/evlog-drain.ts import { createPostHogDrain } from 'evlog/posthog' export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createPostHogDrain({ recordShape: 'compact' })) }) ``` ```typescript [Nitro]server/plugins/evlog-drain.ts import { definePlugin } from 'nitro' import { createPostHogDrain } from 'evlog/posthog' export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createPostHogDrain({ recordShape: 'compact' })) }) ``` ```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({ recordShape: 'compact' }), }) ``` ```typescript [SvelteKit]src/hooks.server.ts import { createEvlogHooks } from 'evlog/sveltekit' import { createPostHogDrain } from 'evlog/posthog' export const { handle, handleError } = createEvlogHooks({ drain: createPostHogDrain({ recordShape: 'compact' }) }) ``` ```typescript [TanStack Start]server/plugins/evlog-drain.ts import { definePlugin } from 'nitro' import { createPostHogDrain } from 'evlog/posthog' export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createPostHogDrain({ recordShape: 'compact' })) }) ``` ```typescript [React Router]app/root.tsx import { evlog } from 'evlog/react-router' import { createPostHogDrain } from 'evlog/posthog' export const middleware: Route.MiddlewareFunction[] = [ evlog({ drain: createPostHogDrain({ recordShape: 'compact' }) }), ] ``` ```typescript [Hono]src/index.ts import { evlog } from 'evlog/hono' import { createPostHogDrain } from 'evlog/posthog' app.use(evlog({ drain: createPostHogDrain({ recordShape: 'compact' }) })) ``` ```typescript [Express]src/index.ts import { evlog } from 'evlog/express' import { createPostHogDrain } from 'evlog/posthog' app.use(evlog({ drain: createPostHogDrain({ recordShape: 'compact' }) })) ``` ```typescript [Fastify]src/index.ts import { evlog } from 'evlog/fastify' import { createPostHogDrain } from 'evlog/posthog' await app.register(evlog, { drain: createPostHogDrain({ recordShape: 'compact' }) }) ``` ```typescript [Elysia]src/index.ts import { evlog } from 'evlog/elysia' import { createPostHogDrain } from 'evlog/posthog' app.use(evlog({ drain: createPostHogDrain({ recordShape: 'compact' }) })) ``` ```typescript [NestJS]src/app.module.ts import { EvlogModule } from 'evlog/nestjs' import { createPostHogDrain } from 'evlog/posthog' EvlogModule.forRoot({ drain: createPostHogDrain({ recordShape: 'compact' }) }) ``` ```typescript [oRPC]server/orpc.ts import { withEvlog } from 'evlog/orpc' import { createPostHogDrain } from 'evlog/posthog' const handler = withEvlog(new RPCHandler(router), { drain: createPostHogDrain({ recordShape: 'compact' }) }) ``` ```typescript [Cloudflare Workers]src/worker.ts import { withEvlog } from 'evlog/workers' import { createPostHogDrain } from 'evlog/posthog' export default withEvlog(handler, { drain: createPostHogDrain({ recordShape: 'compact' }) }) ``` ```typescript [Standalone]src/index.ts import { initLogger } from 'evlog' import { createPostHogDrain } from 'evlog/posthog' initLogger({ 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. `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. ## Link a log to a person and a session replay A log that carries a person identifier shows up on that person's profile in PostHog, under the **Logs** tab, with 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 [Nuxt]server/api/checkout.post.ts import { useLogger } from 'evlog' const log = useLogger(event) log.set({ userId: user.id, // → posthogDistinctId, links to the person sessionId: body.sessionId, // → sessionId, links to the session replay }) ``` ```typescript [Next.js]app/api/checkout/route.ts import { useLogger } from '@/lib/evlog' const log = useLogger() log.set({ userId: user.id, // → posthogDistinctId, links to the person sessionId: body.sessionId, // → sessionId, links to the session replay }) ``` ```typescript [Hono]src/index.ts const log = c.get('log') log.set({ userId: user.id, // → posthogDistinctId, links to the person sessionId: body.sessionId, // → sessionId, links to the session replay }) ``` ```typescript [Express]src/index.ts const log = req.log 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() }, }) ``` ### Point at your own fields When identity lives somewhere else on your events, point the adapter at it. Both options take a dot path: ```typescript [Nuxt]server/plugins/evlog-drain.ts import { createPostHogDrain } from 'evlog/posthog' export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createPostHogDrain({ distinctIdField: 'user.id', sessionIdField: 'session.id', })) }) ``` ```typescript [Nitro]server/plugins/evlog-drain.ts import { definePlugin } from 'nitro' import { createPostHogDrain } from 'evlog/posthog' export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createPostHogDrain({ distinctIdField: 'user.id', sessionIdField: 'session.id', })) }) ``` ```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({ distinctIdField: 'user.id', sessionIdField: 'session.id', }), }) ``` ```typescript [SvelteKit]src/hooks.server.ts import { createEvlogHooks } from 'evlog/sveltekit' import { createPostHogDrain } from 'evlog/posthog' export const { handle, handleError } = createEvlogHooks({ drain: createPostHogDrain({ distinctIdField: 'user.id', sessionIdField: 'session.id', }), }) ``` ```typescript [TanStack Start]server/plugins/evlog-drain.ts import { definePlugin } from 'nitro' import { createPostHogDrain } from 'evlog/posthog' export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createPostHogDrain({ distinctIdField: 'user.id', sessionIdField: 'session.id', })) }) ``` ```typescript [React Router]app/root.tsx import { evlog } from 'evlog/react-router' import { createPostHogDrain } from 'evlog/posthog' export const middleware: Route.MiddlewareFunction[] = [ evlog({ drain: createPostHogDrain({ distinctIdField: 'user.id', sessionIdField: 'session.id', }), }), ] ``` ```typescript [Hono]src/index.ts import { evlog } from 'evlog/hono' import { createPostHogDrain } from 'evlog/posthog' app.use(evlog({ drain: createPostHogDrain({ distinctIdField: 'user.id', sessionIdField: 'session.id', }), })) ``` ```typescript [Express]src/index.ts import { evlog } from 'evlog/express' import { createPostHogDrain } from 'evlog/posthog' app.use(evlog({ drain: createPostHogDrain({ distinctIdField: 'user.id', sessionIdField: 'session.id', }), })) ``` ```typescript [Fastify]src/index.ts import { evlog } from 'evlog/fastify' import { createPostHogDrain } from 'evlog/posthog' await app.register(evlog, { drain: createPostHogDrain({ distinctIdField: 'user.id', sessionIdField: 'session.id', }), }) ``` ```typescript [Elysia]src/index.ts import { evlog } from 'evlog/elysia' import { createPostHogDrain } from 'evlog/posthog' app.use(evlog({ drain: createPostHogDrain({ distinctIdField: 'user.id', sessionIdField: 'session.id', }), })) ``` ```typescript [NestJS]src/app.module.ts import { EvlogModule } from 'evlog/nestjs' import { createPostHogDrain } from 'evlog/posthog' EvlogModule.forRoot({ drain: createPostHogDrain({ distinctIdField: 'user.id', sessionIdField: 'session.id', }), }) ``` ```typescript [oRPC]server/orpc.ts import { withEvlog } from 'evlog/orpc' import { createPostHogDrain } from 'evlog/posthog' const handler = withEvlog(new RPCHandler(router), { drain: createPostHogDrain({ distinctIdField: 'user.id', sessionIdField: 'session.id', }), }) ``` ```typescript [Cloudflare Workers]src/worker.ts import { withEvlog } from 'evlog/workers' import { createPostHogDrain } from 'evlog/posthog' export default withEvlog(handler, { drain: createPostHogDrain({ distinctIdField: 'user.id', sessionIdField: 'session.id', }), }) ``` ```typescript [Standalone]src/index.ts import { initLogger } from 'evlog' import { createPostHogDrain } from 'evlog/posthog' initLogger({ 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. 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 ``` ## Query the 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 ## Send custom events alongside logs If you prefer sending logs as PostHog custom events (e.g., for product analytics, cohorts, or funnels), use `createPostHogDrain()` with `mode: 'events'`: ```typescript [Nuxt]server/plugins/evlog-drain.ts import { createPostHogDrain } from 'evlog/posthog' export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createPostHogDrain({ mode: 'events', eventName: 'server_request', distinctId: 'my-backend-service', })) }) ``` ```typescript [Nitro]server/plugins/evlog-drain.ts import { definePlugin } from 'nitro' import { createPostHogDrain } from 'evlog/posthog' export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createPostHogDrain({ mode: 'events', eventName: 'server_request', distinctId: 'my-backend-service', })) }) ``` ```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({ mode: 'events', eventName: 'server_request', distinctId: 'my-backend-service', }), }) ``` ```typescript [SvelteKit]src/hooks.server.ts import { createEvlogHooks } from 'evlog/sveltekit' import { createPostHogDrain } from 'evlog/posthog' export const { handle, handleError } = createEvlogHooks({ drain: createPostHogDrain({ mode: 'events', eventName: 'server_request', distinctId: 'my-backend-service', }), }) ``` ```typescript [TanStack Start]server/plugins/evlog-drain.ts import { definePlugin } from 'nitro' import { createPostHogDrain } from 'evlog/posthog' export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createPostHogDrain({ mode: 'events', eventName: 'server_request', distinctId: 'my-backend-service', })) }) ``` ```typescript [React Router]app/root.tsx import { evlog } from 'evlog/react-router' import { createPostHogDrain } from 'evlog/posthog' export const middleware: Route.MiddlewareFunction[] = [ evlog({ drain: createPostHogDrain({ mode: 'events', eventName: 'server_request', distinctId: 'my-backend-service', }), }), ] ``` ```typescript [Hono]src/index.ts import { evlog } from 'evlog/hono' import { createPostHogDrain } from 'evlog/posthog' app.use(evlog({ drain: createPostHogDrain({ mode: 'events', eventName: 'server_request', distinctId: 'my-backend-service', }), })) ``` ```typescript [Express]src/index.ts import { evlog } from 'evlog/express' import { createPostHogDrain } from 'evlog/posthog' app.use(evlog({ drain: createPostHogDrain({ mode: 'events', eventName: 'server_request', distinctId: 'my-backend-service', }), })) ``` ```typescript [Fastify]src/index.ts import { evlog } from 'evlog/fastify' import { createPostHogDrain } from 'evlog/posthog' await app.register(evlog, { drain: createPostHogDrain({ mode: 'events', eventName: 'server_request', distinctId: 'my-backend-service', }), }) ``` ```typescript [Elysia]src/index.ts import { evlog } from 'evlog/elysia' import { createPostHogDrain } from 'evlog/posthog' app.use(evlog({ drain: createPostHogDrain({ mode: 'events', eventName: 'server_request', distinctId: 'my-backend-service', }), })) ``` ```typescript [NestJS]src/app.module.ts import { EvlogModule } from 'evlog/nestjs' import { createPostHogDrain } from 'evlog/posthog' EvlogModule.forRoot({ drain: createPostHogDrain({ mode: 'events', eventName: 'server_request', distinctId: 'my-backend-service', }), }) ``` ```typescript [oRPC]server/orpc.ts import { withEvlog } from 'evlog/orpc' import { createPostHogDrain } from 'evlog/posthog' const handler = withEvlog(new RPCHandler(router), { drain: createPostHogDrain({ mode: 'events', eventName: 'server_request', distinctId: 'my-backend-service', }), }) ``` ```typescript [Cloudflare Workers]src/worker.ts import { withEvlog } from 'evlog/workers' import { createPostHogDrain } from 'evlog/posthog' export default withEvlog(handler, { drain: createPostHogDrain({ mode: 'events', eventName: 'server_request', distinctId: 'my-backend-service', }), }) ``` ```typescript [Standalone]src/index.ts import { initLogger } from 'evlog' import { createPostHogDrain } from 'evlog/posthog' initLogger({ 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](#quick-start) above). Custom events count towards your PostHog event quota. PostHog Logs (the default `createPostHogDrain()`) is significantly cheaper. **Legacy:** `createPostHogEventsDrain()` is deprecated and re-routes to `createPostHogDrain({ mode: 'events' })`. It will be removed in the next major release. ### Configure which events go out
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)
recordShape 'json' | 'compact' 'json' Event property shape: compact flattens nested fields (see below )
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.*
With `recordShape: 'compact'`, the other fields are flattened into dotted keys the same way as in logs mode: a nested `ai` object arrives as `properties.ai.costUsd` and `properties.ai.calls` instead of one opaque `properties.ai` value. PostHog can filter and break down on those individual properties; the default `json` shape keeps the nested object as a single serialized property. See [Choose a record shape](#choose-a-record-shape) for the trade-off and how it affects your saved views and alerts. ### How the distinct ID is resolved 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 and 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 // 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' }) ``` 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. ### When to use a log and when to use an event
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 [Nuxt]server/plugins/evlog.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)]) } export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', drain) }) ``` ```typescript [Nitro]server/plugins/evlog.ts import { definePlugin } from 'nitro' import { createPostHogDrain } from 'evlog/posthog' const logs = createPostHogDrain() const events = createPostHogDrain({ mode: 'events' }) const drain = async (ctx) => { await Promise.allSettled([logs(ctx), events(ctx)]) } export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', drain) }) ``` ```typescript [Next.js]lib/evlog.ts import { createEvlog } from 'evlog/next' import { createPostHogDrain } from 'evlog/posthog' const logs = createPostHogDrain() const events = createPostHogDrain({ mode: 'events' }) const drain = async (ctx) => { await Promise.allSettled([logs(ctx), events(ctx)]) } export const { withEvlog, useLogger, log, createError } = createEvlog({ service: 'my-app', drain: drain, }) ``` ```typescript [SvelteKit]src/hooks.server.ts import { createEvlogHooks } from 'evlog/sveltekit' import { createPostHogDrain } from 'evlog/posthog' const logs = createPostHogDrain() const events = createPostHogDrain({ mode: 'events' }) const drain = async (ctx) => { await Promise.allSettled([logs(ctx), events(ctx)]) } export const { handle, handleError } = createEvlogHooks({ drain: drain }) ``` ```typescript [TanStack Start]server/plugins/evlog.ts import { definePlugin } from 'nitro' import { createPostHogDrain } from 'evlog/posthog' const logs = createPostHogDrain() const events = createPostHogDrain({ mode: 'events' }) const drain = async (ctx) => { await Promise.allSettled([logs(ctx), events(ctx)]) } export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', drain) }) ``` ```typescript [React Router]app/root.tsx import { evlog } from 'evlog/react-router' import { createPostHogDrain } from 'evlog/posthog' const logs = createPostHogDrain() const events = createPostHogDrain({ mode: 'events' }) const drain = async (ctx) => { await Promise.allSettled([logs(ctx), events(ctx)]) } export const middleware: Route.MiddlewareFunction[] = [ evlog({ drain: drain }), ] ``` ```typescript [Hono]src/index.ts import { evlog } from 'evlog/hono' import { createPostHogDrain } from 'evlog/posthog' const logs = createPostHogDrain() const events = createPostHogDrain({ mode: 'events' }) const drain = async (ctx) => { await Promise.allSettled([logs(ctx), events(ctx)]) } app.use(evlog({ drain: drain })) ``` ```typescript [Express]src/index.ts import { evlog } from 'evlog/express' import { createPostHogDrain } from 'evlog/posthog' const logs = createPostHogDrain() const events = createPostHogDrain({ mode: 'events' }) const drain = async (ctx) => { await Promise.allSettled([logs(ctx), events(ctx)]) } app.use(evlog({ drain: drain })) ``` ```typescript [Fastify]src/index.ts import { evlog } from 'evlog/fastify' import { createPostHogDrain } from 'evlog/posthog' const logs = createPostHogDrain() const events = createPostHogDrain({ mode: 'events' }) const drain = async (ctx) => { await Promise.allSettled([logs(ctx), events(ctx)]) } await app.register(evlog, { drain: drain }) ``` ```typescript [Elysia]src/index.ts import { evlog } from 'evlog/elysia' import { createPostHogDrain } from 'evlog/posthog' const logs = createPostHogDrain() const events = createPostHogDrain({ mode: 'events' }) const drain = async (ctx) => { await Promise.allSettled([logs(ctx), events(ctx)]) } app.use(evlog({ drain: drain })) ``` ```typescript [NestJS]src/app.module.ts import { EvlogModule } from 'evlog/nestjs' import { createPostHogDrain } from 'evlog/posthog' const logs = createPostHogDrain() const events = createPostHogDrain({ mode: 'events' }) const drain = async (ctx) => { await Promise.allSettled([logs(ctx), events(ctx)]) } EvlogModule.forRoot({ drain: drain }) ``` ```typescript [oRPC]server/orpc.ts import { withEvlog } from 'evlog/orpc' import { createPostHogDrain } from 'evlog/posthog' const logs = createPostHogDrain() const events = createPostHogDrain({ mode: 'events' }) const drain = async (ctx) => { await Promise.allSettled([logs(ctx), events(ctx)]) } const handler = withEvlog(new RPCHandler(router), { drain: drain }) ``` ```typescript [Cloudflare Workers]src/worker.ts import { withEvlog } from 'evlog/workers' import { createPostHogDrain } from 'evlog/posthog' const logs = createPostHogDrain() const events = createPostHogDrain({ mode: 'events' }) const drain = async (ctx) => { await Promise.allSettled([logs(ctx), events(ctx)]) } export default withEvlog(handler, { drain: drain }) ``` ```typescript [Standalone]src/index.ts import { initLogger } from 'evlog' import { createPostHogDrain } from 'evlog/posthog' const logs = createPostHogDrain() const events = createPostHogDrain({ mode: 'events' }) const drain = async (ctx) => { await Promise.allSettled([logs(ctx), events(ctx)]) } initLogger({ drain: drain }) ``` ## 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 ``` ## Call the drain without a framework 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 --- - [PostHog Dashboard](https://app.posthog.com) - [Axiom Adapter](https://www.evlog.dev/integrate/adapters/cloud/axiom) # Sazabi Adapter > Send wide events to Sazabi over OTLP/HTTP. The log stream's intake URL carries its key, so the only setting is one environment variable. [Sazabi](https://sazabi.com) is AI-native log search and incident investigation. The Sazabi adapter sends your wide events to a Sazabi log stream over **OTLP/HTTP**, so every field you set with `log.set()` arrives as a searchable attribute. Add the Sazabi drain adapter to send evlog wide events to Sazabi. 1. Identify which framework I'm using and follow its evlog integration pattern 2. Install evlog if not already installed 3. Import createSazabiDrain from 'evlog/sazabi' 4. Wire createSazabiDrain() into my framework's drain configuration 5. Set SAZABI_INTAKE_URL environment variable in .env 6. Test by triggering a request and checking the log stream in Sazabi Adapter docs: [https://www.evlog.dev/integrate/adapters/cloud/sazabi](https://www.evlog.dev/integrate/adapters/cloud/sazabi) Framework setup: [https://www.evlog.dev/integrate/frameworks/overview](https://www.evlog.dev/integrate/frameworks/overview) ## Installation The Sazabi adapter comes bundled with evlog: ```typescript [src/index.ts] import { createSazabiDrain } from 'evlog/sazabi' ``` ## Quick Start ### 1. Copy your intake URL 1. In Sazabi, open **Log streams** and create or open an **OpenTelemetry** log stream 2. Copy its **Intake URL**. The stream's public key is part of the URL, so there is no separate API key to set ### 2. Set environment variables ```bash [.env] SAZABI_INTAKE_URL=https://.us-west-2.intake.sazabi.com ``` ### 3. Wire the drain to your framework ```typescript [Nuxt]server/plugins/evlog-drain.ts import { createSazabiDrain } from 'evlog/sazabi' export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createSazabiDrain()) }) ``` ```typescript [Nitro]server/plugins/evlog-drain.ts import { definePlugin } from 'nitro' import { createSazabiDrain } from 'evlog/sazabi' export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createSazabiDrain()) }) ``` ```typescript [Next.js]lib/evlog.ts import { createEvlog } from 'evlog/next' import { createSazabiDrain } from 'evlog/sazabi' export const { withEvlog, useLogger, log, createError } = createEvlog({ service: 'my-app', drain: createSazabiDrain(), }) ``` ```typescript [SvelteKit]src/hooks.server.ts import { createEvlogHooks } from 'evlog/sveltekit' import { createSazabiDrain } from 'evlog/sazabi' export const { handle, handleError } = createEvlogHooks({ drain: createSazabiDrain() }) ``` ```typescript [TanStack Start]server/plugins/evlog-drain.ts import { definePlugin } from 'nitro' import { createSazabiDrain } from 'evlog/sazabi' export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createSazabiDrain()) }) ``` ```typescript [React Router]app/root.tsx import { evlog } from 'evlog/react-router' import { createSazabiDrain } from 'evlog/sazabi' export const middleware: Route.MiddlewareFunction[] = [ evlog({ drain: createSazabiDrain() }), ] ``` ```typescript [Hono]src/index.ts import { createSazabiDrain } from 'evlog/sazabi' app.use(evlog({ drain: createSazabiDrain() })) ``` ```typescript [Express]src/index.ts import { createSazabiDrain } from 'evlog/sazabi' app.use(evlog({ drain: createSazabiDrain() })) ``` ```typescript [Fastify]src/index.ts import { createSazabiDrain } from 'evlog/sazabi' await app.register(evlog, { drain: createSazabiDrain() }) ``` ```typescript [Elysia]src/index.ts import { createSazabiDrain } from 'evlog/sazabi' app.use(evlog({ drain: createSazabiDrain() })) ``` ```typescript [NestJS]src/app.module.ts import { createSazabiDrain } from 'evlog/sazabi' EvlogModule.forRoot({ drain: createSazabiDrain() }) ``` ```typescript [oRPC]server/orpc.ts import { withEvlog } from 'evlog/orpc' import { createSazabiDrain } from 'evlog/sazabi' const handler = withEvlog(new RPCHandler(router), { drain: createSazabiDrain() }) ``` ```typescript [Cloudflare Workers]src/worker.ts import { withEvlog } from 'evlog/workers' import { createSazabiDrain } from 'evlog/sazabi' export default withEvlog(handler, { drain: createSazabiDrain() }) ``` ```typescript [Standalone] import { createSazabiDrain } from 'evlog/sazabi' initLogger({ drain: createSazabiDrain() }) ``` Trigger a request and the wide event appears in the log stream within a few seconds. ## Configuration The adapter reads configuration from multiple sources (highest priority first): 1. **Overrides** passed to `createSazabiDrain()` 2. **Runtime config** at `runtimeConfig.evlog.sazabi` or `runtimeConfig.sazabi` (Nuxt/Nitro only) 3. **Environment variables** (`SAZABI_*`) ### Environment Variables
Variable Description
SAZABI_INTAKE_URL Intake URL of the log stream. evlog posts to /v1/logs ; a URL that already ends with /v1/logs is used as is
SAZABI_SERVICE_NAME Override service.name
OTEL_SERVICE_NAME Fallback for service.name (same as the OTLP adapter)
### Runtime Config (Nuxt only) ```typescript [nuxt.config.ts] export default defineNuxtConfig({ runtimeConfig: { sazabi: { // endpoint: '', // Set via SAZABI_INTAKE_URL }, }, }) ``` ### Override Options Pass options directly to override any configuration: ```typescript [Nuxt]server/plugins/evlog-drain.ts import { createSazabiDrain } from 'evlog/sazabi' export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createSazabiDrain({ serviceName: 'checkout-api', resourceAttributes: { 'deployment.region': 'us-west-2' }, })) }) ``` ```typescript [Nitro]server/plugins/evlog-drain.ts import { definePlugin } from 'nitro' import { createSazabiDrain } from 'evlog/sazabi' export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createSazabiDrain({ serviceName: 'checkout-api', resourceAttributes: { 'deployment.region': 'us-west-2' }, })) }) ``` ```typescript [Next.js]lib/evlog.ts import { createEvlog } from 'evlog/next' import { createSazabiDrain } from 'evlog/sazabi' export const { withEvlog, useLogger, log, createError } = createEvlog({ service: 'my-app', drain: createSazabiDrain({ serviceName: 'checkout-api', resourceAttributes: { 'deployment.region': 'us-west-2' }, }), }) ``` ```typescript [SvelteKit]src/hooks.server.ts import { createEvlogHooks } from 'evlog/sveltekit' import { createSazabiDrain } from 'evlog/sazabi' export const { handle, handleError } = createEvlogHooks({ drain: createSazabiDrain({ serviceName: 'checkout-api', resourceAttributes: { 'deployment.region': 'us-west-2' }, }), }) ``` ```typescript [TanStack Start]server/plugins/evlog-drain.ts import { definePlugin } from 'nitro' import { createSazabiDrain } from 'evlog/sazabi' export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createSazabiDrain({ serviceName: 'checkout-api', resourceAttributes: { 'deployment.region': 'us-west-2' }, })) }) ``` ```typescript [React Router]app/root.tsx import { evlog } from 'evlog/react-router' import { createSazabiDrain } from 'evlog/sazabi' export const middleware: Route.MiddlewareFunction[] = [ evlog({ drain: createSazabiDrain({ serviceName: 'checkout-api', resourceAttributes: { 'deployment.region': 'us-west-2' }, }), }), ] ``` ```typescript [Hono]src/index.ts import { evlog } from 'evlog/hono' import { createSazabiDrain } from 'evlog/sazabi' app.use(evlog({ drain: createSazabiDrain({ serviceName: 'checkout-api', resourceAttributes: { 'deployment.region': 'us-west-2' }, }), })) ``` ```typescript [Express]src/index.ts import { evlog } from 'evlog/express' import { createSazabiDrain } from 'evlog/sazabi' app.use(evlog({ drain: createSazabiDrain({ serviceName: 'checkout-api', resourceAttributes: { 'deployment.region': 'us-west-2' }, }), })) ``` ```typescript [Fastify]src/index.ts import { evlog } from 'evlog/fastify' import { createSazabiDrain } from 'evlog/sazabi' await app.register(evlog, { drain: createSazabiDrain({ serviceName: 'checkout-api', resourceAttributes: { 'deployment.region': 'us-west-2' }, }), }) ``` ```typescript [Elysia]src/index.ts import { evlog } from 'evlog/elysia' import { createSazabiDrain } from 'evlog/sazabi' app.use(evlog({ drain: createSazabiDrain({ serviceName: 'checkout-api', resourceAttributes: { 'deployment.region': 'us-west-2' }, }), })) ``` ```typescript [NestJS]src/app.module.ts import { EvlogModule } from 'evlog/nestjs' import { createSazabiDrain } from 'evlog/sazabi' EvlogModule.forRoot({ drain: createSazabiDrain({ serviceName: 'checkout-api', resourceAttributes: { 'deployment.region': 'us-west-2' }, }), }) ``` ```typescript [oRPC]server/orpc.ts import { withEvlog } from 'evlog/orpc' import { createSazabiDrain } from 'evlog/sazabi' const handler = withEvlog(new RPCHandler(router), { drain: createSazabiDrain({ serviceName: 'checkout-api', resourceAttributes: { 'deployment.region': 'us-west-2' }, }), }) ``` ```typescript [Cloudflare Workers]src/worker.ts import { withEvlog } from 'evlog/workers' import { createSazabiDrain } from 'evlog/sazabi' export default withEvlog(handler, { drain: createSazabiDrain({ serviceName: 'checkout-api', resourceAttributes: { 'deployment.region': 'us-west-2' }, }), }) ``` ```typescript [Standalone]src/index.ts import { initLogger } from 'evlog' import { createSazabiDrain } from 'evlog/sazabi' initLogger({ drain: createSazabiDrain({ serviceName: 'checkout-api', resourceAttributes: { 'deployment.region': 'us-west-2' }, }), }) ```
Option Type Default Description
endpoint string SAZABI_INTAKE_URL Intake URL of the log stream
serviceName string event.service Override service.name
resourceAttributes object - Additional OTLP resource attributes
timeout number 5000 Request timeout in milliseconds
retries number 2 Retry attempts on transient failures
recordShape 'compact' | 'json' 'compact' Log record shape: a one-line summary body with dotted attributes, or the serialized event as the body
## How It Works The adapter is a thin layer over the [OTLP adapter](https://www.evlog.dev/integrate/adapters/hybrid/otlp): it normalizes the intake URL and sends the batch with the same OTLP/JSON encoding. - The event's `service`, `environment`, `version`, `region` and `commitHash` become resource attributes - `level` maps to the OTLP severity - Records use the compact shape: the body is a one-line summary such as `POST /api/checkout (500)` - Every other field of the wide event becomes a log attribute, with nested fields flattened to dotted keys, so `user.plan` or `cart.total` can be filtered and grouped in Sazabi ## Querying logs in Sazabi Open the log stream and ask a question in plain language, for example "how many checkout requests failed in the last hour?", or filter on any field the wide event carries. ## Troubleshooting ### Missing endpoint error ```text [evlog/sazabi] Missing endpoint. Set SAZABI_INTAKE_URL, or pass to createSazabiDrain() ``` Set `SAZABI_INTAKE_URL` in the environment the server runs in, or pass `endpoint` to `createSazabiDrain()`. ### 4xx from the intake Copy the intake URL again from the log stream: a truncated host or an old stream's URL is rejected. ## Direct API Usage Send events without the drain, for scripts and jobs: ```typescript [scripts/send.ts] import { sendBatchToSazabi, sendToSazabi } from 'evlog/sazabi' await sendToSazabi(event, { endpoint: process.env.SAZABI_INTAKE_URL! }) await sendBatchToSazabi(events, { endpoint: process.env.SAZABI_INTAKE_URL! }) ``` ## Next Steps - [OTLP Adapter](https://www.evlog.dev/integrate/adapters/hybrid/otlp) for the encoding this adapter reuses - [Drain Pipeline](https://www.evlog.dev/extend/drain-pipeline) for batching, retries and buffering - [Adapters Overview](https://www.evlog.dev/integrate/adapters/overview) for the other destinations --- - [Sazabi](https://sazabi.com) - [OTLP Adapter](https://www.evlog.dev/integrate/adapters/hybrid/otlp) # Sentry Adapter > Send wide events to Sentry Logs and turn server errors into Sentry issues that carry the event's trace. Zero-config setup with a DSN. [Sentry](https://sentry.io) 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. With `issues: true`, which the Quick Start below sets, every server error also opens a **Sentry issue** with its stack and the wide event's trace, so issue alerts and the issue page work on evlog errors and lead back to the full event. Add the Sentry drain adapter to send evlog wide events to Sentry Logs and open Sentry issues for server errors. 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({ issues: true }) into my framework's drain configuration. If the app already captures errors with @sentry/node or another Sentry SDK, use createSentryDrain() without issues, so each error is not reported twice 5. Set SENTRY_DSN environment variable 6. Test by triggering a request and checking Sentry > Explore > Logs, then trigger a server error and check Sentry > Issues Adapter docs: [https://www.evlog.dev/integrate/adapters/cloud/sentry](https://www.evlog.dev/integrate/adapters/cloud/sentry) Framework setup: [https://www.evlog.dev/integrate/frameworks/overview](https://www.evlog.dev/integrate/frameworks/overview) ## 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) 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 ```typescript [Nuxt]server/plugins/evlog-drain.ts import { createSentryDrain } from 'evlog/sentry' export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createSentryDrain({ issues: true })) }) ``` ```typescript [Nitro]server/plugins/evlog-drain.ts import { definePlugin } from 'nitro' import { createSentryDrain } from 'evlog/sentry' export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createSentryDrain({ issues: true })) }) ``` ```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({ issues: true }), }) ``` ```typescript [SvelteKit]src/hooks.server.ts import { createEvlogHooks } from 'evlog/sveltekit' import { createSentryDrain } from 'evlog/sentry' export const { handle, handleError } = createEvlogHooks({ drain: createSentryDrain({ issues: true }) }) ``` ```typescript [TanStack Start]server/plugins/evlog-drain.ts import { definePlugin } from 'nitro' import { createSentryDrain } from 'evlog/sentry' export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createSentryDrain({ issues: true })) }) ``` ```typescript [React Router]app/root.tsx import { evlog } from 'evlog/react-router' import { createSentryDrain } from 'evlog/sentry' export const middleware: Route.MiddlewareFunction[] = [ evlog({ drain: createSentryDrain({ issues: true }) }), ] ``` ```typescript [Hono]src/index.ts import { createSentryDrain } from 'evlog/sentry' app.use(evlog({ drain: createSentryDrain({ issues: true }) })) ``` ```typescript [Express]src/index.ts import { createSentryDrain } from 'evlog/sentry' app.use(evlog({ drain: createSentryDrain({ issues: true }) })) ``` ```typescript [Fastify]src/index.ts import { createSentryDrain } from 'evlog/sentry' await app.register(evlog, { drain: createSentryDrain({ issues: true }) }) ``` ```typescript [Elysia]src/index.ts import { createSentryDrain } from 'evlog/sentry' app.use(evlog({ drain: createSentryDrain({ issues: true }) })) ``` ```typescript [NestJS]src/app.module.ts import { createSentryDrain } from 'evlog/sentry' EvlogModule.forRoot({ drain: createSentryDrain({ issues: true }) }) ``` ```typescript [oRPC]server/orpc.ts import { withEvlog } from 'evlog/orpc' import { createSentryDrain } from 'evlog/sentry' const handler = withEvlog(new RPCHandler(router), { drain: createSentryDrain({ issues: true }) }) ``` ```typescript [Cloudflare Workers]src/worker.ts import { withEvlog } from 'evlog/workers' import { createSentryDrain } from 'evlog/sentry' export default withEvlog(handler, { drain: createSentryDrain({ issues: true }) }) ``` ```typescript [Standalone] import { createSentryDrain } from 'evlog/sentry' initLogger({ drain: createSentryDrain({ issues: true }) }) ``` Your wide events now appear in **Explore > Logs**, and every `error` or `fatal` event without a 4xx status opens an issue in **Issues**. If the app already captures errors with `@sentry/node`, drop `issues: true`: see [Issues and Alerts](#issues-and-alerts). ## 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 [Nuxt]server/plugins/evlog-drain.ts import { createSentryDrain } from 'evlog/sentry' export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createSentryDrain({ dsn: 'https://key@o0.ingest.sentry.io/123', tags: { team: 'backend' }, timeout: 10000, })) }) ``` ```typescript [Nitro]server/plugins/evlog-drain.ts import { definePlugin } from 'nitro' import { createSentryDrain } from 'evlog/sentry' export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createSentryDrain({ dsn: 'https://key@o0.ingest.sentry.io/123', tags: { team: 'backend' }, timeout: 10000, })) }) ``` ```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({ dsn: 'https://key@o0.ingest.sentry.io/123', tags: { team: 'backend' }, timeout: 10000, }), }) ``` ```typescript [SvelteKit]src/hooks.server.ts import { createEvlogHooks } from 'evlog/sveltekit' import { createSentryDrain } from 'evlog/sentry' export const { handle, handleError } = createEvlogHooks({ drain: createSentryDrain({ dsn: 'https://key@o0.ingest.sentry.io/123', tags: { team: 'backend' }, timeout: 10000, }), }) ``` ```typescript [TanStack Start]server/plugins/evlog-drain.ts import { definePlugin } from 'nitro' import { createSentryDrain } from 'evlog/sentry' export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createSentryDrain({ dsn: 'https://key@o0.ingest.sentry.io/123', tags: { team: 'backend' }, timeout: 10000, })) }) ``` ```typescript [React Router]app/root.tsx import { evlog } from 'evlog/react-router' import { createSentryDrain } from 'evlog/sentry' export const middleware: Route.MiddlewareFunction[] = [ evlog({ drain: createSentryDrain({ dsn: 'https://key@o0.ingest.sentry.io/123', tags: { team: 'backend' }, timeout: 10000, }), }), ] ``` ```typescript [Hono]src/index.ts import { evlog } from 'evlog/hono' import { createSentryDrain } from 'evlog/sentry' app.use(evlog({ drain: createSentryDrain({ dsn: 'https://key@o0.ingest.sentry.io/123', tags: { team: 'backend' }, timeout: 10000, }), })) ``` ```typescript [Express]src/index.ts import { evlog } from 'evlog/express' import { createSentryDrain } from 'evlog/sentry' app.use(evlog({ drain: createSentryDrain({ dsn: 'https://key@o0.ingest.sentry.io/123', tags: { team: 'backend' }, timeout: 10000, }), })) ``` ```typescript [Fastify]src/index.ts import { evlog } from 'evlog/fastify' import { createSentryDrain } from 'evlog/sentry' await app.register(evlog, { drain: createSentryDrain({ dsn: 'https://key@o0.ingest.sentry.io/123', tags: { team: 'backend' }, timeout: 10000, }), }) ``` ```typescript [Elysia]src/index.ts import { evlog } from 'evlog/elysia' import { createSentryDrain } from 'evlog/sentry' app.use(evlog({ drain: createSentryDrain({ dsn: 'https://key@o0.ingest.sentry.io/123', tags: { team: 'backend' }, timeout: 10000, }), })) ``` ```typescript [NestJS]src/app.module.ts import { EvlogModule } from 'evlog/nestjs' import { createSentryDrain } from 'evlog/sentry' EvlogModule.forRoot({ drain: createSentryDrain({ dsn: 'https://key@o0.ingest.sentry.io/123', tags: { team: 'backend' }, timeout: 10000, }), }) ``` ```typescript [oRPC]server/orpc.ts import { withEvlog } from 'evlog/orpc' import { createSentryDrain } from 'evlog/sentry' const handler = withEvlog(new RPCHandler(router), { drain: createSentryDrain({ dsn: 'https://key@o0.ingest.sentry.io/123', tags: { team: 'backend' }, timeout: 10000, }), }) ``` ```typescript [Cloudflare Workers]src/worker.ts import { withEvlog } from 'evlog/workers' import { createSentryDrain } from 'evlog/sentry' export default withEvlog(handler, { drain: createSentryDrain({ dsn: 'https://key@o0.ingest.sentry.io/123', tags: { team: 'backend' }, timeout: 10000, }), }) ``` ```typescript [Standalone]src/index.ts import { initLogger } from 'evlog' import { createSentryDrain } from 'evlog/sentry' initLogger({ 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
issues boolean false Also send error and fatal events as Sentry error events
## 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 ## Issues and Alerts Logs alone do not open Sentry issues. `issues: true` also sends every `error` and `fatal` wide event as a Sentry error event, so it groups into an issue and can trigger issue alerts. It is the recommended setup and the one the Quick Start uses: Sentry is built around issues, and without them an evlog error is only a log line nobody gets alerted on. The option defaults to `false` so that upgrading never starts opening issues on its own: ```typescript [server/plugins/evlog-drain.ts] createSentryDrain({ issues: true }) ``` Events with a 4xx status are not sent as issues. A thrown 404, 401 or validation 400 still marks the wide event as `error` and still reaches Sentry as a log, but it is expected traffic, so it opens no issue. The status is read from `error.status`, then `error.statusCode`, then the event's `status`. Browser errors sent through the [client transport](https://www.evlog.dev/use-cases/client-logging) (`source: 'client'`) open issues too, and anyone can post to that endpoint, so keep Sentry's spike protection or a project rate limit on. The error event carries the wide event's trace context: `contexts.trace.trace_id` is the event's `traceId`, the same value as the `trace_id` of its log, so the issue opens on that log. When the `traceId` came from an incoming `traceparent`, the same id links the issue to the other services in the trace. An event with no `traceId` gets one generated inside the Sentry drain, shared by its log and its issue only: other drains in the pipeline do not see it. The exception type and value come from `error.name` and `error.message`, and `error.stack` becomes the issue's stack frames, which Sentry groups on. An error without a stack is grouped by its type and transaction, so a message carrying an id (`User 8231 not found`) stays one issue. The transaction is `METHOD /path` or `action`, `service` plus your `tags` become issue tags, and `userId` or `user.id` sets the issue's user. Other fields stay on the log. The logs are sent first, in envelopes of at most 100, and a failed log envelope fails the batch. Issues follow one request at a time. The first failed issue is reported and stops the rest of that batch, so a batch retry never resends logs that already went through when the batch holds 100 logs or fewer. Above 100 logs, a retry can resend the envelopes that were already accepted. If the app also runs `@sentry/node` (or another Sentry SDK) with error capture on, leave `issues` off: both would report the same failure, and every issue would open twice. ## 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 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 --- - [Sentry Dashboard](https://sentry.io) - [OTLP Adapter](https://www.evlog.dev/integrate/adapters/hybrid/otlp) # ClickHouse Adapter > Insert wide events into ClickHouse over the HTTP interface — typed columns for what you aggregate, full JSON for everything else. The ClickHouse adapter inserts wide events over the [HTTP interface](https://clickhouse.com/docs/en/interfaces/http) 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. 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. [http://localhost:8123](http://localhost:8123)), 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: [https://www.evlog.dev/integrate/adapters/hybrid/clickhouse](https://www.evlog.dev/integrate/adapters/hybrid/clickhouse) Framework setup: [https://www.evlog.dev/integrate/frameworks/overview](https://www.evlog.dev/integrate/frameworks/overview) ## 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; ``` `ORDER BY (service, environment, timestamp)` matches how you'll filter most often. Adjust the `TTL` to your retention policy, and drop the clause entirely to keep events forever. ## Quick Start ```typescript [Nuxt]server/plugins/evlog.ts import { createClickHouseDrain } from 'evlog/clickhouse' export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createClickHouseDrain()) }) ``` ```typescript [Nitro]server/plugins/evlog.ts import { definePlugin } from 'nitro' import { createClickHouseDrain } from 'evlog/clickhouse' export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createClickHouseDrain()) }) ``` ```typescript [Next.js]lib/evlog.ts import { createEvlog } from 'evlog/next' import { createClickHouseDrain } from 'evlog/clickhouse' export const { withEvlog, useLogger, log, createError } = createEvlog({ service: 'my-app', drain: createClickHouseDrain(), }) ``` ```typescript [SvelteKit]src/hooks.server.ts import { createEvlogHooks } from 'evlog/sveltekit' import { createClickHouseDrain } from 'evlog/clickhouse' export const { handle, handleError } = createEvlogHooks({ drain: createClickHouseDrain() }) ``` ```typescript [TanStack Start]server/plugins/evlog.ts import { definePlugin } from 'nitro' import { createClickHouseDrain } from 'evlog/clickhouse' export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createClickHouseDrain()) }) ``` ```typescript [React Router]app/root.tsx import { evlog } from 'evlog/react-router' import { createClickHouseDrain } from 'evlog/clickhouse' export const middleware: Route.MiddlewareFunction[] = [ evlog({ drain: createClickHouseDrain() }), ] ``` ```typescript [Hono]src/index.ts 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]src/index.ts import express from 'express' import { evlog } from 'evlog/express' import { createClickHouseDrain } from 'evlog/clickhouse' const app = express() app.use(evlog({ drain: createClickHouseDrain() })) ``` ```typescript [Fastify]src/index.ts 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]src/index.ts import { Elysia } from 'elysia' import { evlog } from 'evlog/elysia' import { createClickHouseDrain } from 'evlog/clickhouse' const app = new Elysia().use(evlog({ drain: createClickHouseDrain() })) ``` ```typescript [NestJS]src/app.module.ts 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 [oRPC]server/orpc.ts import { withEvlog } from 'evlog/orpc' import { createClickHouseDrain } from 'evlog/clickhouse' const handler = withEvlog(new RPCHandler(router), { drain: createClickHouseDrain() }) ``` ```typescript [Cloudflare Workers]src/worker.ts import { withEvlog } from 'evlog/workers' import { createClickHouseDrain } from 'evlog/clickhouse' export default withEvlog(handler, { drain: createClickHouseDrain() }) ``` ```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 [Nuxt]server/plugins/evlog-drain.ts import { createClickHouseDrain } from 'evlog/clickhouse' export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createClickHouseDrain({ endpoint: 'http://localhost:8123' })) }) ``` ```typescript [Nitro]server/plugins/evlog-drain.ts import { definePlugin } from 'nitro' import { createClickHouseDrain } from 'evlog/clickhouse' export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createClickHouseDrain({ endpoint: 'http://localhost:8123' })) }) ``` ```typescript [Next.js]lib/evlog.ts import { createEvlog } from 'evlog/next' import { createClickHouseDrain } from 'evlog/clickhouse' export const { withEvlog, useLogger, log, createError } = createEvlog({ service: 'my-app', drain: createClickHouseDrain({ endpoint: 'http://localhost:8123' }), }) ``` ```typescript [SvelteKit]src/hooks.server.ts import { createEvlogHooks } from 'evlog/sveltekit' import { createClickHouseDrain } from 'evlog/clickhouse' export const { handle, handleError } = createEvlogHooks({ drain: createClickHouseDrain({ endpoint: 'http://localhost:8123' }) }) ``` ```typescript [TanStack Start]server/plugins/evlog-drain.ts import { definePlugin } from 'nitro' import { createClickHouseDrain } from 'evlog/clickhouse' export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createClickHouseDrain({ endpoint: 'http://localhost:8123' })) }) ``` ```typescript [React Router]app/root.tsx import { evlog } from 'evlog/react-router' import { createClickHouseDrain } from 'evlog/clickhouse' export const middleware: Route.MiddlewareFunction[] = [ evlog({ drain: createClickHouseDrain({ endpoint: 'http://localhost:8123' }) }), ] ``` ```typescript [Hono]src/index.ts import { evlog } from 'evlog/hono' import { createClickHouseDrain } from 'evlog/clickhouse' app.use(evlog({ drain: createClickHouseDrain({ endpoint: 'http://localhost:8123' }) })) ``` ```typescript [Express]src/index.ts import { evlog } from 'evlog/express' import { createClickHouseDrain } from 'evlog/clickhouse' app.use(evlog({ drain: createClickHouseDrain({ endpoint: 'http://localhost:8123' }) })) ``` ```typescript [Fastify]src/index.ts import { evlog } from 'evlog/fastify' import { createClickHouseDrain } from 'evlog/clickhouse' await app.register(evlog, { drain: createClickHouseDrain({ endpoint: 'http://localhost:8123' }) }) ``` ```typescript [Elysia]src/index.ts import { evlog } from 'evlog/elysia' import { createClickHouseDrain } from 'evlog/clickhouse' app.use(evlog({ drain: createClickHouseDrain({ endpoint: 'http://localhost:8123' }) })) ``` ```typescript [NestJS]src/app.module.ts import { EvlogModule } from 'evlog/nestjs' import { createClickHouseDrain } from 'evlog/clickhouse' EvlogModule.forRoot({ drain: createClickHouseDrain({ endpoint: 'http://localhost:8123' }) }) ``` ```typescript [oRPC]server/orpc.ts import { withEvlog } from 'evlog/orpc' import { createClickHouseDrain } from 'evlog/clickhouse' const handler = withEvlog(new RPCHandler(router), { drain: createClickHouseDrain({ endpoint: 'http://localhost:8123' }) }) ``` ```typescript [Cloudflare Workers]src/worker.ts import { withEvlog } from 'evlog/workers' import { createClickHouseDrain } from 'evlog/clickhouse' export default withEvlog(handler, { drain: createClickHouseDrain({ endpoint: 'http://localhost:8123' }) }) ``` ```typescript [Standalone]src/index.ts import { initLogger } from 'evlog' import { createClickHouseDrain } from 'evlog/clickhouse' initLogger({ drain: 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 [Nuxt]server/plugins/evlog-drain.ts import { createClickHouseDrain } from 'evlog/clickhouse' export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createClickHouseDrain({ endpoint: 'https://abc123.eu-west-1.aws.clickhouse.cloud:8443', password: process.env.CLICKHOUSE_PASSWORD, database: 'logs', })) }) ``` ```typescript [Nitro]server/plugins/evlog-drain.ts import { definePlugin } from 'nitro' import { createClickHouseDrain } from 'evlog/clickhouse' export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createClickHouseDrain({ endpoint: 'https://abc123.eu-west-1.aws.clickhouse.cloud:8443', password: process.env.CLICKHOUSE_PASSWORD, database: 'logs', })) }) ``` ```typescript [Next.js]lib/evlog.ts import { createEvlog } from 'evlog/next' import { createClickHouseDrain } from 'evlog/clickhouse' export const { withEvlog, useLogger, log, createError } = createEvlog({ service: 'my-app', drain: createClickHouseDrain({ endpoint: 'https://abc123.eu-west-1.aws.clickhouse.cloud:8443', password: process.env.CLICKHOUSE_PASSWORD, database: 'logs', }), }) ``` ```typescript [SvelteKit]src/hooks.server.ts import { createEvlogHooks } from 'evlog/sveltekit' import { createClickHouseDrain } from 'evlog/clickhouse' export const { handle, handleError } = createEvlogHooks({ drain: createClickHouseDrain({ endpoint: 'https://abc123.eu-west-1.aws.clickhouse.cloud:8443', password: process.env.CLICKHOUSE_PASSWORD, database: 'logs', }), }) ``` ```typescript [TanStack Start]server/plugins/evlog-drain.ts import { definePlugin } from 'nitro' import { createClickHouseDrain } from 'evlog/clickhouse' export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createClickHouseDrain({ endpoint: 'https://abc123.eu-west-1.aws.clickhouse.cloud:8443', password: process.env.CLICKHOUSE_PASSWORD, database: 'logs', })) }) ``` ```typescript [React Router]app/root.tsx import { evlog } from 'evlog/react-router' import { createClickHouseDrain } from 'evlog/clickhouse' export const middleware: Route.MiddlewareFunction[] = [ evlog({ drain: createClickHouseDrain({ endpoint: 'https://abc123.eu-west-1.aws.clickhouse.cloud:8443', password: process.env.CLICKHOUSE_PASSWORD, database: 'logs', }), }), ] ``` ```typescript [Hono]src/index.ts import { evlog } from 'evlog/hono' import { createClickHouseDrain } from 'evlog/clickhouse' app.use(evlog({ drain: createClickHouseDrain({ endpoint: 'https://abc123.eu-west-1.aws.clickhouse.cloud:8443', password: process.env.CLICKHOUSE_PASSWORD, database: 'logs', }), })) ``` ```typescript [Express]src/index.ts import { evlog } from 'evlog/express' import { createClickHouseDrain } from 'evlog/clickhouse' app.use(evlog({ drain: createClickHouseDrain({ endpoint: 'https://abc123.eu-west-1.aws.clickhouse.cloud:8443', password: process.env.CLICKHOUSE_PASSWORD, database: 'logs', }), })) ``` ```typescript [Fastify]src/index.ts import { evlog } from 'evlog/fastify' import { createClickHouseDrain } from 'evlog/clickhouse' await app.register(evlog, { drain: createClickHouseDrain({ endpoint: 'https://abc123.eu-west-1.aws.clickhouse.cloud:8443', password: process.env.CLICKHOUSE_PASSWORD, database: 'logs', }), }) ``` ```typescript [Elysia]src/index.ts import { evlog } from 'evlog/elysia' import { createClickHouseDrain } from 'evlog/clickhouse' app.use(evlog({ drain: createClickHouseDrain({ endpoint: 'https://abc123.eu-west-1.aws.clickhouse.cloud:8443', password: process.env.CLICKHOUSE_PASSWORD, database: 'logs', }), })) ``` ```typescript [NestJS]src/app.module.ts import { EvlogModule } from 'evlog/nestjs' import { createClickHouseDrain } from 'evlog/clickhouse' EvlogModule.forRoot({ drain: createClickHouseDrain({ endpoint: 'https://abc123.eu-west-1.aws.clickhouse.cloud:8443', password: process.env.CLICKHOUSE_PASSWORD, database: 'logs', }), }) ``` ```typescript [oRPC]server/orpc.ts import { withEvlog } from 'evlog/orpc' import { createClickHouseDrain } from 'evlog/clickhouse' const handler = withEvlog(new RPCHandler(router), { drain: createClickHouseDrain({ endpoint: 'https://abc123.eu-west-1.aws.clickhouse.cloud:8443', password: process.env.CLICKHOUSE_PASSWORD, database: 'logs', }), }) ``` ```typescript [Cloudflare Workers]src/worker.ts import { withEvlog } from 'evlog/workers' import { createClickHouseDrain } from 'evlog/clickhouse' export default withEvlog(handler, { drain: createClickHouseDrain({ endpoint: 'https://abc123.eu-west-1.aws.clickhouse.cloud:8443', password: process.env.CLICKHOUSE_PASSWORD, database: 'logs', }), }) ``` ```typescript [Standalone]src/index.ts import { initLogger } from 'evlog' import { createClickHouseDrain } from 'evlog/clickhouse' initLogger({ drain: 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 ``` 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. 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, and 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) 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 [Nuxt]server/plugins/evlog-drain.ts import { createClickHouseDrain } from 'evlog/clickhouse' export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createClickHouseDrain({ table: 'app_logs', transform: event => ({ ts: event.timestamp, lvl: event.level, svc: event.service, payload: JSON.stringify(event), }), })) }) ``` ```typescript [Nitro]server/plugins/evlog-drain.ts import { definePlugin } from 'nitro' import { createClickHouseDrain } from 'evlog/clickhouse' export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createClickHouseDrain({ table: 'app_logs', transform: event => ({ ts: event.timestamp, lvl: event.level, svc: event.service, payload: JSON.stringify(event), }), })) }) ``` ```typescript [Next.js]lib/evlog.ts import { createEvlog } from 'evlog/next' import { createClickHouseDrain } from 'evlog/clickhouse' export const { withEvlog, useLogger, log, createError } = createEvlog({ service: 'my-app', drain: createClickHouseDrain({ table: 'app_logs', transform: event => ({ ts: event.timestamp, lvl: event.level, svc: event.service, payload: JSON.stringify(event), }), }), }) ``` ```typescript [SvelteKit]src/hooks.server.ts import { createEvlogHooks } from 'evlog/sveltekit' import { createClickHouseDrain } from 'evlog/clickhouse' export const { handle, handleError } = createEvlogHooks({ drain: createClickHouseDrain({ table: 'app_logs', transform: event => ({ ts: event.timestamp, lvl: event.level, svc: event.service, payload: JSON.stringify(event), }), }), }) ``` ```typescript [TanStack Start]server/plugins/evlog-drain.ts import { definePlugin } from 'nitro' import { createClickHouseDrain } from 'evlog/clickhouse' export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createClickHouseDrain({ table: 'app_logs', transform: event => ({ ts: event.timestamp, lvl: event.level, svc: event.service, payload: JSON.stringify(event), }), })) }) ``` ```typescript [React Router]app/root.tsx import { evlog } from 'evlog/react-router' import { createClickHouseDrain } from 'evlog/clickhouse' export const middleware: Route.MiddlewareFunction[] = [ evlog({ drain: createClickHouseDrain({ table: 'app_logs', transform: event => ({ ts: event.timestamp, lvl: event.level, svc: event.service, payload: JSON.stringify(event), }), }), }), ] ``` ```typescript [Hono]src/index.ts import { evlog } from 'evlog/hono' import { createClickHouseDrain } from 'evlog/clickhouse' app.use(evlog({ drain: createClickHouseDrain({ table: 'app_logs', transform: event => ({ ts: event.timestamp, lvl: event.level, svc: event.service, payload: JSON.stringify(event), }), }), })) ``` ```typescript [Express]src/index.ts import { evlog } from 'evlog/express' import { createClickHouseDrain } from 'evlog/clickhouse' app.use(evlog({ drain: createClickHouseDrain({ table: 'app_logs', transform: event => ({ ts: event.timestamp, lvl: event.level, svc: event.service, payload: JSON.stringify(event), }), }), })) ``` ```typescript [Fastify]src/index.ts import { evlog } from 'evlog/fastify' import { createClickHouseDrain } from 'evlog/clickhouse' await app.register(evlog, { drain: createClickHouseDrain({ table: 'app_logs', transform: event => ({ ts: event.timestamp, lvl: event.level, svc: event.service, payload: JSON.stringify(event), }), }), }) ``` ```typescript [Elysia]src/index.ts import { evlog } from 'evlog/elysia' import { createClickHouseDrain } from 'evlog/clickhouse' app.use(evlog({ drain: createClickHouseDrain({ table: 'app_logs', transform: event => ({ ts: event.timestamp, lvl: event.level, svc: event.service, payload: JSON.stringify(event), }), }), })) ``` ```typescript [NestJS]src/app.module.ts import { EvlogModule } from 'evlog/nestjs' import { createClickHouseDrain } from 'evlog/clickhouse' EvlogModule.forRoot({ drain: createClickHouseDrain({ table: 'app_logs', transform: event => ({ ts: event.timestamp, lvl: event.level, svc: event.service, payload: JSON.stringify(event), }), }), }) ``` ```typescript [oRPC]server/orpc.ts import { withEvlog } from 'evlog/orpc' import { createClickHouseDrain } from 'evlog/clickhouse' const handler = withEvlog(new RPCHandler(router), { drain: createClickHouseDrain({ table: 'app_logs', transform: event => ({ ts: event.timestamp, lvl: event.level, svc: event.service, payload: JSON.stringify(event), }), }), }) ``` ```typescript [Cloudflare Workers]src/worker.ts import { withEvlog } from 'evlog/workers' import { createClickHouseDrain } from 'evlog/clickhouse' export default withEvlog(handler, { drain: createClickHouseDrain({ table: 'app_logs', transform: event => ({ ts: event.timestamp, lvl: event.level, svc: event.service, payload: JSON.stringify(event), }), }), }) ``` ```typescript [Standalone]src/index.ts import { initLogger } from 'evlog' import { createClickHouseDrain } from 'evlog/clickhouse' initLogger({ drain: 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, because ClickHouse rejects a `JSONEachRow` insert containing unknown columns. ## Batching Pair with a pipeline to insert in larger batches: ```typescript [Nuxt]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())) }) ``` ```typescript [Nitro]server/plugins/evlog.ts import { definePlugin } from 'nitro' 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 definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', pipeline(createClickHouseDrain())) }) ``` ```typescript [Next.js]lib/evlog.ts import { createEvlog } from 'evlog/next' import { createDrainPipeline } from 'evlog/pipeline' import { createClickHouseDrain } from 'evlog/clickhouse' import type { DrainContext } from 'evlog' const pipeline = createDrainPipeline({ batch: { size: 500, intervalMs: 5000 }, }) export const { withEvlog, useLogger, log, createError } = createEvlog({ service: 'my-app', drain: pipeline(createClickHouseDrain()), }) ``` ```typescript [SvelteKit]src/hooks.server.ts import { createEvlogHooks } from 'evlog/sveltekit' import { createDrainPipeline } from 'evlog/pipeline' import { createClickHouseDrain } from 'evlog/clickhouse' import type { DrainContext } from 'evlog' const pipeline = createDrainPipeline({ batch: { size: 500, intervalMs: 5000 }, }) export const { handle, handleError } = createEvlogHooks({ drain: pipeline(createClickHouseDrain()) }) ``` ```typescript [TanStack Start]server/plugins/evlog.ts import { definePlugin } from 'nitro' 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 definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', pipeline(createClickHouseDrain())) }) ``` ```typescript [React Router]app/root.tsx import { evlog } from 'evlog/react-router' import { createDrainPipeline } from 'evlog/pipeline' import { createClickHouseDrain } from 'evlog/clickhouse' import type { DrainContext } from 'evlog' const pipeline = createDrainPipeline({ batch: { size: 500, intervalMs: 5000 }, }) export const middleware: Route.MiddlewareFunction[] = [ evlog({ drain: pipeline(createClickHouseDrain()) }), ] ``` ```typescript [Hono]src/index.ts import { evlog } from 'evlog/hono' import { createDrainPipeline } from 'evlog/pipeline' import { createClickHouseDrain } from 'evlog/clickhouse' import type { DrainContext } from 'evlog' const pipeline = createDrainPipeline({ batch: { size: 500, intervalMs: 5000 }, }) app.use(evlog({ drain: pipeline(createClickHouseDrain()) })) ``` ```typescript [Express]src/index.ts import { evlog } from 'evlog/express' import { createDrainPipeline } from 'evlog/pipeline' import { createClickHouseDrain } from 'evlog/clickhouse' import type { DrainContext } from 'evlog' const pipeline = createDrainPipeline({ batch: { size: 500, intervalMs: 5000 }, }) app.use(evlog({ drain: pipeline(createClickHouseDrain()) })) ``` ```typescript [Fastify]src/index.ts import { evlog } from 'evlog/fastify' import { createDrainPipeline } from 'evlog/pipeline' import { createClickHouseDrain } from 'evlog/clickhouse' import type { DrainContext } from 'evlog' const pipeline = createDrainPipeline({ batch: { size: 500, intervalMs: 5000 }, }) await app.register(evlog, { drain: pipeline(createClickHouseDrain()) }) ``` ```typescript [Elysia]src/index.ts import { evlog } from 'evlog/elysia' import { createDrainPipeline } from 'evlog/pipeline' import { createClickHouseDrain } from 'evlog/clickhouse' import type { DrainContext } from 'evlog' const pipeline = createDrainPipeline({ batch: { size: 500, intervalMs: 5000 }, }) app.use(evlog({ drain: pipeline(createClickHouseDrain()) })) ``` ```typescript [NestJS]src/app.module.ts import { EvlogModule } from 'evlog/nestjs' import { createDrainPipeline } from 'evlog/pipeline' import { createClickHouseDrain } from 'evlog/clickhouse' import type { DrainContext } from 'evlog' const pipeline = createDrainPipeline({ batch: { size: 500, intervalMs: 5000 }, }) EvlogModule.forRoot({ drain: pipeline(createClickHouseDrain()) }) ``` ```typescript [oRPC]server/orpc.ts import { withEvlog } from 'evlog/orpc' import { createDrainPipeline } from 'evlog/pipeline' import { createClickHouseDrain } from 'evlog/clickhouse' import type { DrainContext } from 'evlog' const pipeline = createDrainPipeline({ batch: { size: 500, intervalMs: 5000 }, }) const handler = withEvlog(new RPCHandler(router), { drain: pipeline(createClickHouseDrain()) }) ``` ```typescript [Cloudflare Workers]src/worker.ts import { withEvlog } from 'evlog/workers' 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 withEvlog(handler, { drain: pipeline(createClickHouseDrain()) }) ``` ```typescript [Standalone]src/index.ts import { initLogger } from 'evlog' import { createDrainPipeline } from 'evlog/pipeline' import { createClickHouseDrain } from 'evlog/clickhouse' import type { DrainContext } from 'evlog' const pipeline = createDrainPipeline({ batch: { size: 500, intervalMs: 5000 }, }) initLogger({ 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/extend/drain-pipeline): batching, retries, and fan-out - [Adapters Overview](https://www.evlog.dev/integrate/adapters/overview): all available destinations --- - [HTTP Interface](https://clickhouse.com/docs/en/interfaces/http) - [Async Inserts](https://clickhouse.com/docs/en/optimize/asynchronous-inserts) # HyperDX Adapter > Send wide events to HyperDX over OTLP/HTTP, using its documented endpoint and authorization header. Configured from environment variables. [HyperDX](https://hyperdx.io) 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). 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: [https://www.evlog.dev/integrate/adapters/hybrid/hyperdx](https://www.evlog.dev/integrate/adapters/hybrid/hyperdx) Framework setup: [https://www.evlog.dev/integrate/frameworks/overview](https://www.evlog.dev/integrate/frameworks/overview) ## 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) 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 ```typescript [Nuxt]server/plugins/evlog-drain.ts import { createHyperDXDrain } from 'evlog/hyperdx' export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createHyperDXDrain()) }) ``` ```typescript [Nitro]server/plugins/evlog-drain.ts import { definePlugin } from 'nitro' import { createHyperDXDrain } from 'evlog/hyperdx' export default definePlugin((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 [SvelteKit]src/hooks.server.ts import { createEvlogHooks } from 'evlog/sveltekit' import { createHyperDXDrain } from 'evlog/hyperdx' export const { handle, handleError } = createEvlogHooks({ drain: createHyperDXDrain() }) ``` ```typescript [TanStack Start]server/plugins/evlog-drain.ts import { definePlugin } from 'nitro' import { createHyperDXDrain } from 'evlog/hyperdx' export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createHyperDXDrain()) }) ``` ```typescript [React Router]app/root.tsx import { evlog } from 'evlog/react-router' import { createHyperDXDrain } from 'evlog/hyperdx' export const middleware: Route.MiddlewareFunction[] = [ evlog({ drain: createHyperDXDrain() }), ] ``` ```typescript [Hono]src/index.ts import { createHyperDXDrain } from 'evlog/hyperdx' app.use(evlog({ drain: createHyperDXDrain() })) ``` ```typescript [Express]src/index.ts import { createHyperDXDrain } from 'evlog/hyperdx' app.use(evlog({ drain: createHyperDXDrain() })) ``` ```typescript [Fastify]src/index.ts import { createHyperDXDrain } from 'evlog/hyperdx' await app.register(evlog, { drain: createHyperDXDrain() }) ``` ```typescript [Elysia]src/index.ts import { createHyperDXDrain } from 'evlog/hyperdx' app.use(evlog({ drain: createHyperDXDrain() })) ``` ```typescript [NestJS]src/app.module.ts import { createHyperDXDrain } from 'evlog/hyperdx' EvlogModule.forRoot({ drain: createHyperDXDrain() }) ``` ```typescript [oRPC]server/orpc.ts import { withEvlog } from 'evlog/orpc' import { createHyperDXDrain } from 'evlog/hyperdx' const handler = withEvlog(new RPCHandler(router), { drain: createHyperDXDrain() }) ``` ```typescript [Cloudflare Workers]src/worker.ts import { withEvlog } from 'evlog/workers' import { createHyperDXDrain } from 'evlog/hyperdx' export default withEvlog(handler, { 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 [Nuxt]server/plugins/evlog-drain.ts import { createHyperDXDrain } from 'evlog/hyperdx' export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createHyperDXDrain({ apiKey: process.env.HYPERDX_API_KEY!, endpoint: 'https://in-otel.hyperdx.io', timeout: 10000, })) }) ``` ```typescript [Nitro]server/plugins/evlog-drain.ts import { definePlugin } from 'nitro' import { createHyperDXDrain } from 'evlog/hyperdx' export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createHyperDXDrain({ apiKey: process.env.HYPERDX_API_KEY!, endpoint: 'https://in-otel.hyperdx.io', timeout: 10000, })) }) ``` ```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({ apiKey: process.env.HYPERDX_API_KEY!, endpoint: 'https://in-otel.hyperdx.io', timeout: 10000, }), }) ``` ```typescript [SvelteKit]src/hooks.server.ts import { createEvlogHooks } from 'evlog/sveltekit' import { createHyperDXDrain } from 'evlog/hyperdx' export const { handle, handleError } = createEvlogHooks({ drain: createHyperDXDrain({ apiKey: process.env.HYPERDX_API_KEY!, endpoint: 'https://in-otel.hyperdx.io', timeout: 10000, }), }) ``` ```typescript [TanStack Start]server/plugins/evlog-drain.ts import { definePlugin } from 'nitro' import { createHyperDXDrain } from 'evlog/hyperdx' export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createHyperDXDrain({ apiKey: process.env.HYPERDX_API_KEY!, endpoint: 'https://in-otel.hyperdx.io', timeout: 10000, })) }) ``` ```typescript [React Router]app/root.tsx import { evlog } from 'evlog/react-router' import { createHyperDXDrain } from 'evlog/hyperdx' export const middleware: Route.MiddlewareFunction[] = [ evlog({ drain: createHyperDXDrain({ apiKey: process.env.HYPERDX_API_KEY!, endpoint: 'https://in-otel.hyperdx.io', timeout: 10000, }), }), ] ``` ```typescript [Hono]src/index.ts import { evlog } from 'evlog/hono' import { createHyperDXDrain } from 'evlog/hyperdx' app.use(evlog({ drain: createHyperDXDrain({ apiKey: process.env.HYPERDX_API_KEY!, endpoint: 'https://in-otel.hyperdx.io', timeout: 10000, }), })) ``` ```typescript [Express]src/index.ts import { evlog } from 'evlog/express' import { createHyperDXDrain } from 'evlog/hyperdx' app.use(evlog({ drain: createHyperDXDrain({ apiKey: process.env.HYPERDX_API_KEY!, endpoint: 'https://in-otel.hyperdx.io', timeout: 10000, }), })) ``` ```typescript [Fastify]src/index.ts import { evlog } from 'evlog/fastify' import { createHyperDXDrain } from 'evlog/hyperdx' await app.register(evlog, { drain: createHyperDXDrain({ apiKey: process.env.HYPERDX_API_KEY!, endpoint: 'https://in-otel.hyperdx.io', timeout: 10000, }), }) ``` ```typescript [Elysia]src/index.ts import { evlog } from 'evlog/elysia' import { createHyperDXDrain } from 'evlog/hyperdx' app.use(evlog({ drain: createHyperDXDrain({ apiKey: process.env.HYPERDX_API_KEY!, endpoint: 'https://in-otel.hyperdx.io', timeout: 10000, }), })) ``` ```typescript [NestJS]src/app.module.ts import { EvlogModule } from 'evlog/nestjs' import { createHyperDXDrain } from 'evlog/hyperdx' EvlogModule.forRoot({ drain: createHyperDXDrain({ apiKey: process.env.HYPERDX_API_KEY!, endpoint: 'https://in-otel.hyperdx.io', timeout: 10000, }), }) ``` ```typescript [oRPC]server/orpc.ts import { withEvlog } from 'evlog/orpc' import { createHyperDXDrain } from 'evlog/hyperdx' const handler = withEvlog(new RPCHandler(router), { drain: createHyperDXDrain({ apiKey: process.env.HYPERDX_API_KEY!, endpoint: 'https://in-otel.hyperdx.io', timeout: 10000, }), }) ``` ```typescript [Cloudflare Workers]src/worker.ts import { withEvlog } from 'evlog/workers' import { createHyperDXDrain } from 'evlog/hyperdx' export default withEvlog(handler, { drain: createHyperDXDrain({ apiKey: process.env.HYPERDX_API_KEY!, endpoint: 'https://in-otel.hyperdx.io', timeout: 10000, }), }) ``` ```typescript [Standalone]src/index.ts import { initLogger } from 'evlog' import { createHyperDXDrain } from 'evlog/hyperdx' initLogger({ 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 [Nuxt]server/plugins/evlog-drain.ts import { createHyperDXDrain } from 'evlog/hyperdx' export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createHyperDXDrain()) }) ``` ```typescript [Nitro]server/plugins/evlog-drain.ts import { definePlugin } from 'nitro' import { createHyperDXDrain } from 'evlog/hyperdx' export default definePlugin((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 [SvelteKit]src/hooks.server.ts import { createEvlogHooks } from 'evlog/sveltekit' import { createHyperDXDrain } from 'evlog/hyperdx' export const { handle, handleError } = createEvlogHooks({ drain: createHyperDXDrain() }) ``` ```typescript [TanStack Start]server/plugins/evlog-drain.ts import { definePlugin } from 'nitro' import { createHyperDXDrain } from 'evlog/hyperdx' export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createHyperDXDrain()) }) ``` ```typescript [React Router]app/root.tsx import { evlog } from 'evlog/react-router' import { createHyperDXDrain } from 'evlog/hyperdx' export const middleware: Route.MiddlewareFunction[] = [ evlog({ drain: createHyperDXDrain() }), ] ``` ```typescript [Hono]src/index.ts import { evlog } from 'evlog/hono' import { createHyperDXDrain } from 'evlog/hyperdx' app.use(evlog({ drain: createHyperDXDrain() })) ``` ```typescript [Express]src/index.ts import { evlog } from 'evlog/express' import { createHyperDXDrain } from 'evlog/hyperdx' app.use(evlog({ drain: createHyperDXDrain() })) ``` ```typescript [Fastify]src/index.ts import { evlog } from 'evlog/fastify' import { createHyperDXDrain } from 'evlog/hyperdx' await app.register(evlog, { drain: createHyperDXDrain() }) ``` ```typescript [Elysia]src/index.ts import { evlog } from 'evlog/elysia' import { createHyperDXDrain } from 'evlog/hyperdx' app.use(evlog({ drain: createHyperDXDrain() })) ``` ```typescript [NestJS]src/app.module.ts import { EvlogModule } from 'evlog/nestjs' import { createHyperDXDrain } from 'evlog/hyperdx' EvlogModule.forRoot({ drain: createHyperDXDrain() }) ``` ```typescript [oRPC]server/orpc.ts import { withEvlog } from 'evlog/orpc' import { createHyperDXDrain } from 'evlog/hyperdx' const handler = withEvlog(new RPCHandler(router), { drain: createHyperDXDrain() }) ``` ```typescript [Cloudflare Workers]src/worker.ts import { withEvlog } from 'evlog/workers' import { createHyperDXDrain } from 'evlog/hyperdx' export default withEvlog(handler, { drain: createHyperDXDrain() }) ``` ```typescript [Standalone]src/index.ts import { initLogger } from 'evlog' import { createHyperDXDrain } from 'evlog/hyperdx' initLogger({ drain: 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 [Nuxt]server/plugins/evlog-drain.ts import { createHyperDXDrain } from 'evlog/hyperdx' export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createHyperDXDrain({ endpoint: 'http://hyperdx.internal:4318' })) }) ``` ```typescript [Nitro]server/plugins/evlog-drain.ts import { definePlugin } from 'nitro' import { createHyperDXDrain } from 'evlog/hyperdx' export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createHyperDXDrain({ endpoint: 'http://hyperdx.internal:4318' })) }) ``` ```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({ endpoint: 'http://hyperdx.internal:4318' }), }) ``` ```typescript [SvelteKit]src/hooks.server.ts import { createEvlogHooks } from 'evlog/sveltekit' import { createHyperDXDrain } from 'evlog/hyperdx' export const { handle, handleError } = createEvlogHooks({ drain: createHyperDXDrain({ endpoint: 'http://hyperdx.internal:4318' }) }) ``` ```typescript [TanStack Start]server/plugins/evlog-drain.ts import { definePlugin } from 'nitro' import { createHyperDXDrain } from 'evlog/hyperdx' export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createHyperDXDrain({ endpoint: 'http://hyperdx.internal:4318' })) }) ``` ```typescript [React Router]app/root.tsx import { evlog } from 'evlog/react-router' import { createHyperDXDrain } from 'evlog/hyperdx' export const middleware: Route.MiddlewareFunction[] = [ evlog({ drain: createHyperDXDrain({ endpoint: 'http://hyperdx.internal:4318' }) }), ] ``` ```typescript [Hono]src/index.ts import { evlog } from 'evlog/hono' import { createHyperDXDrain } from 'evlog/hyperdx' app.use(evlog({ drain: createHyperDXDrain({ endpoint: 'http://hyperdx.internal:4318' }) })) ``` ```typescript [Express]src/index.ts import { evlog } from 'evlog/express' import { createHyperDXDrain } from 'evlog/hyperdx' app.use(evlog({ drain: createHyperDXDrain({ endpoint: 'http://hyperdx.internal:4318' }) })) ``` ```typescript [Fastify]src/index.ts import { evlog } from 'evlog/fastify' import { createHyperDXDrain } from 'evlog/hyperdx' await app.register(evlog, { drain: createHyperDXDrain({ endpoint: 'http://hyperdx.internal:4318' }) }) ``` ```typescript [Elysia]src/index.ts import { evlog } from 'evlog/elysia' import { createHyperDXDrain } from 'evlog/hyperdx' app.use(evlog({ drain: createHyperDXDrain({ endpoint: 'http://hyperdx.internal:4318' }) })) ``` ```typescript [NestJS]src/app.module.ts import { EvlogModule } from 'evlog/nestjs' import { createHyperDXDrain } from 'evlog/hyperdx' EvlogModule.forRoot({ drain: createHyperDXDrain({ endpoint: 'http://hyperdx.internal:4318' }) }) ``` ```typescript [oRPC]server/orpc.ts import { withEvlog } from 'evlog/orpc' import { createHyperDXDrain } from 'evlog/hyperdx' const handler = withEvlog(new RPCHandler(router), { drain: createHyperDXDrain({ endpoint: 'http://hyperdx.internal:4318' }) }) ``` ```typescript [Cloudflare Workers]src/worker.ts import { withEvlog } from 'evlog/workers' import { createHyperDXDrain } from 'evlog/hyperdx' export default withEvlog(handler, { drain: createHyperDXDrain({ endpoint: 'http://hyperdx.internal:4318' }) }) ``` ```typescript [Standalone]src/index.ts import { initLogger } from 'evlog' import { createHyperDXDrain } from 'evlog/hyperdx' initLogger({ drain: 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 ``` 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 setup](https://hyperdx.io/docs/install/opentelemetry): > 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) 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 --- - [HyperDX](https://hyperdx.io) - [OTLP Adapter](https://www.evlog.dev/integrate/adapters/hybrid/otlp) # Grafana Loki Adapter > Push wide events to Grafana Loki — self-hosted, multi-tenant, or Grafana Cloud — with low-cardinality labels and full JSON querying. The Loki adapter pushes wide events to [Grafana Loki](https://grafana.com/docs/loki/latest/) 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`. 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. [http://localhost:3100](http://localhost:3100)). 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: [https://www.evlog.dev/integrate/adapters/hybrid/loki](https://www.evlog.dev/integrate/adapters/hybrid/loki) Framework setup: [https://www.evlog.dev/integrate/frameworks/overview](https://www.evlog.dev/integrate/frameworks/overview) ## 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. ```typescript [Nuxt]server/plugins/evlog.ts import { createLokiDrain } from 'evlog/loki' export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createLokiDrain()) }) ``` ```typescript [Nitro]server/plugins/evlog.ts import { definePlugin } from 'nitro' import { createLokiDrain } from 'evlog/loki' export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createLokiDrain()) }) ``` ```typescript [Next.js]lib/evlog.ts import { createEvlog } from 'evlog/next' import { createLokiDrain } from 'evlog/loki' export const { withEvlog, useLogger, log, createError } = createEvlog({ service: 'my-app', drain: createLokiDrain(), }) ``` ```typescript [SvelteKit]src/hooks.server.ts import { createEvlogHooks } from 'evlog/sveltekit' import { createLokiDrain } from 'evlog/loki' export const { handle, handleError } = createEvlogHooks({ drain: createLokiDrain() }) ``` ```typescript [TanStack Start]server/plugins/evlog.ts import { definePlugin } from 'nitro' import { createLokiDrain } from 'evlog/loki' export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createLokiDrain()) }) ``` ```typescript [React Router]app/root.tsx import { evlog } from 'evlog/react-router' import { createLokiDrain } from 'evlog/loki' export const middleware: Route.MiddlewareFunction[] = [ evlog({ drain: createLokiDrain() }), ] ``` ```typescript [Hono]src/index.ts 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]src/index.ts import express from 'express' import { evlog } from 'evlog/express' import { createLokiDrain } from 'evlog/loki' const app = express() app.use(evlog({ drain: createLokiDrain() })) ``` ```typescript [Fastify]src/index.ts 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]src/index.ts import { Elysia } from 'elysia' import { evlog } from 'evlog/elysia' import { createLokiDrain } from 'evlog/loki' const app = new Elysia().use(evlog({ drain: createLokiDrain() })) ``` ```typescript [NestJS]src/app.module.ts 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 [oRPC]server/orpc.ts import { withEvlog } from 'evlog/orpc' import { createLokiDrain } from 'evlog/loki' const handler = withEvlog(new RPCHandler(router), { drain: createLokiDrain() }) ``` ```typescript [Cloudflare Workers]src/worker.ts import { withEvlog } from 'evlog/workers' import { createLokiDrain } from 'evlog/loki' export default withEvlog(handler, { drain: createLokiDrain() }) ``` ```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 [Nuxt]server/plugins/evlog-drain.ts import { createLokiDrain } from 'evlog/loki' export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createLokiDrain({ endpoint: 'http://localhost:3100' })) }) ``` ```typescript [Nitro]server/plugins/evlog-drain.ts import { definePlugin } from 'nitro' import { createLokiDrain } from 'evlog/loki' export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createLokiDrain({ endpoint: 'http://localhost:3100' })) }) ``` ```typescript [Next.js]lib/evlog.ts import { createEvlog } from 'evlog/next' import { createLokiDrain } from 'evlog/loki' export const { withEvlog, useLogger, log, createError } = createEvlog({ service: 'my-app', drain: createLokiDrain({ endpoint: 'http://localhost:3100' }), }) ``` ```typescript [SvelteKit]src/hooks.server.ts import { createEvlogHooks } from 'evlog/sveltekit' import { createLokiDrain } from 'evlog/loki' export const { handle, handleError } = createEvlogHooks({ drain: createLokiDrain({ endpoint: 'http://localhost:3100' }) }) ``` ```typescript [TanStack Start]server/plugins/evlog-drain.ts import { definePlugin } from 'nitro' import { createLokiDrain } from 'evlog/loki' export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createLokiDrain({ endpoint: 'http://localhost:3100' })) }) ``` ```typescript [React Router]app/root.tsx import { evlog } from 'evlog/react-router' import { createLokiDrain } from 'evlog/loki' export const middleware: Route.MiddlewareFunction[] = [ evlog({ drain: createLokiDrain({ endpoint: 'http://localhost:3100' }) }), ] ``` ```typescript [Hono]src/index.ts import { evlog } from 'evlog/hono' import { createLokiDrain } from 'evlog/loki' app.use(evlog({ drain: createLokiDrain({ endpoint: 'http://localhost:3100' }) })) ``` ```typescript [Express]src/index.ts import { evlog } from 'evlog/express' import { createLokiDrain } from 'evlog/loki' app.use(evlog({ drain: createLokiDrain({ endpoint: 'http://localhost:3100' }) })) ``` ```typescript [Fastify]src/index.ts import { evlog } from 'evlog/fastify' import { createLokiDrain } from 'evlog/loki' await app.register(evlog, { drain: createLokiDrain({ endpoint: 'http://localhost:3100' }) }) ``` ```typescript [Elysia]src/index.ts import { evlog } from 'evlog/elysia' import { createLokiDrain } from 'evlog/loki' app.use(evlog({ drain: createLokiDrain({ endpoint: 'http://localhost:3100' }) })) ``` ```typescript [NestJS]src/app.module.ts import { EvlogModule } from 'evlog/nestjs' import { createLokiDrain } from 'evlog/loki' EvlogModule.forRoot({ drain: createLokiDrain({ endpoint: 'http://localhost:3100' }) }) ``` ```typescript [oRPC]server/orpc.ts import { withEvlog } from 'evlog/orpc' import { createLokiDrain } from 'evlog/loki' const handler = withEvlog(new RPCHandler(router), { drain: createLokiDrain({ endpoint: 'http://localhost:3100' }) }) ``` ```typescript [Cloudflare Workers]src/worker.ts import { withEvlog } from 'evlog/workers' import { createLokiDrain } from 'evlog/loki' export default withEvlog(handler, { drain: createLokiDrain({ endpoint: 'http://localhost:3100' }) }) ``` ```typescript [Standalone]src/index.ts import { initLogger } from 'evlog' import { createLokiDrain } from 'evlog/loki' initLogger({ drain: createLokiDrain({ endpoint: 'http://localhost:3100' }) }) ``` ```bash [.env] LOKI_ENDPOINT=http://localhost:3100 ``` For a **multi-tenant** deployment, name the tenant, and it is sent as `X-Scope-OrgID`: ```typescript [Nuxt]server/plugins/evlog-drain.ts import { createLokiDrain } from 'evlog/loki' export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createLokiDrain({ endpoint: 'http://loki.internal:3100', tenantId: 'team-checkout', })) }) ``` ```typescript [Nitro]server/plugins/evlog-drain.ts import { definePlugin } from 'nitro' import { createLokiDrain } from 'evlog/loki' export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createLokiDrain({ endpoint: 'http://loki.internal:3100', tenantId: 'team-checkout', })) }) ``` ```typescript [Next.js]lib/evlog.ts import { createEvlog } from 'evlog/next' import { createLokiDrain } from 'evlog/loki' export const { withEvlog, useLogger, log, createError } = createEvlog({ service: 'my-app', drain: createLokiDrain({ endpoint: 'http://loki.internal:3100', tenantId: 'team-checkout', }), }) ``` ```typescript [SvelteKit]src/hooks.server.ts import { createEvlogHooks } from 'evlog/sveltekit' import { createLokiDrain } from 'evlog/loki' export const { handle, handleError } = createEvlogHooks({ drain: createLokiDrain({ endpoint: 'http://loki.internal:3100', tenantId: 'team-checkout', }), }) ``` ```typescript [TanStack Start]server/plugins/evlog-drain.ts import { definePlugin } from 'nitro' import { createLokiDrain } from 'evlog/loki' export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createLokiDrain({ endpoint: 'http://loki.internal:3100', tenantId: 'team-checkout', })) }) ``` ```typescript [React Router]app/root.tsx import { evlog } from 'evlog/react-router' import { createLokiDrain } from 'evlog/loki' export const middleware: Route.MiddlewareFunction[] = [ evlog({ drain: createLokiDrain({ endpoint: 'http://loki.internal:3100', tenantId: 'team-checkout', }), }), ] ``` ```typescript [Hono]src/index.ts import { evlog } from 'evlog/hono' import { createLokiDrain } from 'evlog/loki' app.use(evlog({ drain: createLokiDrain({ endpoint: 'http://loki.internal:3100', tenantId: 'team-checkout', }), })) ``` ```typescript [Express]src/index.ts import { evlog } from 'evlog/express' import { createLokiDrain } from 'evlog/loki' app.use(evlog({ drain: createLokiDrain({ endpoint: 'http://loki.internal:3100', tenantId: 'team-checkout', }), })) ``` ```typescript [Fastify]src/index.ts import { evlog } from 'evlog/fastify' import { createLokiDrain } from 'evlog/loki' await app.register(evlog, { drain: createLokiDrain({ endpoint: 'http://loki.internal:3100', tenantId: 'team-checkout', }), }) ``` ```typescript [Elysia]src/index.ts import { evlog } from 'evlog/elysia' import { createLokiDrain } from 'evlog/loki' app.use(evlog({ drain: createLokiDrain({ endpoint: 'http://loki.internal:3100', tenantId: 'team-checkout', }), })) ``` ```typescript [NestJS]src/app.module.ts import { EvlogModule } from 'evlog/nestjs' import { createLokiDrain } from 'evlog/loki' EvlogModule.forRoot({ drain: createLokiDrain({ endpoint: 'http://loki.internal:3100', tenantId: 'team-checkout', }), }) ``` ```typescript [oRPC]server/orpc.ts import { withEvlog } from 'evlog/orpc' import { createLokiDrain } from 'evlog/loki' const handler = withEvlog(new RPCHandler(router), { drain: createLokiDrain({ endpoint: 'http://loki.internal:3100', tenantId: 'team-checkout', }), }) ``` ```typescript [Cloudflare Workers]src/worker.ts import { withEvlog } from 'evlog/workers' import { createLokiDrain } from 'evlog/loki' export default withEvlog(handler, { drain: createLokiDrain({ endpoint: 'http://loki.internal:3100', tenantId: 'team-checkout', }), }) ``` ```typescript [Standalone]src/index.ts import { initLogger } from 'evlog' import { createLokiDrain } from 'evlog/loki' initLogger({ drain: 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 [Nuxt]server/plugins/evlog-drain.ts import { createLokiDrain } from 'evlog/loki' export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createLokiDrain({ endpoint: 'https://logs-prod-eu-west-0.grafana.net', user: '123456', apiKey: process.env.GRAFANA_API_KEY, })) }) ``` ```typescript [Nitro]server/plugins/evlog-drain.ts import { definePlugin } from 'nitro' import { createLokiDrain } from 'evlog/loki' export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createLokiDrain({ endpoint: 'https://logs-prod-eu-west-0.grafana.net', user: '123456', apiKey: process.env.GRAFANA_API_KEY, })) }) ``` ```typescript [Next.js]lib/evlog.ts import { createEvlog } from 'evlog/next' import { createLokiDrain } from 'evlog/loki' export const { withEvlog, useLogger, log, createError } = createEvlog({ service: 'my-app', drain: createLokiDrain({ endpoint: 'https://logs-prod-eu-west-0.grafana.net', user: '123456', apiKey: process.env.GRAFANA_API_KEY, }), }) ``` ```typescript [SvelteKit]src/hooks.server.ts import { createEvlogHooks } from 'evlog/sveltekit' import { createLokiDrain } from 'evlog/loki' export const { handle, handleError } = createEvlogHooks({ drain: createLokiDrain({ endpoint: 'https://logs-prod-eu-west-0.grafana.net', user: '123456', apiKey: process.env.GRAFANA_API_KEY, }), }) ``` ```typescript [TanStack Start]server/plugins/evlog-drain.ts import { definePlugin } from 'nitro' import { createLokiDrain } from 'evlog/loki' export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createLokiDrain({ endpoint: 'https://logs-prod-eu-west-0.grafana.net', user: '123456', apiKey: process.env.GRAFANA_API_KEY, })) }) ``` ```typescript [React Router]app/root.tsx import { evlog } from 'evlog/react-router' import { createLokiDrain } from 'evlog/loki' export const middleware: Route.MiddlewareFunction[] = [ evlog({ drain: createLokiDrain({ endpoint: 'https://logs-prod-eu-west-0.grafana.net', user: '123456', apiKey: process.env.GRAFANA_API_KEY, }), }), ] ``` ```typescript [Hono]src/index.ts import { evlog } from 'evlog/hono' import { createLokiDrain } from 'evlog/loki' app.use(evlog({ drain: createLokiDrain({ endpoint: 'https://logs-prod-eu-west-0.grafana.net', user: '123456', apiKey: process.env.GRAFANA_API_KEY, }), })) ``` ```typescript [Express]src/index.ts import { evlog } from 'evlog/express' import { createLokiDrain } from 'evlog/loki' app.use(evlog({ drain: createLokiDrain({ endpoint: 'https://logs-prod-eu-west-0.grafana.net', user: '123456', apiKey: process.env.GRAFANA_API_KEY, }), })) ``` ```typescript [Fastify]src/index.ts import { evlog } from 'evlog/fastify' import { createLokiDrain } from 'evlog/loki' await app.register(evlog, { drain: createLokiDrain({ endpoint: 'https://logs-prod-eu-west-0.grafana.net', user: '123456', apiKey: process.env.GRAFANA_API_KEY, }), }) ``` ```typescript [Elysia]src/index.ts import { evlog } from 'evlog/elysia' import { createLokiDrain } from 'evlog/loki' app.use(evlog({ drain: createLokiDrain({ endpoint: 'https://logs-prod-eu-west-0.grafana.net', user: '123456', apiKey: process.env.GRAFANA_API_KEY, }), })) ``` ```typescript [NestJS]src/app.module.ts import { EvlogModule } from 'evlog/nestjs' import { createLokiDrain } from 'evlog/loki' EvlogModule.forRoot({ drain: createLokiDrain({ endpoint: 'https://logs-prod-eu-west-0.grafana.net', user: '123456', apiKey: process.env.GRAFANA_API_KEY, }), }) ``` ```typescript [oRPC]server/orpc.ts import { withEvlog } from 'evlog/orpc' import { createLokiDrain } from 'evlog/loki' const handler = withEvlog(new RPCHandler(router), { drain: createLokiDrain({ endpoint: 'https://logs-prod-eu-west-0.grafana.net', user: '123456', apiKey: process.env.GRAFANA_API_KEY, }), }) ``` ```typescript [Cloudflare Workers]src/worker.ts import { withEvlog } from 'evlog/workers' import { createLokiDrain } from 'evlog/loki' export default withEvlog(handler, { drain: createLokiDrain({ endpoint: 'https://logs-prod-eu-west-0.grafana.net', user: '123456', apiKey: process.env.GRAFANA_API_KEY, }), }) ``` ```typescript [Standalone]src/index.ts import { initLogger } from 'evlog' import { createLokiDrain } from 'evlog/loki' initLogger({ drain: 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 ``` `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 Never promote a high-cardinality field to a label. Loki creates one stream per unique label combination, so 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 [Nuxt]server/plugins/evlog-drain.ts import { createLokiDrain } from 'evlog/loki' export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createLokiDrain({ // `region` has a handful of values — safe labelFields: ['service', 'environment', 'level', 'region'], // Static labels for everything in this deployment labels: { cluster: 'prod-eu' }, })) }) ``` ```typescript [Nitro]server/plugins/evlog-drain.ts import { definePlugin } from 'nitro' import { createLokiDrain } from 'evlog/loki' export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createLokiDrain({ // `region` has a handful of values — safe labelFields: ['service', 'environment', 'level', 'region'], // Static labels for everything in this deployment labels: { cluster: 'prod-eu' }, })) }) ``` ```typescript [Next.js]lib/evlog.ts import { createEvlog } from 'evlog/next' import { createLokiDrain } from 'evlog/loki' export const { withEvlog, useLogger, log, createError } = createEvlog({ service: 'my-app', drain: createLokiDrain({ // `region` has a handful of values — safe labelFields: ['service', 'environment', 'level', 'region'], // Static labels for everything in this deployment labels: { cluster: 'prod-eu' }, }), }) ``` ```typescript [SvelteKit]src/hooks.server.ts import { createEvlogHooks } from 'evlog/sveltekit' import { createLokiDrain } from 'evlog/loki' export const { handle, handleError } = createEvlogHooks({ drain: createLokiDrain({ // `region` has a handful of values — safe labelFields: ['service', 'environment', 'level', 'region'], // Static labels for everything in this deployment labels: { cluster: 'prod-eu' }, }), }) ``` ```typescript [TanStack Start]server/plugins/evlog-drain.ts import { definePlugin } from 'nitro' import { createLokiDrain } from 'evlog/loki' export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createLokiDrain({ // `region` has a handful of values — safe labelFields: ['service', 'environment', 'level', 'region'], // Static labels for everything in this deployment labels: { cluster: 'prod-eu' }, })) }) ``` ```typescript [React Router]app/root.tsx import { evlog } from 'evlog/react-router' import { createLokiDrain } from 'evlog/loki' export const middleware: Route.MiddlewareFunction[] = [ evlog({ drain: createLokiDrain({ // `region` has a handful of values — safe labelFields: ['service', 'environment', 'level', 'region'], // Static labels for everything in this deployment labels: { cluster: 'prod-eu' }, }), }), ] ``` ```typescript [Hono]src/index.ts import { evlog } from 'evlog/hono' import { createLokiDrain } from 'evlog/loki' app.use(evlog({ drain: createLokiDrain({ // `region` has a handful of values — safe labelFields: ['service', 'environment', 'level', 'region'], // Static labels for everything in this deployment labels: { cluster: 'prod-eu' }, }), })) ``` ```typescript [Express]src/index.ts import { evlog } from 'evlog/express' import { createLokiDrain } from 'evlog/loki' app.use(evlog({ drain: createLokiDrain({ // `region` has a handful of values — safe labelFields: ['service', 'environment', 'level', 'region'], // Static labels for everything in this deployment labels: { cluster: 'prod-eu' }, }), })) ``` ```typescript [Fastify]src/index.ts import { evlog } from 'evlog/fastify' import { createLokiDrain } from 'evlog/loki' await app.register(evlog, { drain: createLokiDrain({ // `region` has a handful of values — safe labelFields: ['service', 'environment', 'level', 'region'], // Static labels for everything in this deployment labels: { cluster: 'prod-eu' }, }), }) ``` ```typescript [Elysia]src/index.ts import { evlog } from 'evlog/elysia' import { createLokiDrain } from 'evlog/loki' app.use(evlog({ drain: createLokiDrain({ // `region` has a handful of values — safe labelFields: ['service', 'environment', 'level', 'region'], // Static labels for everything in this deployment labels: { cluster: 'prod-eu' }, }), })) ``` ```typescript [NestJS]src/app.module.ts import { EvlogModule } from 'evlog/nestjs' import { createLokiDrain } from 'evlog/loki' EvlogModule.forRoot({ drain: createLokiDrain({ // `region` has a handful of values — safe labelFields: ['service', 'environment', 'level', 'region'], // Static labels for everything in this deployment labels: { cluster: 'prod-eu' }, }), }) ``` ```typescript [oRPC]server/orpc.ts import { withEvlog } from 'evlog/orpc' import { createLokiDrain } from 'evlog/loki' const handler = withEvlog(new RPCHandler(router), { drain: createLokiDrain({ // `region` has a handful of values — safe labelFields: ['service', 'environment', 'level', 'region'], // Static labels for everything in this deployment labels: { cluster: 'prod-eu' }, }), }) ``` ```typescript [Cloudflare Workers]src/worker.ts import { withEvlog } from 'evlog/workers' import { createLokiDrain } from 'evlog/loki' export default withEvlog(handler, { drain: createLokiDrain({ // `region` has a handful of values — safe labelFields: ['service', 'environment', 'level', 'region'], // Static labels for everything in this deployment labels: { cluster: 'prod-eu' }, }), }) ``` ```typescript [Standalone]src/index.ts import { initLogger } from 'evlog' import { createLokiDrain } from 'evlog/loki' initLogger({ drain: 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 [Nuxt]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())) }) ``` ```typescript [Nitro]server/plugins/evlog.ts import { definePlugin } from 'nitro' 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 definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', pipeline(createLokiDrain())) }) ``` ```typescript [Next.js]lib/evlog.ts import { createEvlog } from 'evlog/next' import { createDrainPipeline } from 'evlog/pipeline' import { createLokiDrain } from 'evlog/loki' import type { DrainContext } from 'evlog' const pipeline = createDrainPipeline({ batch: { size: 100, intervalMs: 5000 }, }) export const { withEvlog, useLogger, log, createError } = createEvlog({ service: 'my-app', drain: pipeline(createLokiDrain()), }) ``` ```typescript [SvelteKit]src/hooks.server.ts import { createEvlogHooks } from 'evlog/sveltekit' import { createDrainPipeline } from 'evlog/pipeline' import { createLokiDrain } from 'evlog/loki' import type { DrainContext } from 'evlog' const pipeline = createDrainPipeline({ batch: { size: 100, intervalMs: 5000 }, }) export const { handle, handleError } = createEvlogHooks({ drain: pipeline(createLokiDrain()) }) ``` ```typescript [TanStack Start]server/plugins/evlog.ts import { definePlugin } from 'nitro' 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 definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', pipeline(createLokiDrain())) }) ``` ```typescript [React Router]app/root.tsx import { evlog } from 'evlog/react-router' import { createDrainPipeline } from 'evlog/pipeline' import { createLokiDrain } from 'evlog/loki' import type { DrainContext } from 'evlog' const pipeline = createDrainPipeline({ batch: { size: 100, intervalMs: 5000 }, }) export const middleware: Route.MiddlewareFunction[] = [ evlog({ drain: pipeline(createLokiDrain()) }), ] ``` ```typescript [Hono]src/index.ts import { evlog } from 'evlog/hono' import { createDrainPipeline } from 'evlog/pipeline' import { createLokiDrain } from 'evlog/loki' import type { DrainContext } from 'evlog' const pipeline = createDrainPipeline({ batch: { size: 100, intervalMs: 5000 }, }) app.use(evlog({ drain: pipeline(createLokiDrain()) })) ``` ```typescript [Express]src/index.ts import { evlog } from 'evlog/express' import { createDrainPipeline } from 'evlog/pipeline' import { createLokiDrain } from 'evlog/loki' import type { DrainContext } from 'evlog' const pipeline = createDrainPipeline({ batch: { size: 100, intervalMs: 5000 }, }) app.use(evlog({ drain: pipeline(createLokiDrain()) })) ``` ```typescript [Fastify]src/index.ts import { evlog } from 'evlog/fastify' import { createDrainPipeline } from 'evlog/pipeline' import { createLokiDrain } from 'evlog/loki' import type { DrainContext } from 'evlog' const pipeline = createDrainPipeline({ batch: { size: 100, intervalMs: 5000 }, }) await app.register(evlog, { drain: pipeline(createLokiDrain()) }) ``` ```typescript [Elysia]src/index.ts import { evlog } from 'evlog/elysia' import { createDrainPipeline } from 'evlog/pipeline' import { createLokiDrain } from 'evlog/loki' import type { DrainContext } from 'evlog' const pipeline = createDrainPipeline({ batch: { size: 100, intervalMs: 5000 }, }) app.use(evlog({ drain: pipeline(createLokiDrain()) })) ``` ```typescript [NestJS]src/app.module.ts import { EvlogModule } from 'evlog/nestjs' import { createDrainPipeline } from 'evlog/pipeline' import { createLokiDrain } from 'evlog/loki' import type { DrainContext } from 'evlog' const pipeline = createDrainPipeline({ batch: { size: 100, intervalMs: 5000 }, }) EvlogModule.forRoot({ drain: pipeline(createLokiDrain()) }) ``` ```typescript [oRPC]server/orpc.ts import { withEvlog } from 'evlog/orpc' import { createDrainPipeline } from 'evlog/pipeline' import { createLokiDrain } from 'evlog/loki' import type { DrainContext } from 'evlog' const pipeline = createDrainPipeline({ batch: { size: 100, intervalMs: 5000 }, }) const handler = withEvlog(new RPCHandler(router), { drain: pipeline(createLokiDrain()) }) ``` ```typescript [Cloudflare Workers]src/worker.ts import { withEvlog } from 'evlog/workers' 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 withEvlog(handler, { drain: pipeline(createLokiDrain()) }) ``` ```typescript [Standalone]src/index.ts import { initLogger } from 'evlog' import { createDrainPipeline } from 'evlog/pipeline' import { createLokiDrain } from 'evlog/loki' import type { DrainContext } from 'evlog' const pipeline = createDrainPipeline({ batch: { size: 100, intervalMs: 5000 }, }) initLogger({ drain: pipeline(createLokiDrain()) }) ``` Events sharing a label set are grouped into a single Loki stream, and entries are sorted by timestamp, because Loki rejects out-of-order pushes within a stream. ## Verify it locally Spin up Loki in Docker and push a real event, with 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 - [Drain pipeline](https://www.evlog.dev/extend/drain-pipeline): batching, retries, and fan-out - [Adapters Overview](https://www.evlog.dev/integrate/adapters/overview): all available destinations --- - [Loki HTTP API](https://grafana.com/docs/loki/latest/reference/loki-http-api/) - [LogQL](https://grafana.com/docs/loki/latest/query/) # OTLP Adapter > Send logs via OpenTelemetry Protocol (OTLP) to Grafana, Datadog, Honeycomb, and any compatible backend. Supports gRPC and HTTP transports. 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** - **Sazabi** 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: [https://www.evlog.dev/integrate/adapters/hybrid/otlp](https://www.evlog.dev/integrate/adapters/hybrid/otlp) Framework setup: [https://www.evlog.dev/integrate/frameworks/overview](https://www.evlog.dev/integrate/frameworks/overview) ## 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 ```typescript [Nuxt]server/plugins/evlog-drain.ts import { createOTLPDrain } from 'evlog/otlp' export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createOTLPDrain()) }) ``` ```typescript [Nitro]server/plugins/evlog-drain.ts import { definePlugin } from 'nitro' import { createOTLPDrain } from 'evlog/otlp' export default definePlugin((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 [SvelteKit]src/hooks.server.ts import { createEvlogHooks } from 'evlog/sveltekit' import { createOTLPDrain } from 'evlog/otlp' export const { handle, handleError } = createEvlogHooks({ drain: createOTLPDrain() }) ``` ```typescript [TanStack Start]server/plugins/evlog-drain.ts import { definePlugin } from 'nitro' import { createOTLPDrain } from 'evlog/otlp' export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createOTLPDrain()) }) ``` ```typescript [React Router]app/root.tsx import { evlog } from 'evlog/react-router' import { createOTLPDrain } from 'evlog/otlp' export const middleware: Route.MiddlewareFunction[] = [ evlog({ drain: createOTLPDrain() }), ] ``` ```typescript [Hono]src/index.ts import { createOTLPDrain } from 'evlog/otlp' app.use(evlog({ drain: createOTLPDrain() })) ``` ```typescript [Express]src/index.ts import { createOTLPDrain } from 'evlog/otlp' app.use(evlog({ drain: createOTLPDrain() })) ``` ```typescript [Fastify]src/index.ts import { createOTLPDrain } from 'evlog/otlp' await app.register(evlog, { drain: createOTLPDrain() }) ``` ```typescript [Elysia]src/index.ts import { createOTLPDrain } from 'evlog/otlp' app.use(evlog({ drain: createOTLPDrain() })) ``` ```typescript [NestJS]src/app.module.ts import { createOTLPDrain } from 'evlog/otlp' EvlogModule.forRoot({ drain: createOTLPDrain() }) ``` ```typescript [oRPC]server/orpc.ts import { withEvlog } from 'evlog/orpc' import { createOTLPDrain } from 'evlog/otlp' const handler = withEvlog(new RPCHandler(router), { drain: createOTLPDrain() }) ``` ```typescript [Cloudflare Workers]src/worker.ts import { withEvlog } from 'evlog/workers' import { createOTLPDrain } from 'evlog/otlp' export default withEvlog(handler, { 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_EXPORTER_OTLP_LOGS_ENDPOINT Full logs URL, used as-is with no /v1/logs appended. Takes precedence over OTEL_EXPORTER_OTLP_ENDPOINT .
OTEL_EXPORTER_OTLP_LOGS_HEADERS Headers for the logs signal. Merged over OTEL_EXPORTER_OTLP_HEADERS , winning on conflicts.
OTEL_EXPORTER_OTLP_COMPRESSION gzip or none . OTEL_EXPORTER_OTLP_LOGS_COMPRESSION takes precedence.
OTEL_EXPORTER_OTLP_PROTOCOL http/json or http/protobuf . OTEL_EXPORTER_OTLP_LOGS_PROTOCOL takes precedence. grpc is not supported.
OTEL_SERVICE_NAME Service name override
OTEL_RESOURCE_ATTRIBUTES Resource attributes as key=value pairs, comma-separated. resourceAttributes and the event's service , environment , version , region and commitHash take precedence.
These follow the [OpenTelemetry exporter specification](https://opentelemetry.io/docs/specs/otel/protocol/exporter/), so a deployment that already configures an OTel SDK through the environment needs no evlog-specific variables. ### 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 [Nuxt]server/plugins/evlog-drain.ts import { createOTLPDrain } from 'evlog/otlp' export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createOTLPDrain({ endpoint: 'http://localhost:4318', serviceName: 'my-api', headers: { 'Authorization': 'Bearer xxx', }, resourceAttributes: { 'deployment.environment': 'staging', }, })) }) ``` ```typescript [Nitro]server/plugins/evlog-drain.ts import { definePlugin } from 'nitro' import { createOTLPDrain } from 'evlog/otlp' export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createOTLPDrain({ endpoint: 'http://localhost:4318', serviceName: 'my-api', headers: { 'Authorization': 'Bearer xxx', }, resourceAttributes: { 'deployment.environment': 'staging', }, })) }) ``` ```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({ endpoint: 'http://localhost:4318', serviceName: 'my-api', headers: { 'Authorization': 'Bearer xxx', }, resourceAttributes: { 'deployment.environment': 'staging', }, }), }) ``` ```typescript [SvelteKit]src/hooks.server.ts import { createEvlogHooks } from 'evlog/sveltekit' import { createOTLPDrain } from 'evlog/otlp' export const { handle, handleError } = createEvlogHooks({ drain: createOTLPDrain({ endpoint: 'http://localhost:4318', serviceName: 'my-api', headers: { 'Authorization': 'Bearer xxx', }, resourceAttributes: { 'deployment.environment': 'staging', }, }), }) ``` ```typescript [TanStack Start]server/plugins/evlog-drain.ts import { definePlugin } from 'nitro' import { createOTLPDrain } from 'evlog/otlp' export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createOTLPDrain({ endpoint: 'http://localhost:4318', serviceName: 'my-api', headers: { 'Authorization': 'Bearer xxx', }, resourceAttributes: { 'deployment.environment': 'staging', }, })) }) ``` ```typescript [React Router]app/root.tsx import { evlog } from 'evlog/react-router' import { createOTLPDrain } from 'evlog/otlp' export const middleware: Route.MiddlewareFunction[] = [ evlog({ drain: createOTLPDrain({ endpoint: 'http://localhost:4318', serviceName: 'my-api', headers: { 'Authorization': 'Bearer xxx', }, resourceAttributes: { 'deployment.environment': 'staging', }, }), }), ] ``` ```typescript [Hono]src/index.ts import { evlog } from 'evlog/hono' import { createOTLPDrain } from 'evlog/otlp' app.use(evlog({ drain: createOTLPDrain({ endpoint: 'http://localhost:4318', serviceName: 'my-api', headers: { 'Authorization': 'Bearer xxx', }, resourceAttributes: { 'deployment.environment': 'staging', }, }), })) ``` ```typescript [Express]src/index.ts import { evlog } from 'evlog/express' import { createOTLPDrain } from 'evlog/otlp' app.use(evlog({ drain: createOTLPDrain({ endpoint: 'http://localhost:4318', serviceName: 'my-api', headers: { 'Authorization': 'Bearer xxx', }, resourceAttributes: { 'deployment.environment': 'staging', }, }), })) ``` ```typescript [Fastify]src/index.ts import { evlog } from 'evlog/fastify' import { createOTLPDrain } from 'evlog/otlp' await app.register(evlog, { drain: createOTLPDrain({ endpoint: 'http://localhost:4318', serviceName: 'my-api', headers: { 'Authorization': 'Bearer xxx', }, resourceAttributes: { 'deployment.environment': 'staging', }, }), }) ``` ```typescript [Elysia]src/index.ts import { evlog } from 'evlog/elysia' import { createOTLPDrain } from 'evlog/otlp' app.use(evlog({ drain: createOTLPDrain({ endpoint: 'http://localhost:4318', serviceName: 'my-api', headers: { 'Authorization': 'Bearer xxx', }, resourceAttributes: { 'deployment.environment': 'staging', }, }), })) ``` ```typescript [NestJS]src/app.module.ts import { EvlogModule } from 'evlog/nestjs' import { createOTLPDrain } from 'evlog/otlp' EvlogModule.forRoot({ drain: createOTLPDrain({ endpoint: 'http://localhost:4318', serviceName: 'my-api', headers: { 'Authorization': 'Bearer xxx', }, resourceAttributes: { 'deployment.environment': 'staging', }, }), }) ``` ```typescript [oRPC]server/orpc.ts import { withEvlog } from 'evlog/orpc' import { createOTLPDrain } from 'evlog/otlp' const handler = withEvlog(new RPCHandler(router), { drain: createOTLPDrain({ endpoint: 'http://localhost:4318', serviceName: 'my-api', headers: { 'Authorization': 'Bearer xxx', }, resourceAttributes: { 'deployment.environment': 'staging', }, }), }) ``` ```typescript [Cloudflare Workers]src/worker.ts import { withEvlog } from 'evlog/workers' import { createOTLPDrain } from 'evlog/otlp' export default withEvlog(handler, { drain: createOTLPDrain({ endpoint: 'http://localhost:4318', serviceName: 'my-api', headers: { 'Authorization': 'Bearer xxx', }, resourceAttributes: { 'deployment.environment': 'staging', }, }), }) ``` ```typescript [Standalone]src/index.ts import { initLogger } from 'evlog' import { createOTLPDrain } from 'evlog/otlp' initLogger({ 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 )
compression 'gzip' | 'none' 'none' Gzip the request body and send Content-Encoding: gzip
protocol 'http/json' | 'http/protobuf' 'http/json' Request body encoding ( details )
semanticConventions boolean false Add OpenTelemetry attribute names next to the evlog ones ( details )
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: ```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 ``` Grafana Cloud uses URL-encoded headers, so 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 , plus deployment.environment.name with semanticConventions
version Resource attribute service.version
region Resource attribute cloud.region
traceId traceId
spanId spanId
All other fields Log attributes
`spanId` is the span of the request that produced the event. The [TraceContext enricher](https://www.evlog.dev/use-cases/enrichers#tracecontextenricher) records the caller's span from an incoming `traceparent` as `parentSpanId`, which is sent as a log attribute, so set `spanId` from your tracer's active span to link the record to the server span. Attribute values keep their type: integers are sent as `intValue`, other finite numbers as `doubleValue`, booleans as `boolValue`, and arrays whose elements share one primitive type as `arrayValue`. In the `json` shape, nested plain objects are sent as `kvlistValue`. Anything else is serialized to a `stringValue`. A `traceId` or `spanId` that is not valid W3C hex, or is all zeros, stays an ordinary attribute. 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. Both shapes drop them at every level, so a nested `{ user: { id: null } }` has no `user.id` attribute in `compact` and no `id` entry in the `json` key-value list. ### Record Shape `recordShape` controls how the record carries the event. The default is `json`. ```json [json (default)] { "body": { "stringValue": "{\"timestamp\":\"…\",\"method\":\"POST\",\"user\":{\"id\":\"usr_123\"}}" }, "attributes": [ { "key": "method", "value": { "stringValue": "POST" } }, { "key": "user", "value": { "kvlistValue": { "values": [ { "key": "id", "value": { "stringValue": "usr_123" } }, { "key": "plan", "value": { "stringValue": "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 sent as a single attribute, an `arrayValue` when their elements share one primitive type and a JSON string otherwise. Indexing them, as `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. The same goes for anything else that is not a plain object, such as a `Date`. An empty object stays a single `{}` attribute rather than disappearing. `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. ### Semantic Conventions With `semanticConventions: true`, each record also carries the [OpenTelemetry semantic convention](https://opentelemetry.io/docs/specs/semconv/) names for the fields evlog knows, so backends with built-in HTTP, error, and GenAI views pick them up. The evlog names stay, so existing queries keep working.
evlog field Attribute
method http.request.method
path url.path
status http.response.status_code
userAgent.raw user_agent.original
error.name / error.message / error.stack exception.type / exception.message / exception.stacktrace
ai.model / ai.provider / ai.responseId gen_ai.request.model / gen_ai.provider.name / gen_ai.response.id
ai.inputTokens / ai.outputTokens gen_ai.usage.input_tokens / gen_ai.usage.output_tokens
ai.cacheReadTokens / ai.cacheWriteTokens gen_ai.usage.cache_read.input_tokens / gen_ai.usage.cache_creation.input_tokens
ai.finishReason gen_ai.response.finish_reasons
A field is mapped only when it has the type the convention requires, and an attribute the event already sets under that name is not overwritten. The resource also gets `deployment.environment.name`. The option becomes the default in the next major. ### Protobuf With `protocol: 'http/protobuf'`, the adapter sends the same request as binary protobuf with `Content-Type: application/x-protobuf`. Use it for collectors and gateways that only accept protobuf, or to shrink the payload. The encoder ships with evlog, has no dependencies, and is only loaded when this protocol is selected, so `evlog/otlp` stays the same size for JSON users. ```typescript [Nuxt]server/plugins/evlog-drain.ts import { createOTLPDrain } from 'evlog/otlp' export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createOTLPDrain({ endpoint: 'http://localhost:4318', protocol: 'http/protobuf', compression: 'gzip', })) }) ``` ```typescript [Nitro]server/plugins/evlog-drain.ts import { definePlugin } from 'nitro' import { createOTLPDrain } from 'evlog/otlp' export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createOTLPDrain({ endpoint: 'http://localhost:4318', protocol: 'http/protobuf', compression: 'gzip', })) }) ``` ```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({ endpoint: 'http://localhost:4318', protocol: 'http/protobuf', compression: 'gzip', }), }) ``` ```typescript [SvelteKit]src/hooks.server.ts import { createEvlogHooks } from 'evlog/sveltekit' import { createOTLPDrain } from 'evlog/otlp' export const { handle, handleError } = createEvlogHooks({ drain: createOTLPDrain({ endpoint: 'http://localhost:4318', protocol: 'http/protobuf', compression: 'gzip', }), }) ``` ```typescript [TanStack Start]server/plugins/evlog-drain.ts import { definePlugin } from 'nitro' import { createOTLPDrain } from 'evlog/otlp' export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createOTLPDrain({ endpoint: 'http://localhost:4318', protocol: 'http/protobuf', compression: 'gzip', })) }) ``` ```typescript [React Router]app/root.tsx import { evlog } from 'evlog/react-router' import { createOTLPDrain } from 'evlog/otlp' export const middleware: Route.MiddlewareFunction[] = [ evlog({ drain: createOTLPDrain({ endpoint: 'http://localhost:4318', protocol: 'http/protobuf', compression: 'gzip', }), }), ] ``` ```typescript [Hono]src/index.ts import { evlog } from 'evlog/hono' import { createOTLPDrain } from 'evlog/otlp' app.use(evlog({ drain: createOTLPDrain({ endpoint: 'http://localhost:4318', protocol: 'http/protobuf', compression: 'gzip', }), })) ``` ```typescript [Express]src/index.ts import { evlog } from 'evlog/express' import { createOTLPDrain } from 'evlog/otlp' app.use(evlog({ drain: createOTLPDrain({ endpoint: 'http://localhost:4318', protocol: 'http/protobuf', compression: 'gzip', }), })) ``` ```typescript [Fastify]src/index.ts import { evlog } from 'evlog/fastify' import { createOTLPDrain } from 'evlog/otlp' await app.register(evlog, { drain: createOTLPDrain({ endpoint: 'http://localhost:4318', protocol: 'http/protobuf', compression: 'gzip', }), }) ``` ```typescript [Elysia]src/index.ts import { evlog } from 'evlog/elysia' import { createOTLPDrain } from 'evlog/otlp' app.use(evlog({ drain: createOTLPDrain({ endpoint: 'http://localhost:4318', protocol: 'http/protobuf', compression: 'gzip', }), })) ``` ```typescript [NestJS]src/app.module.ts import { EvlogModule } from 'evlog/nestjs' import { createOTLPDrain } from 'evlog/otlp' EvlogModule.forRoot({ drain: createOTLPDrain({ endpoint: 'http://localhost:4318', protocol: 'http/protobuf', compression: 'gzip', }), }) ``` ```typescript [oRPC]server/orpc.ts import { withEvlog } from 'evlog/orpc' import { createOTLPDrain } from 'evlog/otlp' const handler = withEvlog(new RPCHandler(router), { drain: createOTLPDrain({ endpoint: 'http://localhost:4318', protocol: 'http/protobuf', compression: 'gzip', }), }) ``` ```typescript [Cloudflare Workers]src/worker.ts import { withEvlog } from 'evlog/workers' import { createOTLPDrain } from 'evlog/otlp' export default withEvlog(handler, { drain: createOTLPDrain({ endpoint: 'http://localhost:4318', protocol: 'http/protobuf', compression: 'gzip', }), }) ``` ```typescript [Standalone]src/index.ts import { initLogger } from 'evlog' import { createOTLPDrain } from 'evlog/otlp' initLogger({ drain: createOTLPDrain({ endpoint: 'http://localhost:4318', protocol: 'http/protobuf', compression: 'gzip', }), }) ``` To encode a request yourself, `evlog/otlp/protobuf` exports `encodeOTLPLogsRequest()`. ### Severity Mapping
evlog Level OTLP Severity Number OTLP Severity Text
trace 1 TRACE
debug 5 DEBUG
info 9 INFO
warn 13 WARN
error 17 ERROR
fatal 21 FATAL
## 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). For a protobuf-only receiver, set `protocol: 'http/protobuf'` - 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 --- - [OpenTelemetry Docs](https://opentelemetry.io/docs/) - [Custom Adapters](https://www.evlog.dev/extend/custom-drains) # Adapters Overview > Send your logs to external services with evlog adapters. Built-in support for popular observability platforms and custom destinations. 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. ## Send events without naming a destination in your code 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: ```typescript [Nuxt]server/plugins/evlog-drain.ts import { createAxiomDrain } from 'evlog/axiom' export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createAxiomDrain()) }) ``` ```typescript [Nitro]server/plugins/evlog-drain.ts import { definePlugin } from 'nitro' import { createAxiomDrain } from 'evlog/axiom' export default definePlugin((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 [SvelteKit]src/hooks.server.ts import { createEvlogHooks } from 'evlog/sveltekit' import { createAxiomDrain } from 'evlog/axiom' export const { handle, handleError } = createEvlogHooks({ drain: createAxiomDrain() }) ``` ```typescript [TanStack Start]server/plugins/evlog-drain.ts import { definePlugin } from 'nitro' import { createAxiomDrain } from 'evlog/axiom' export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createAxiomDrain()) }) ``` ```typescript [React Router]app/root.tsx import { evlog } from 'evlog/react-router' import { createAxiomDrain } from 'evlog/axiom' export const middleware: Route.MiddlewareFunction[] = [ evlog({ drain: createAxiomDrain() }), ] ``` ```typescript [Hono]src/index.ts import { createAxiomDrain } from 'evlog/axiom' app.use(evlog({ drain: createAxiomDrain() })) ``` ```typescript [Express]src/index.ts import { createAxiomDrain } from 'evlog/axiom' app.use(evlog({ drain: createAxiomDrain() })) ``` ```typescript [Fastify]src/index.ts import { createAxiomDrain } from 'evlog/axiom' await app.register(evlog, { drain: createAxiomDrain() }) ``` ```typescript [Elysia]src/index.ts import { createAxiomDrain } from 'evlog/axiom' app.use(evlog({ drain: createAxiomDrain() })) ``` ```typescript [NestJS]src/app.module.ts import { createAxiomDrain } from 'evlog/axiom' EvlogModule.forRoot({ drain: createAxiomDrain() }) ``` ```typescript [oRPC]server/orpc.ts import { withEvlog } from 'evlog/orpc' import { createAxiomDrain } from 'evlog/axiom' const handler = withEvlog(new RPCHandler(router), { drain: createAxiomDrain() }) ``` ```typescript [Cloudflare Workers]src/worker.ts import { withEvlog } from 'evlog/workers' import { createAxiomDrain } from 'evlog/axiom' export default withEvlog(handler, { drain: createAxiomDrain() }) ``` ```typescript [Standalone] import { createAxiomDrain } from 'evlog/axiom' initLogger({ drain: createAxiomDrain() }) ``` **Serverless Support:** On Cloudflare Workers and Vercel Edge, evlog automatically uses `waitUntil()` to ensure drains complete before the runtime terminates. No additional configuration needed. ## Pick a destination Send logs to Axiom, which indexes every field so you can query on any of them. Send logs to PostHog Logs for structured logging and observability. Send structured logs to Sentry Logs for high-cardinality querying. Send logs to Better Stack for log management and alerting. Send logs to Datadog Logs via the native HTTP intake API. Send logs to a Sazabi log stream over OTLP/HTTP, configured with the stream's intake URL. Push logs to self-hosted, multi-tenant, or Grafana Cloud Loki. Insert logs into ClickHouse for fast aggregation over huge volumes. OpenTelemetry Protocol for Grafana, Datadog, Honeycomb, and more. Send logs to HyperDX via OTLP/HTTP using their documented ingest endpoint and API key. Write logs to local NDJSON files for debugging and AI agent integration. Self-hosted log storage in your NuxtHub database with automatic retention. In-memory ring buffer that works in any runtime, including Cloudflare Workers. Build your own adapter for any destination. Send client logs to your server over HTTP without framework coupling. Batch events, retry on failure, and handle buffer overflow. ## Call a drain outside a request 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() ``` See the full [bun-script example](https://github.com/evloghq/evlog/tree/main/examples/bun-script) for a realistic batch processing script. ## Send to several places at once 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: ```typescript [Nuxt]server/plugins/evlog-drain.ts export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', drain) }) ``` ```typescript [Nitro]server/plugins/evlog-drain.ts import { definePlugin } from 'nitro' export default definePlugin((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 [SvelteKit]src/hooks.server.ts import { createEvlogHooks } from 'evlog/sveltekit' export const { handle, handleError } = createEvlogHooks({ drain }) ``` ```typescript [TanStack Start]server/plugins/evlog-drain.ts import { definePlugin } from 'nitro' export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', drain) }) ``` ```typescript [React Router]app/root.tsx import { evlog } from 'evlog/react-router' export const middleware: Route.MiddlewareFunction[] = [ evlog({ drain }), ] ``` ```typescript [Hono]src/index.ts app.use(evlog({ drain })) ``` ```typescript [Express]src/index.ts app.use(evlog({ drain })) ``` ```typescript [Fastify]src/index.ts await app.register(evlog, { drain }) ``` ```typescript [Elysia]src/index.ts app.use(evlog({ drain })) ``` ```typescript [NestJS]src/app.module.ts EvlogModule.forRoot({ drain }) ``` ```typescript [oRPC]server/orpc.ts import { withEvlog } from 'evlog/orpc' const handler = withEvlog(new RPCHandler(router), { drain }) ``` ```typescript [Cloudflare Workers]src/worker.ts import { withEvlog } from 'evlog/workers' export default withEvlog(handler, { drain }) ``` ```typescript [Standalone] initLogger({ drain }) ``` ## What a drain receives 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)
**Security:** Sensitive headers (`authorization`, `cookie`, `x-api-key`, etc.) are automatically filtered and never passed to adapters. ## Configure it from the environment All adapters support automatic configuration via environment variables. No code changes needed when deploying to different environments. Each adapter reads from standard environment variables, and 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 # Sazabi SAZABI_INTAKE_URL=https://.us-west-2.intake.sazabi.com # 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 a `create*Drain()` factory takes 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](https://www.evlog.dev/integrate/adapters/cloud/axiom) - [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) - [Datadog](https://www.evlog.dev/integrate/adapters/cloud/datadog) - [Sazabi](https://www.evlog.dev/integrate/adapters/cloud/sazabi) - [Grafana Loki](https://www.evlog.dev/integrate/adapters/hybrid/loki) - [ClickHouse](https://www.evlog.dev/integrate/adapters/hybrid/clickhouse) - [OTLP](https://www.evlog.dev/integrate/adapters/hybrid/otlp) - [HyperDX](https://www.evlog.dev/integrate/adapters/hybrid/hyperdx) - [File System](https://www.evlog.dev/integrate/adapters/self-hosted/fs) - [NuxtHub](https://www.evlog.dev/integrate/adapters/self-hosted/nuxthub) - [Memory](https://www.evlog.dev/integrate/adapters/self-hosted/memory) # File System Adapter > Write wide events to the local file system as NDJSON for local debugging, AI agent integration, and production backup. 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 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: [https://www.evlog.dev/integrate/adapters/self-hosted/fs](https://www.evlog.dev/integrate/adapters/self-hosted/fs) Framework setup: [https://www.evlog.dev/integrate/frameworks/overview](https://www.evlog.dev/integrate/frameworks/overview) ## 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: ```typescript [Nuxt]server/plugins/evlog-drain.ts import { createFsDrain } from 'evlog/fs' export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createFsDrain()) }) ``` ```typescript [Nitro]server/plugins/evlog-drain.ts import { definePlugin } from 'nitro' import { createFsDrain } from 'evlog/fs' export default definePlugin((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(), }) ``` ```typescript [SvelteKit]src/hooks.server.ts import { createEvlogHooks } from 'evlog/sveltekit' import { createFsDrain } from 'evlog/fs' export const { handle, handleError } = createEvlogHooks({ drain: createFsDrain() }) ``` ```typescript [TanStack Start]server/plugins/evlog-drain.ts import { definePlugin } from 'nitro' import { createFsDrain } from 'evlog/fs' export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createFsDrain()) }) ``` ```typescript [React Router]app/root.tsx import { evlog } from 'evlog/react-router' import { createFsDrain } from 'evlog/fs' export const middleware: Route.MiddlewareFunction[] = [ evlog({ drain: createFsDrain() }), ] ``` ```typescript [Hono]src/index.ts import { createFsDrain } from 'evlog/fs' app.use(evlog({ drain: createFsDrain() })) ``` ```typescript [Express]src/index.ts import { createFsDrain } from 'evlog/fs' app.use(evlog({ drain: createFsDrain() })) ``` ```typescript [Fastify]src/index.ts import { createFsDrain } from 'evlog/fs' await app.register(evlog, { drain: createFsDrain() }) ``` ```typescript [Elysia]src/index.ts import { createFsDrain } from 'evlog/fs' app.use(evlog({ drain: createFsDrain() })) ``` ```typescript [NestJS]src/app.module.ts import { createFsDrain } from 'evlog/fs' EvlogModule.forRoot({ drain: createFsDrain() }) ``` ```typescript [oRPC]server/orpc.ts import { withEvlog } from 'evlog/orpc' import { createFsDrain } from 'evlog/fs' const handler = withEvlog(new RPCHandler(router), { drain: createFsDrain() }) ``` ```typescript [Cloudflare Workers]src/worker.ts import { withEvlog } from 'evlog/workers' import { createFsDrain } from 'evlog/fs' export default withEvlog(handler, { drain: createFsDrain() }) ``` ```typescript [Standalone] import { createFsDrain } from 'evlog/fs' initLogger({ drain: createFsDrain() }) ``` 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. Logs start appearing in `.evlog/logs/` immediately. ## Where the files land ```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. 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 // 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' }) ``` ### Rotate by size or by day 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 ``` ### Delete old files on a schedule When `maxFiles` is set, the adapter automatically deletes the oldest `.jsonl` files after each write, keeping only the most recent files. ## Write to disk and ship to a service Use the FS adapter alongside a network drain for local backup: ```typescript [Nuxt]server/plugins/evlog.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)]) } export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', drain) }) ``` ```typescript [Nitro]server/plugins/evlog.ts import { definePlugin } from 'nitro' 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)]) } export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', drain) }) ``` ```typescript [Next.js]lib/evlog.ts import { createEvlog } from 'evlog/next' 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)]) } export const { withEvlog, useLogger, log, createError } = createEvlog({ service: 'my-app', drain: drain, }) ``` ```typescript [SvelteKit]src/hooks.server.ts import { createEvlogHooks } from 'evlog/sveltekit' 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)]) } export const { handle, handleError } = createEvlogHooks({ drain: drain }) ``` ```typescript [TanStack Start]server/plugins/evlog.ts import { definePlugin } from 'nitro' 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)]) } export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', drain) }) ``` ```typescript [React Router]app/root.tsx import { evlog } from 'evlog/react-router' 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)]) } export const middleware: Route.MiddlewareFunction[] = [ evlog({ drain: drain }), ] ``` ```typescript [Hono]src/index.ts import { evlog } from 'evlog/hono' 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)]) } app.use(evlog({ drain: drain })) ``` ```typescript [Express]src/index.ts import { evlog } from 'evlog/express' 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)]) } app.use(evlog({ drain: drain })) ``` ```typescript [Fastify]src/index.ts import { evlog } from 'evlog/fastify' 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)]) } await app.register(evlog, { drain: drain }) ``` ```typescript [Elysia]src/index.ts import { evlog } from 'evlog/elysia' 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)]) } app.use(evlog({ drain: drain })) ``` ```typescript [NestJS]src/app.module.ts import { EvlogModule } from 'evlog/nestjs' 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)]) } EvlogModule.forRoot({ drain: drain }) ``` ```typescript [oRPC]server/orpc.ts import { withEvlog } from 'evlog/orpc' 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)]) } const handler = withEvlog(new RPCHandler(router), { drain: drain }) ``` ```typescript [Cloudflare Workers]src/worker.ts import { withEvlog } from 'evlog/workers' 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)]) } export default withEvlog(handler, { drain: drain }) ``` ```typescript [Standalone]src/index.ts import { initLogger } from 'evlog' 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)]) } initLogger({ drain: drain }) ``` ## Read back what you wrote ### 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 ``` ## Call the drain without a framework 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, }) ``` ## Hand the file to an agent 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 --- - [NDJSON Format](https://github.com/ndjson/ndjson-spec) - [Axiom Adapter](https://www.evlog.dev/integrate/adapters/cloud/axiom) # Memory Adapter > Store wide events in an in-memory ring buffer. Works in any runtime — including Cloudflare Workers (workerd) — where the file system is unavailable. 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. 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: [https://www.evlog.dev/integrate/adapters/self-hosted/memory](https://www.evlog.dev/integrate/adapters/self-hosted/memory) Framework setup: [https://www.evlog.dev/integrate/frameworks/overview](https://www.evlog.dev/integrate/frameworks/overview) ## Installation The Memory adapter comes bundled with evlog: ```typescript [src/index.ts] import { createMemoryDrain, readMemoryLogs } from 'evlog/memory' ``` ## Quick Start ```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]server/plugins/evlog-drain.ts import { createMemoryDrain } from 'evlog/memory' export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', createMemoryDrain()) }) ``` ```typescript [Nitro]server/plugins/evlog-drain.ts import { definePlugin } from 'nitro' import { createMemoryDrain } from 'evlog/memory' export default definePlugin((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]src/index.ts import { evlog } from 'evlog/express' import { createMemoryDrain } from 'evlog/memory' app.use(evlog({ drain: createMemoryDrain() })) ``` ```typescript [Fastify]src/index.ts import { evlog } from 'evlog/fastify' import { createMemoryDrain } from 'evlog/memory' await app.register(evlog, { drain: createMemoryDrain() }) ``` ```typescript [Elysia]src/index.ts import { evlog } from 'evlog/elysia' import { createMemoryDrain } from 'evlog/memory' app.use(evlog({ drain: createMemoryDrain() })) ``` ```typescript [NestJS]src/app.module.ts 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 // 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 [Nuxt]server/plugins/evlog.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) } } export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', drain) }) ``` ```typescript [Nitro]server/plugins/evlog.ts import { definePlugin } from 'nitro' 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) } } export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', drain) }) ``` ```typescript [Next.js]lib/evlog.ts import { createEvlog } from 'evlog/next' 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) } } export const { withEvlog, useLogger, log, createError } = createEvlog({ service: 'my-app', drain: drain, }) ``` ```typescript [SvelteKit]src/hooks.server.ts import { createEvlogHooks } from 'evlog/sveltekit' 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) } } export const { handle, handleError } = createEvlogHooks({ drain: drain }) ``` ```typescript [TanStack Start]server/plugins/evlog.ts import { definePlugin } from 'nitro' 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) } } export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', drain) }) ``` ```typescript [React Router]app/root.tsx import { evlog } from 'evlog/react-router' 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) } } export const middleware: Route.MiddlewareFunction[] = [ evlog({ drain: drain }), ] ``` ```typescript [Hono]src/index.ts import { evlog } from 'evlog/hono' 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) } } app.use(evlog({ drain: drain })) ``` ```typescript [Express]src/index.ts import { evlog } from 'evlog/express' 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) } } app.use(evlog({ drain: drain })) ``` ```typescript [Fastify]src/index.ts import { evlog } from 'evlog/fastify' 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) } } await app.register(evlog, { drain: drain }) ``` ```typescript [Elysia]src/index.ts import { evlog } from 'evlog/elysia' 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) } } app.use(evlog({ drain: drain })) ``` ```typescript [NestJS]src/app.module.ts import { EvlogModule } from 'evlog/nestjs' 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) } } EvlogModule.forRoot({ drain: drain }) ``` ```typescript [oRPC]server/orpc.ts import { withEvlog } from 'evlog/orpc' 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) } } const handler = withEvlog(new RPCHandler(router), { drain: drain }) ``` ```typescript [Cloudflare Workers]src/worker.ts import { withEvlog } from 'evlog/workers' 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) } } export default withEvlog(handler, { drain: drain }) ``` ```typescript [Standalone]src/index.ts import { initLogger } from 'evlog' 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) } } initLogger({ drain: drain }) ``` ## 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] ``` 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 --- - [File System Adapter](https://www.evlog.dev/integrate/adapters/self-hosted/fs) - [Hono Integration](https://www.evlog.dev/integrate/frameworks/hono) # NuxtHub Storage > Self-hosted retention using NuxtHub database storage. Store, query, and clean up your wide events with no external dependency. `@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. 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: [https://www.evlog.dev/integrate/adapters/self-hosted/nuxthub](https://www.evlog.dev/integrate/adapters/self-hosted/nuxthub) NuxtHub: [https://hub.nuxt.com](https://hub.nuxt.com) ## 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 ```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] pnpm dlx 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 **Prerequisites:** Your project must use [NuxtHub](https://hub.nuxt.com) 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 --- - [NuxtHub](https://hub.nuxt.com) - [Adapters](https://www.evlog.dev/integrate/adapters/overview) # Astro > Wide events and structured errors in Astro server middleware. Astro doesn't have a dedicated evlog integration. Instead, use the core `evlog` package with Astro's middleware to create request-scoped loggers manually. Set up evlog in my Astro app. - Install evlog: pnpm add evlog - Import initLogger and createRequestLogger from 'evlog' - Create evlog.config.ts with export default defineEvlog({ service: 'my-astro-app' }) - Call initLogger(toLoggerConfig(config)) 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: [https://www.evlog.dev/integrate/frameworks/astro](https://www.evlog.dev/integrate/frameworks/astro) Adapters: [https://www.evlog.dev/integrate/adapters/overview](https://www.evlog.dev/integrate/adapters/overview) This is a guide-level integration. It uses the generic `createRequestLogger` API rather than a framework-specific module. 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, so check the [Cloudflare adapter docs](https://docs.astro.build/en/guides/integrations-guide/cloudflare/). ## Quick Start ### 1. Install ```bash [pnpm] pnpm add evlog ``` ```bash [bun] bun add evlog ``` ```bash [yarn] yarn add evlog ``` ```bash [npm] npm install evlog ``` ### 2. Create evlog.config.ts ```typescript [evlog.config.ts] import { defineEvlog } from 'evlog' export default defineEvlog({ service: 'my-astro-app', }) ``` Every option lives in this file: sampling, drain, `enrich`, route filters. `npx evlog init` writes it, and the wiring below, for you. See [Configuration](https://www.evlog.dev/reference/configuration). ### 3. Create a middleware ```typescript [src/middleware.ts] import { defineMiddleware } from 'astro:middleware' import { initLogger, createRequestLogger, toLoggerConfig } from 'evlog' import config from '../evlog.config' initLogger(toLoggerConfig(config)) 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 } }) ``` ### 4. 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 Every option goes in `evlog.config.ts`. See the [Configuration reference](https://www.evlog.dev/reference/configuration) for the full list. This guide builds the request logger with `createRequestLogger`, not an evlog middleware, so the file's `enrich`, `keep`, `include`, `exclude` and `routes` do not apply. ## Drain Set the drain in the file; `toLoggerConfig(config)` passes it to `initLogger`: ```typescript [evlog.config.ts] import { defineEvlog, type DrainContext } from 'evlog' import { createAxiomDrain } from 'evlog/axiom' import { createDrainPipeline } from 'evlog/pipeline' const pipeline = createDrainPipeline({ batch: { size: 50, intervalMs: 5000 }, }) export default defineEvlog({ service: 'my-astro-app', drain: pipeline(createAxiomDrain()), }) ``` 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 # AWS Lambda > Wide events and structured logging in AWS Lambda functions, including SQS consumers and event-driven handlers. 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. Set up evlog in an AWS Lambda function (e.g. SQS consumer). - Install evlog: pnpm add evlog - Create evlog.config.ts with export default defineEvlog({ service: 'my-fn' }) - Call initLogger(toLoggerConfig(config)) 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: [https://www.evlog.dev/integrate/frameworks/aws-lambda](https://www.evlog.dev/integrate/frameworks/aws-lambda) Adapters: [https://www.evlog.dev/integrate/adapters/overview](https://www.evlog.dev/integrate/adapters/overview) ## 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 ```bash [pnpm] pnpm add evlog ``` ```bash [bun] bun add evlog ``` ```bash [yarn] yarn add evlog ``` ```bash [npm] npm install evlog ``` ### 2. Create evlog.config.ts ```typescript [evlog.config.ts] import { defineEvlog } from 'evlog' export default defineEvlog({ service: 'sqs-consumer', environment: process.env.NODE_ENV, }) ``` Your bundler includes the file with the handler, so the drain and `process.env` reads in it work at cold start. `npx evlog init` writes it for you. ### 3. Initialize once, log per invocation ```typescript [src/handler.ts] import type { SQSEvent } from 'aws-lambda' import { initLogger, createLogger, toLoggerConfig } from 'evlog' import config from '../evlog.config' initLogger(toLoggerConfig(config)) 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#options). ```typescript [evlog.config.ts] import { defineEvlog } from 'evlog' import { createAxiomDrain } from 'evlog/axiom' export default defineEvlog({ service: 'sqs-consumer', silent: process.env.NODE_ENV === 'production', drain: createAxiomDrain(), }) ``` If `silent` is enabled without a `drain`, events are built but never written anywhere. See the `silent` option in [Configuration](https://www.evlog.dev/reference/configuration#options). ## What the file covers `toLoggerConfig` keeps every option `initLogger` takes, such as the service, sampling, `minLevel`, `redact`, the drain and the plugins. The file's `enrich`, `keep`, `include`, `exclude` and `routes` are framework middleware options, so they do not apply to a handler. ## 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 # Cloudflare Workers > Wide events, structured errors, and logging in Cloudflare Workers and Durable Objects. 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. Set up evlog in my Cloudflare Worker. - Install evlog: pnpm add evlog - Import initWorkersLogger and withEvlog from 'evlog/workers' - Create evlog.config.ts with export default defineEvlog({ service: 'my-worker' }) - Call initWorkersLogger(toLoggerConfig(config)) at the top level and pass toMiddlewareOptions(config) to withEvlog - 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: [https://www.evlog.dev/integrate/frameworks/cloudflare-workers](https://www.evlog.dev/integrate/frameworks/cloudflare-workers) Adapters: [https://www.evlog.dev/integrate/adapters/overview](https://www.evlog.dev/integrate/adapters/overview) ## Quick Start ### 1. Install ```bash [pnpm] pnpm add evlog ``` ```bash [bun] bun add evlog ``` ```bash [yarn] yarn add evlog ``` ```bash [npm] npm install evlog ``` ### 2. Create evlog.config.ts ```typescript [evlog.config.ts] import { defineEvlog } from 'evlog' export default defineEvlog({ service: 'my-worker', }) ``` Every option lives in this file: sampling, drain, `enrich`, route filters. `npx evlog init` writes it, and the wiring below, for you. See [Configuration](https://www.evlog.dev/reference/configuration). ### 3. Wrap your fetch handler ```typescript [src/worker.ts] import { toLoggerConfig, toMiddlewareOptions } from 'evlog' import { initWorkersLogger, withEvlog } from 'evlog/workers' import config from '../evlog.config' initWorkersLogger(toLoggerConfig(config)) export default withEvlog(async (request, _env, _ctx, log) => { log.set({ action: 'handle_request' }) // ... your handler logic return Response.json({ ok: true }) }, toMiddlewareOptions(config)) ``` `withEvlog` emits one wide event per request when the handler returns, with 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` takes the same options as every other framework integration. Put them in the file: ```typescript [evlog.config.ts] import { defineEvlog } from 'evlog' import { createAxiomDrain } from 'evlog/axiom' export default defineEvlog({ service: 'my-worker', 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 }, }) ``` `initWorkersLogger` always sets `pretty` and `stringify` to `false`, whatever the file says. The file is bundled into the Worker and evaluated there, so a `process.env` read in it only sees your Worker's variables when Cloudflare exposes them on `process.env` (the `nodejs_compat` flag). Adapters such as `createAxiomDrain()` read their keys the same way. ### Emitting manually Prefer to own the emit? `defineWorkerFetch` wires `ExecutionContext` for you but leaves `log.emit()` to you: ```typescript [src/worker.ts] import { toLoggerConfig } from 'evlog' import { defineWorkerFetch, initWorkersLogger } from 'evlog/workers' import config from '../evlog.config' initWorkersLogger(toLoggerConfig(config)) export default defineWorkerFetch(async (request, _env, _ctx, log) => { log.set({ action: 'handle_request' }) log.emit() return Response.json({ ok: true }) }) ``` `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 { toLoggerConfig } from 'evlog' import { defineWorkerFetch, initWorkersLogger } from 'evlog/workers' import config from '../evlog.config' initWorkersLogger(toLoggerConfig(config)) 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: ```typescript [src/worker.ts] import { createError, parseError } from 'evlog' import { toLoggerConfig } from 'evlog' import { defineWorkerFetch, initWorkersLogger } from 'evlog/workers' import config from '../evlog.config' initWorkersLogger(toLoggerConfig(config)) 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 Every option goes in `evlog.config.ts`. See the [Configuration reference](https://www.evlog.dev/reference/configuration) for the full list. ## Drain & Enrichers Put the drain and the enrichers in the file. `toLoggerConfig` hands the drain to `initWorkersLogger`, and `toMiddlewareOptions` hands both to `withEvlog`, which runs them on every request event: ```typescript [evlog.config.ts] import { defineEvlog, type DrainContext } from 'evlog' import { createAxiomDrain } from 'evlog/axiom' import { createUserAgentEnricher } from 'evlog/enrichers' import { createDrainPipeline } from 'evlog/pipeline' const pipeline = createDrainPipeline({ batch: { size: 50, intervalMs: 5000 }, }) const userAgent = createUserAgentEnricher() export default defineEvlog({ service: 'my-worker', drain: pipeline(createAxiomDrain()), enrich: (ctx) => { userAgent(ctx) }, }) ``` 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 # Effect > routerLayer gives every HttpRouter or HttpApi request one wide event, EvlogLogger reads it from any effect, and withLogger covers jobs and alarms. `evlog/effect` turns each request served by Effect's `HttpRouter` or `HttpApi` into one wide event, and any other effect (a job, a queue message, a Durable Object alarm) into one with `withLogger`. The event travels in the Effect context as the `EvlogLogger` service, so handlers read it with `yield* EvlogLogger` and the event records the `Cause` the effect failed with. It needs `effect` 4. **Beta.** `evlog/effect` is new, and Effect still marks `effect/http` as unstable. Its exports and the fields it writes may change in a minor release of evlog, including breaking changes. Pin your evlog version and read the changelog when you upgrade. Feedback is welcome on [GitHub](https://github.com/evloghq/evlog/issues). Set up evlog in my Effect HTTP app. - Install evlog: pnpm add evlog (effect 4 is a peer dependency) - Create evlog.config.ts with export default defineEvlog({ service: 'my-api' }) - Provide initLoggerLayer(toLoggerConfig(config)) from 'evlog/effect' to the server layer - Provide routerLayer(toMiddlewareOptions(config)) from 'evlog/effect' to the HttpRouter routes, and pass disableLogger: true to HttpRouter.serve or HttpRouter.toWebHandler - Read the event with yield* EvlogLogger, or yield* useLogger () for typed fields, and call log.set() to accumulate context - Wrap jobs, queue consumers and Durable Object alarms with withLogger('action-name') - Provide loggerLayer() so Effect.log* calls land on the current wide event Docs: [https://www.evlog.dev/integrate/frameworks/effect](https://www.evlog.dev/integrate/frameworks/effect) Adapters: [https://www.evlog.dev/integrate/adapters/overview](https://www.evlog.dev/integrate/adapters/overview) ## Quick Start ### 1. Install ```bash [pnpm] pnpm add evlog effect @effect/platform-node ``` ```bash [bun] bun add evlog effect @effect/platform-node ``` ```bash [yarn] yarn add evlog effect @effect/platform-node ``` ```bash [npm] npm install evlog effect @effect/platform-node ``` `@effect/platform-node` serves the router on Node. On Cloudflare Workers you leave it out and serve the router with `HttpRouter.toWebHandler`. See [Cloudflare Workers and Durable Objects](#cloudflare-workers-and-durable-objects). ### 2. Create evlog.config.ts ```typescript [evlog.config.ts] import { defineEvlog } from 'evlog' export default defineEvlog({ service: 'my-api', }) ``` Every option lives in this file: sampling, drain, `enrich`, route filters. See [Configuration](https://www.evlog.dev/reference/configuration). ### 3. Provide the layers `routerLayer` installs the evlog middleware on every route of the router and provides `EvlogLogger` to their handlers. `initLoggerLayer` calls `initLogger` when the layer builds: ```typescript [src/index.ts] import { createServer } from 'node:http' import { NodeHttpServer, NodeRuntime } from '@effect/platform-node' import { Effect, Layer } from 'effect' import { HttpRouter, HttpServerResponse } from 'effect/http' import { toLoggerConfig, toMiddlewareOptions } from 'evlog' import { EvlogLogger, initLoggerLayer, routerLayer } from 'evlog/effect' import config from '../evlog.config' const Routes = HttpRouter.add('GET', '/health', Effect.gen(function* () { const log = yield* EvlogLogger log.set({ route: 'health' }) return HttpServerResponse.jsonUnsafe({ ok: true }) })) const Server = HttpRouter.serve( Routes.pipe(Layer.provide(routerLayer(toMiddlewareOptions(config)))), { disableLogger: true }, ).pipe( Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })), Layer.provide(initLoggerLayer(toLoggerConfig(config))), ) NodeRuntime.runMain(Layer.launch(Server)) ``` `disableLogger: true` turns off the router's own `Sent HTTP response` line, which the wide event replaces. The same layer works with `HttpRouter.toWebHandler` and with `HttpApiBuilder.layer(Api)` for an `HttpApi`. Without a router, `evlog()` is the raw `effect/http` middleware: `HttpServer.serve(app, evlog(options))`. `routerLayer(options)` is that middleware registered globally on the router. ## Wide Events Build up context through the handler. One request is one wide event: ```typescript [src/routes/users.ts] import { Effect } from 'effect' import { HttpRouter, HttpServerResponse } from 'effect/http' import { EvlogLogger } from 'evlog/effect' export const UserRoute = HttpRouter.add('GET', '/users/:id', Effect.gen(function* () { const log = yield* EvlogLogger const { id } = yield* HttpRouter.params log.set({ user: { id } }) const user = yield* Users.find(id) log.set({ user: { name: user.name, plan: user.plan } }) const orders = yield* Orders.forUser(id) log.set({ orders: { count: orders.length, totalRevenue: sum(orders) } }) return HttpServerResponse.jsonUnsafe({ user, orders }) })) ``` Every field is merged into one event emitted when the response is ready. The request runs in an Effect span, so the event carries its `traceId` and `spanId`: ```bash [Terminal output] 16:03:30.441 INFO [my-api] GET /users/42 200 in 3ms ├─ requestId: 11362fc9-... ├─ traceId: 370aa48d6f233739a22e1297da7cabb5 ├─ spanId: 6cf0065dae1b90e3 ├─ user: id=42 name=Alice plan=pro └─ orders: count=2 totalRevenue=6298 ``` ## Reach the event from any effect Other integrations hand the logger to code underneath through `AsyncLocalStorage`. `evlog/effect` uses the Effect context instead: `EvlogLogger` is a service, and a function that reads it declares the requirement in its type. ```typescript [src/services/payment.ts] import { Effect } from 'effect' import { EvlogLogger } from 'evlog/effect' export const chargeCard = (amount: number) => Effect.gen(function* () { const log = yield* EvlogLogger log.set({ payment: { amount } }) }) ``` `chargeCard` has type `Effect`, so running it outside a unit of work fails to type-check. Library code that should not carry that requirement uses `set` instead, which merges fields into the current event and does nothing outside one, like `Effect.annotateCurrentSpan` outside a span: ```typescript [src/services/cache.ts] import { Effect } from 'effect' import { set } from 'evlog/effect' export const recordCacheHit = Effect.gen(function* () { yield* set({ cache: { hit: true } }) }) ``` There is no `AsyncLocalStorage` anywhere in the entry point, so it runs on Cloudflare Workers without the `nodejs_compat` flag. There is also no `log.fork()`: run background work through [`withLogger`](#jobs-queues-and-alarms), which emits its own event in the same trace. ### Typed fields `useLogger()` returns the same event, typed with your fields, so a wrong shape fails to compile: ```typescript [src/routes/checkout.ts] import { Effect } from 'effect' import { useLogger } from 'evlog/effect' interface CheckoutFields { cart: { items: number, total: number } user: { id: string } } const checkout = Effect.gen(function* () { const log = yield* useLogger() log.set({ cart: { items: 3, total: 9999 } }) log.set({ cart: { items: 'three' } }) // type error }) ``` ## Error Handling When a handler fails, evlog reads the `Cause`. The primary error, an expected failure before a defect as Effect's `Cause.squash` ranks them, becomes the event's `error`. Any further failures and defects go under `error.suppressed` as `{ name, message }`. A unit that was only interrupted gets `status: 499`, evlog's cancelled-request status, and no `error`. A failure that is not an `Error` is recorded by its `_tag` (or as `UnknownError`) without copying its other properties, so a failure value carrying a prompt or a payload does not leak it into the event. The event's `status` is the one the client received. Effect's router does not read `status` off an error, so a `createError({ status: 402 })` that reaches it is answered with `500`. To answer with the error's own status, catch it in the handler, record it, and build the response with `parseError()`: ```typescript [src/routes/checkout.ts] import { Effect } from 'effect' import { HttpRouter, HttpServerResponse } from 'effect/http' import { createError, parseError } from 'evlog' import { EvlogLogger } from 'evlog/effect' const respondWithError = (error: unknown) => Effect.gen(function* () { const log = yield* EvlogLogger log.error(error instanceof Error ? error : String(error)) const parsed = parseError(error) return HttpServerResponse.jsonUnsafe( { message: parsed.message, why: parsed.why, fix: parsed.fix, link: parsed.link }, { status: parsed.status }, ) }) export const CheckoutRoute = HttpRouter.add('POST', '/checkout', Effect.gen(function* () { const log = yield* EvlogLogger log.set({ cart: { items: 3, total: 9999 } }) return yield* Effect.fail(createError({ message: 'Payment failed', status: 402, why: 'Card declined by issuer', fix: 'Try a different payment method', link: 'https://docs.example.com/payments/declined', })) }).pipe(Effect.catch(respondWithError))) ``` ```bash [Terminal output] 16:03:30.452 ERROR [my-api] POST /checkout 402 in 2ms ├─ error: Payment failed │ Why: Card declined by issuer │ Fix: Try a different payment method │ More: https://docs.example.com/payments/declined ├─ requestId: ecb06410-... ├─ traceId: 74b88b043b5093c642d81d4aa6c54ab8 └─ cart: items=3 total=9999 ``` A route that does not exist is recorded as `404`, with the router's `HttpServerError` as the error. **HttpApi** encodes an endpoint's declared errors into responses before the router middleware sees them. The event gets that response's status (a declared `UserNotFound` with `httpApiStatus: 404` is recorded as `404`) but no `error` field. Defects and failures the endpoint does not declare are recorded as `error`. Call `log.error()` in the handler when a declared error should carry its details. **Streamed responses** emit their event when the handler returns the `HttpServerResponse`, not when a streamed body finishes, so the stream's duration and any error while streaming are not on the event. ## Jobs, queues and alarms Work that is not an HTTP request becomes its own wide event with `withLogger`. It provides `EvlogLogger`, runs the effect in a span named after the action, and emits when the effect succeeds, fails or is interrupted: ```typescript [src/jobs/digest.ts] import { Effect } from 'effect' import { EvlogLogger, withLogger } from 'evlog/effect' export const sendDigest = Effect.gen(function* () { const log = yield* EvlogLogger const users = yield* Users.subscribed() log.set({ digest: { recipients: users.length } }) yield* Mailer.sendDigest(users) }).pipe(withLogger('send-digest', { fields: { job: { queue: 'mail' } } })) ``` The event carries `action` in place of `method` and `path`. Run inside a request, the job's event shares the request's `traceId` and records the request span as its `parentSpanId`: ```bash [Terminal output] 16:03:30.461 INFO [my-api] in 4ms ├─ action: send-digest ├─ traceId: 23e560634c887a4a598c8ab52a044397 ├─ spanId: bdbe7c5b55e6b542 ├─ parentSpanId: 8858cf5e94ca2ad9 ├─ job: queue=mail └─ digest: recipients=128 ``` `withLogger(action, options)` takes `drain`, `enrich`, `keep`, `redact`, `plugins` and `waitUntil` as `routerLayer` does, plus:
Option Type Effect on the event
fields Record Fields the event starts with
traceparent string Continue this W3C trace instead of the current span. A malformed value starts a new trace
tracestate string Forwarded on outgoing requests next to traceparent
Pass the `traceparent` a queue message carries to continue the trace of the request that enqueued it. ## Cloudflare Workers and Durable Objects `HttpRouter.toWebHandler` turns the routes into a Fetch handler. Drains need the request's `waitUntil` to outlive the response, so provide it per request through the handler's context argument: ```typescript [src/worker.ts] import { Context, Layer } from 'effect' import { HttpRouter } from 'effect/http' import { WaitUntil, routerLayer } from 'evlog/effect' import { createAxiomDrain } from 'evlog/axiom' import { initWorkersLogger } from 'evlog/workers' initWorkersLogger({ env: { service: 'my-worker' } }) const { handler } = HttpRouter.toWebHandler( Routes.pipe(Layer.provide(routerLayer({ drain: createAxiomDrain() }))), { disableLogger: true }, ) export default { fetch(request: Request, _env: Env, ctx: ExecutionContext) { return handler(request, Context.make(WaitUntil, ctx.waitUntil.bind(ctx))) }, } ``` Outside a router, `Effect.provideService(WaitUntil, fn)` sets it for any effect. Without `WaitUntil`, the unit awaits its drains before it settles. A Durable Object `fetch` receives a raw `Request`. `withRequestLogger` wraps an effect that produces the `Response` and records it like a router request: method, path, status, and a failure's status taken from the error (`status` / `statusCode`), or `500`. An alarm has no request, so it uses `withLogger`, and since no `WaitUntil` is provided there, the alarm awaits its drains before it returns: ```typescript [src/room.ts] import { DurableObject } from 'cloudflare:workers' import { Effect } from 'effect' import { withRequestLogger, withLogger } from 'evlog/effect' export class Room extends DurableObject { fetch(request: Request) { return Effect.runPromise(join(request).pipe(withRequestLogger(request))) } alarm() { return Effect.runPromise(sweepIdleMembers.pipe(withLogger('room-alarm'))) } } ``` ## Follow one action across services Each unit continues an incoming W3C `traceparent` and keeps its `tracestate`, so a request that crosses a gateway, a Durable Object and two services produces one event per hop with the same `traceId`, and each event's `parentSpanId` points at the span that made the call. Effect's `HttpClient` already sends `traceparent` for the current span. `withTracePropagation` adds the received `tracestate` to every request of a client: ```typescript [src/services/search.ts] import { Effect } from 'effect' import { HttpClient } from 'effect/http' import { withTracePropagation } from 'evlog/effect' export const search = (query: string) => Effect.gen(function* () { const client = withTracePropagation(yield* HttpClient.HttpClient) return yield* client.get(`https://search.internal/q?${new URLSearchParams({ query })}`) }) ``` Calls that do not go through `HttpClient`, such as a Durable Object stub `fetch`, take the headers from `traceHeaders`: ```typescript [src/routes/messages.ts] import { Effect } from 'effect' import { traceHeaders } from 'evlog/effect' export const postMessage = (stub: DurableObjectStub, body: string) => Effect.gen(function* () { const headers = yield* traceHeaders return yield* Effect.promise(() => stub.fetch('https://room/messages', { method: 'POST', headers, body })) }) ``` ## Fold Effect.log into the event `Effect.logInfo` and friends print a separate line per call by default. `loggerLayer()` sends those calls to the current wide event instead, through the evlog method of the same severity (`fatal`, `error`, `warn`, `info`, `debug`, `trace`), so `Effect.logError` raises the event's level. Messages use evlog's printf-style formatting (`%s`, `%d`, `%j`), and `Effect.annotateLogs` annotations merge into the event as fields. Outside a unit, a call goes to the loggers that were installed before `loggerLayer()`, so your existing logger keeps working. ```typescript [src/index.ts] import { loggerLayer } from 'evlog/effect' const Server = HttpRouter.serve(App, { disableLogger: true }).pipe( Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })), Layer.provide(loggerLayer()), ) ``` ```bash [Terminal output] 16:03:30.461 INFO [my-api] in 4ms ├─ action: send-digest ├─ digest: recipients=128 └─ requestLogs: [{"level":"info","message":"digest rendered","timestamp":"..."}] ``` ## Drain & Enrichers `routerLayer` takes the same options as every evlog integration. Set `drain` and `enrich` in `evlog.config.ts` so `toMiddlewareOptions(config)` passes them, or inline. An inline key replaces the one from the file: ```typescript [src/index.ts] import { createAxiomDrain } from 'evlog/axiom' import { createUserAgentEnricher } from 'evlog/enrichers' import { routerLayer } from 'evlog/effect' const userAgent = createUserAgentEnricher() const EvlogLive = routerLayer({ drain: createAxiomDrain(), enrich: (ctx) => { userAgent(ctx) ctx.event.region = process.env.FLY_REGION }, }) ``` `withLogger` and `withRequestLogger` take their own `drain` and `enrich`. A unit without a `drain` uses the one given to `initLogger`. ### Pipeline (Batching & Retry) A `createDrainPipeline` drain buffers events, so it has to be flushed before the process exits. Give it to `initLoggerLayer`: the layer flushes it when its scope closes, which `NodeRuntime.runMain` does on shutdown. `routerLayer` and `withLogger` fall back to it when they have no `drain` of their own: ```typescript [src/index.ts] import type { DrainContext } from 'evlog' import { createAxiomDrain } from 'evlog/axiom' import { initLoggerLayer } from 'evlog/effect' import { createDrainPipeline } from 'evlog/pipeline' const pipeline = createDrainPipeline({ batch: { size: 50, intervalMs: 5000 }, retry: { maxAttempts: 3 }, }) const EvlogLive = initLoggerLayer({ env: { service: 'my-api' }, drain: pipeline(createAxiomDrain()), }) ``` See the [pipeline docs](https://www.evlog.dev/extend/drain-pipeline) for every option. ## Tail Sampling Use `keep` to force-retain an event regardless of head sampling. It runs after the unit settles, with its duration, status and fields: ```typescript [src/index.ts] routerLayer({ keep: (ctx) => { if (ctx.duration && ctx.duration > 2000) ctx.shouldKeep = true }, }) ``` `withLogger` takes the same `keep`, so a slow job is kept the way a slow request is. See [Sampling](https://www.evlog.dev/learn/sampling). ## Route Filtering Control which requests become events with `include` and `exclude`, and assign a service per route with `routes`. An excluded route still runs: its handlers get a no-op `EvlogLogger`, and nothing is emitted. ```typescript [src/index.ts] routerLayer({ include: ['/api/**'], exclude: ['/_internal/**', '/health'], routes: { '/api/auth/**': { service: 'auth-service' }, '/api/payment/**': { service: 'payment-service' }, }, }) ``` ## Redaction `redact` masks PII and secrets in every event before it reaches a drain, including fields set from Effect code. Pass `redact: true` for the built-in patterns, or list paths to drop content fields such as prompts and message bodies: ```typescript [evlog.config.ts] import { defineEvlog } from 'evlog' export default defineEvlog({ service: 'my-api', redact: { paths: ['prompt', '**.content'] }, }) ``` See [Redaction](https://www.evlog.dev/learn/redaction) for the patterns and path syntax. ## Run Locally ```bash [Terminal] git clone https://github.com/evloghq/evlog.git cd evlog pnpm install pnpm example effect ``` Open [http://localhost:3000](http://localhost:3000) to explore the interactive test UI. Browse the complete Effect 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 --- - [Source Code](https://github.com/evloghq/evlog/tree/main/examples/effect) # Elysia > The plugin derives `log` into every route context, so an Elysia handler reads it as a parameter and the wide event closes with the response. 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. Set up evlog in my Elysia app. - Install evlog: pnpm add evlog - Create evlog.config.ts with export default defineEvlog({ service: 'my-api' }) - Call initLogger(toLoggerConfig(config)) at startup, importing config from evlog.config.ts - Import evlog from 'evlog/elysia' and add .use(evlog(toMiddlewareOptions(config))) 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 - Put drain, enrich, include and keep in evlog.config.ts; toMiddlewareOptions(config) passes them to evlog() Docs: [https://www.evlog.dev/integrate/frameworks/elysia](https://www.evlog.dev/integrate/frameworks/elysia) Adapters: [https://www.evlog.dev/integrate/adapters/overview](https://www.evlog.dev/integrate/adapters/overview) ## Quick Start ### 1. Install ```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. Create evlog.config.ts ```typescript [evlog.config.ts] import { defineEvlog } from 'evlog' export default defineEvlog({ service: 'my-api', }) ``` Every option lives in this file: sampling, drain, `enrich`, route filters. `npx evlog init` writes it, and the wiring below, for you. See [Configuration](https://www.evlog.dev/reference/configuration). ### 3. Initialize and register the plugin ```typescript [src/index.ts] import { Elysia } from 'elysia' import { initLogger, toLoggerConfig, toMiddlewareOptions } from 'evlog' import { evlog } from 'evlog/elysia' import config from '../evlog.config' initLogger(toLoggerConfig(config)) const app = new Elysia() .use(evlog(toMiddlewareOptions(config))) .get('/health', ({ log }) => { log.set({ route: 'health' }) return { ok: true } }) .listen(3000) ``` **Using Vite?** The [`evlog/vite` plugin](https://www.evlog.dev/reference/vite-plugin) strips `log.debug()` from production builds and injects source locations. It reads `evlog.config.ts` too, so the service and the other serializable settings come from the file. It cannot carry the drain, so keep the `initLogger(toLoggerConfig(config))` call: it runs after the plugin's auto-init and replaces it. 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 [After emit](https://www.evlog.dev/learn/wide-events#after-emit-sealing-and-background-work) in Wide events. ```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 Every option goes in `evlog.config.ts`. See the [Configuration reference](https://www.evlog.dev/reference/configuration) for the full list. ## Drain & Enrichers Set `drain` and `enrich` in `evlog.config.ts` so they reach the app through `toMiddlewareOptions(config)`. You can also pass them inline in the plugin options; an inline key replaces the one from the file instead of merging with it: ```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 })) ``` 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 }) }) ``` 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/evloghq/evlog.git cd evlog pnpm install pnpm example elysia ``` Open [http://localhost:3000](http://localhost:3000) to explore the interactive test UI. 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 --- - [Source Code](https://github.com/evloghq/evlog/tree/main/examples/elysia) # Express > One `app.use(evlog())` puts a request logger on `req.log`, emits the wide event when the response finishes, and feeds your error handler. The `evlog/express` middleware auto-creates a request-scoped logger on `req.log` and emits a wide event when the response finishes. Set up evlog in my Express app. - Install evlog: pnpm add evlog - Create evlog.config.ts with export default defineEvlog({ service: 'my-api' }) - Call initLogger(toLoggerConfig(config)) at startup, importing config from evlog.config.ts - Import evlog middleware from 'evlog/express' and add app.use(evlog(toMiddlewareOptions(config))) - 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 - Put drain, enrich, include and keep in evlog.config.ts; toMiddlewareOptions(config) passes them to evlog() Docs: [https://www.evlog.dev/integrate/frameworks/express](https://www.evlog.dev/integrate/frameworks/express) Adapters: [https://www.evlog.dev/integrate/adapters/overview](https://www.evlog.dev/integrate/adapters/overview) ## Quick Start ### 1. Install ```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. Create evlog.config.ts ```typescript [evlog.config.ts] import { defineEvlog } from 'evlog' export default defineEvlog({ service: 'my-api', }) ``` Every option lives in this file: sampling, drain, `enrich`, route filters. `npx evlog init` writes it, and the wiring below, for you. See [Configuration](https://www.evlog.dev/reference/configuration). ### 3. Initialize and register the middleware ```typescript [src/index.ts] import express from 'express' import { initLogger, toLoggerConfig, toMiddlewareOptions } from 'evlog' import { evlog } from 'evlog/express' import config from '../evlog.config' initLogger(toLoggerConfig(config)) const app = express() app.use(evlog(toMiddlewareOptions(config))) app.get('/health', (req, res) => { req.log.set({ route: 'health' }) res.json({ ok: true }) }) app.listen(3000) ``` **Using Vite?** The [`evlog/vite` plugin](https://www.evlog.dev/reference/vite-plugin) strips `log.debug()` from production builds and injects source locations. It reads `evlog.config.ts` too, so the service and the other serializable settings come from the file. It cannot carry the drain, so keep the `initLogger(toLoggerConfig(config))` call: it runs after the plugin's auto-init and replaces it. 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 the forked logger, which emits its own event with `operation` and `_parentRequestId`. See [After emit](https://www.evlog.dev/learn/wide-events#after-emit-sealing-and-background-work) in Wide events. ```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 Every option goes in `evlog.config.ts`. See the [Configuration reference](https://www.evlog.dev/reference/configuration) for the full list. ## Drain & Enrichers Set `drain` and `enrich` in `evlog.config.ts` so they reach the app through `toMiddlewareOptions(config)`. You can also pass them inline in the middleware options; an inline key replaces the one from the file instead of merging with it: ```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 })) ``` 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) }) ``` 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/evloghq/evlog.git cd evlog pnpm install pnpm example express ``` Open [http://localhost:3000](http://localhost:3000) to explore the interactive test UI. 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 --- - [Source Code](https://github.com/evloghq/evlog/tree/main/examples/express) # Fastify > The plugin shadows Fastify's built-in pino logger on `request.log` and emits one wide event from the onRequest, onResponse and onError hooks. The `evlog/fastify` plugin auto-creates a request-scoped logger accessible via `request.log` and `useLogger()`, emitting a wide event when the response completes. Set up evlog in my Fastify app. - Install evlog: pnpm add evlog - Create evlog.config.ts with export default defineEvlog({ service: 'my-api' }) - Call initLogger(toLoggerConfig(config)) at startup, importing config from evlog.config.ts - Import evlog from 'evlog/fastify' and register with app.register(evlog, toMiddlewareOptions(config)) - Access the logger via request.log in route handlers or useLogger() anywhere - Use log.set() to accumulate context throughout the request - Put drain, enrich, include and keep in evlog.config.ts; pass toMiddlewareOptions(config) when registering Docs: [https://www.evlog.dev/integrate/frameworks/fastify](https://www.evlog.dev/integrate/frameworks/fastify) Adapters: [https://www.evlog.dev/integrate/adapters/overview](https://www.evlog.dev/integrate/adapters/overview) ## Quick Start ### 1. Install ```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. Create evlog.config.ts ```typescript [evlog.config.ts] import { defineEvlog } from 'evlog' export default defineEvlog({ service: 'my-api', }) ``` Every option lives in this file: sampling, drain, `enrich`, route filters. `npx evlog init` writes it, and the wiring below, for you. See [Configuration](https://www.evlog.dev/reference/configuration). ### 3. Initialize and register the plugin ```typescript [src/index.ts] import Fastify from 'fastify' import { initLogger, toLoggerConfig, toMiddlewareOptions } from 'evlog' import { evlog } from 'evlog/fastify' import config from '../evlog.config' initLogger(toLoggerConfig(config)) const app = Fastify({ logger: false }) await app.register(evlog, toMiddlewareOptions(config)) app.get('/health', async (request) => { request.log.set({ route: 'health' }) return { ok: true } }) await app.listen({ port: 3000 }) ``` **Using Vite?** The [`evlog/vite` plugin](https://www.evlog.dev/reference/vite-plugin) strips `log.debug()` from production builds and injects source locations. It reads `evlog.config.ts` too, so the service and the other serializable settings come from the file. It cannot carry the drain, so keep the `initLogger(toLoggerConfig(config))` call: it runs after the plugin's auto-init and replaces it. `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 [After emit](https://www.evlog.dev/learn/wide-events#after-emit-sealing-and-background-work) in Wide events. ```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 Every option goes in `evlog.config.ts`. See the [Configuration reference](https://www.evlog.dev/reference/configuration) for the full list. ## Drain & Enrichers Set `drain` and `enrich` in `evlog.config.ts` so they reach the app through `toMiddlewareOptions(config)`. You can also pass them inline in the plugin options; an inline key replaces the one from the file instead of merging with it: ```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 }) ``` 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/evloghq/evlog.git cd evlog pnpm install pnpm example fastify ``` Open [http://localhost:3000](http://localhost:3000) to explore the interactive test UI. 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 --- - [Source Code](https://github.com/evloghq/evlog/tree/main/examples/fastify) # Hono > One middleware puts a typed logger on `c.get('log')`, defers the event until a streamed body closes, and uses `waitUntil` where the runtime has it. 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. Set up evlog in my Hono app. - Install evlog: pnpm add evlog - Create evlog.config.ts with export default defineEvlog({ service: 'my-api' }) - Call initLogger(toLoggerConfig(config)) at startup, importing config from evlog.config.ts - Import evlog middleware and EvlogVariables type from 'evlog/hono' - Add app.use(evlog(toMiddlewareOptions(config))) and type the app with Hono - 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 - Put drain, enrich, include and keep in evlog.config.ts; toMiddlewareOptions(config) passes them to evlog() Docs: [https://www.evlog.dev/integrate/frameworks/hono](https://www.evlog.dev/integrate/frameworks/hono) Adapters: [https://www.evlog.dev/integrate/adapters/overview](https://www.evlog.dev/integrate/adapters/overview) ## Quick Start ### 1. Install ```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. Create evlog.config.ts ```typescript [evlog.config.ts] import { defineEvlog } from 'evlog' export default defineEvlog({ service: 'my-api', }) ``` Every option lives in this file: sampling, drain, `enrich`, route filters. `npx evlog init` writes it, and the wiring below, for you. See [Configuration](https://www.evlog.dev/reference/configuration). ### 3. Initialize and register the middleware ```typescript [src/index.ts] import { serve } from '@hono/node-server' import { Hono } from 'hono' import { initLogger, toLoggerConfig, toMiddlewareOptions } from 'evlog' import { evlog, type EvlogVariables } from 'evlog/hono' import config from '../evlog.config' initLogger(toLoggerConfig(config)) const app = new Hono() app.use(evlog(toMiddlewareOptions(config))) app.get('/health', (c) => { c.get('log').set({ route: 'health' }) return c.json({ ok: true }) }) serve({ fetch: app.fetch, port: 3000 }) ``` **Using Vite?** The [`evlog/vite` plugin](https://www.evlog.dev/reference/vite-plugin) strips `log.debug()` from production builds and injects source locations. It reads `evlog.config.ts` too, so the service and the other serializable settings come from the file. It cannot carry the drain, so keep the `initLogger(toLoggerConfig(config))` call: it runs after the plugin's auto-init and replaces it. 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, so pick whichever reads better at the call site. `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 forked 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 [After emit](https://www.evlog.dev/learn/wide-events#after-emit-sealing-and-background-work) in Wide events. ## 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 Every option goes in `evlog.config.ts`. See the [Configuration reference](https://www.evlog.dev/reference/configuration) for the full list. ## Drain & Enrichers Set `drain` and `enrich` in `evlog.config.ts` so they reach the app through `toMiddlewareOptions(config)`. You can also pass them inline in the middleware options; an inline key replaces the one from the file instead of merging with it: ```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 })) ``` 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) }) ``` 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/evloghq/evlog.git cd evlog pnpm install pnpm example hono ``` Open [http://localhost:3000](http://localhost:3000) to explore the interactive test UI. 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 --- - [Source Code](https://github.com/evloghq/evlog/tree/main/examples/hono) # NestJS > `EvlogModule.forRoot()` registers the middleware, the exception filter and `useLogger()` across a NestJS app, with async config where you need it. 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. Set up evlog in my NestJS app. - Install evlog: pnpm add evlog - Create evlog.config.ts with export default defineEvlog({ service: 'my-api' }) - Import EvlogModule from 'evlog/nestjs' and add EvlogModule.forRoot(toMiddlewareOptions(config)) to AppModule imports - Call initLogger(toLoggerConfig(config)) in main.ts - 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: [https://www.evlog.dev/integrate/frameworks/nestjs](https://www.evlog.dev/integrate/frameworks/nestjs) Adapters: [https://www.evlog.dev/integrate/adapters/overview](https://www.evlog.dev/integrate/adapters/overview) ## Quick Start ### 1. Install ```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. Create evlog.config.ts ```typescript [evlog.config.ts] import { defineEvlog } from 'evlog' export default defineEvlog({ service: 'my-api', }) ``` Every option lives in this file: sampling, drain, `enrich`, route filters. `npx evlog init` writes it, and the wiring below, for you. See [Configuration](https://www.evlog.dev/reference/configuration). ### 3. Register the module ```typescript [src/app.module.ts] import { Module } from '@nestjs/common' import { toMiddlewareOptions } from 'evlog' import { EvlogModule } from 'evlog/nestjs' import config from '../evlog.config' @Module({ imports: [ EvlogModule.forRoot(toMiddlewareOptions(config)), ], }) export class AppModule {} ``` ### 4. Bootstrap with evlog ```typescript [src/main.ts] import 'reflect-metadata' import { NestFactory } from '@nestjs/core' import { initLogger, toLoggerConfig } from 'evlog' import { AppModule } from './app.module' import config from '../evlog.config' initLogger(toLoggerConfig(config)) 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. ## Fastify Platform The module works the same on `@nestjs/platform-fastify`. Install it in place of `@nestjs/platform-express` and pass the adapter to `NestFactory.create()`: ```typescript [src/main.ts] import 'reflect-metadata' import { NestFactory } from '@nestjs/core' import { FastifyAdapter } from '@nestjs/platform-fastify' import type { NestFastifyApplication } from '@nestjs/platform-fastify' import { initLogger } from 'evlog' import { AppModule } from './app.module' initLogger({ env: { service: 'my-api' }, }) const app = await NestFactory.create(AppModule, new FastifyAdapter()) await app.listen(3000) ``` Nest runs middleware on the raw Node request, so on Fastify the module also registers a `preHandler` hook that sets the evlog logger on `FastifyRequest.log`, in place of Fastify's own request logger. Guards, interceptors, pipes, and controllers all get the evlog logger from `request.log`. Fastify `onRequest` hooks run before it and still see Fastify's logger. Do not register the `evlog/fastify` plugin on the same app: each one emits its own wide event, so every request is logged twice. ## 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 [After emit](https://www.evlog.dev/learn/wide-events#after-emit-sealing-and-background-work) in Wide events. ```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 Every option goes in `evlog.config.ts`. See the [Configuration reference](https://www.evlog.dev/reference/configuration) for the full list. ## Drain & Enrichers Set `drain` and `enrich` in `evlog.config.ts` so they reach the app through `toMiddlewareOptions(config)`. You can also pass them inline in `EvlogModule.forRoot()`; an inline key replaces the one from the file instead of merging with it: ```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 }) ``` 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/evloghq/evlog.git cd evlog pnpm install pnpm example nestjs ``` Open [http://localhost:3000](http://localhost:3000) to explore the interactive test UI. 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 --- - [Source Code](https://github.com/evloghq/evlog/tree/main/examples/nestjs) # Next.js > Wide events, structured errors, drain pipeline, tail sampling, route-based services, error handling, and client-side logging in Next.js applications. 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. Set up evlog in my Next.js app with wide events and structured errors. - Install evlog: pnpm add evlog - Create evlog.config.ts with export default defineEvlog({ service: 'my-app' }); put sampling, drain, enrich and route filters there - Create lib/evlog.ts with createEvlog(config), importing config from evlog.config.ts, to export withEvlog, useLogger, createError - 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: [https://www.evlog.dev/integrate/frameworks/nextjs](https://www.evlog.dev/integrate/frameworks/nextjs) Adapters: [https://www.evlog.dev/integrate/adapters/overview](https://www.evlog.dev/integrate/adapters/overview) ## Quick Start ### 1. Install ```bash [pnpm] pnpm add evlog ``` ```bash [bun] bun add evlog ``` ```bash [yarn] yarn add evlog ``` ```bash [npm] npm install evlog ``` ### 2. Create evlog.config.ts ```typescript [evlog.config.ts] import { defineEvlog } from 'evlog' export default defineEvlog({ service: 'my-app', }) ``` Every option lives in this file: sampling, drain, `enrich`, route filters. `npx evlog init` writes it for you. See [Configuration](https://www.evlog.dev/reference/configuration). ### 3. Create your evlog instance ```typescript [lib/evlog.ts] import { createEvlog } from 'evlog/next' import config from '../evlog.config' export const { withEvlog, useLogger, log, createError } = createEvlog(config) ``` `createEvlog(config)` starts the logger when `lib/evlog.ts` is first imported, and the routes wrapped with `withEvlog` use the file's route filters, drain, `enrich` and `keep`. To also drain `log.*` calls made outside a request, report unhandled errors, and apply the file's `environment`, start the logger from the file in `instrumentation.ts` as shown in [Start the logger from evlog.config.ts](#start-the-logger-from-evlogconfigts). ### 4. 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) file at the project root for server startup hooks and error reporting. evlog provides `createInstrumentation()` to integrate with this pattern. 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`). `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, and the raw write is not duplicated. evlog's own output passes through the capture, so a drain receives each wide event once. 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: ```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, }) ``` On Vercel, `withEvlog` registers drain work with Next's `after()`, and with a pipeline it also registers `drain.settled()`: the function stays alive until the buffered batch is delivered, or dropped once retries are exhausted. `batch.intervalMs` bounds when the first delivery attempt starts; retry backoff can extend the lifetime past it. If your ingest endpoint is slow enough to hit the default 5s request timeout, raise it (`createAxiomDrain({ timeout: 15000 })`): a timed-out request that actually landed gets retried, and non-idempotent ingest APIs count that twice. ## 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 [After emit](https://www.evlog.dev/learn/wide-events#after-emit-sealing-and-background-work) in Wide events. ```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: ```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 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
### Start the logger from evlog.config.ts Read the file in `lib/evlog.ts` for both the instrumentation and `createEvlog()`: ```typescript [lib/evlog.ts] import { toLoggerConfig } from 'evlog' import { createEvlog } from 'evlog/next' import { createInstrumentation } from 'evlog/next/instrumentation/create' import config from '../evlog.config' export const { register, onRequestError } = createInstrumentation({ ...toLoggerConfig(config), captureOutput: true, }) export const { withEvlog, useLogger, log, createError } = createEvlog(config) ``` ```typescript [instrumentation.ts] import { defineNodeInstrumentation } from 'evlog/next/instrumentation' export const { register, onRequestError } = defineNodeInstrumentation(() => import('./lib/evlog')) ``` `defineNodeInstrumentation` loads `lib/evlog.ts` on Node.js only, so the drains the config imports stay out of the Edge bundle. `register()` starts the logger from the file before any route runs, and `createEvlog()` takes the route filters, `enrich` and `keep` from it. [`evlog init`](https://www.evlog.dev/cli/init#nextjs) writes both files for you. ## 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*'], } ``` 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 its drain: ```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/evloghq/evlog.git cd evlog/examples/nextjs pnpm install pnpm run dev ``` Open [http://localhost:3000](http://localhost:3000) to explore the example. 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 --- - [Source Code](https://github.com/evloghq/evlog/tree/main/examples/nextjs) # Nitro > One module covers Nitro v3 and v2. Reach the request logger with `useLogger(event)`, and set drain and enrichers from Nitro plugins. 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. 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) - Create evlog.config.ts at the app root with export default defineEvlog({ service: 'my-app' }); the module loads it, so call evlog() with no options - 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: [https://www.evlog.dev/integrate/frameworks/nitro](https://www.evlog.dev/integrate/frameworks/nitro) Adapters: [https://www.evlog.dev/integrate/adapters/overview](https://www.evlog.dev/integrate/adapters/overview) ## Quick Start ### 1. Install ```bash [pnpm] pnpm add evlog ``` ```bash [bun] bun add evlog ``` ```bash [yarn] yarn add evlog ``` ```bash [npm] npm install evlog ``` ### 2. Create evlog.config.ts ```typescript [evlog.config.ts] import { defineEvlog } from 'evlog' export default defineEvlog({ service: 'my-app', }) ``` Every option lives in this file: sampling, drain, `enrich`, route filters. `npx evlog init` writes it for you. See [Configuration](https://www.evlog.dev/reference/configuration). ### 3. Add the module ```typescript [nitro.config.ts (v3)] import { defineConfig } from 'nitro' import evlog from 'evlog/nitro/v3' export default defineConfig({ modules: [ evlog(), ], }) ``` ```typescript [nitro.config.ts (v2)] import { defineNitroConfig } from 'nitropack/config' import evlog from 'evlog/nitro' export default defineNitroConfig({ modules: [ evlog(), ], }) ``` The module finds `evlog.config.ts` and loads it, with nothing to import. ## Wide Events Build up context progressively throughout a request with `useLogger(event)`. evlog emits a single wide event when the request completes. ```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 [After emit](https://www.evlog.dev/learn/wide-events#after-emit-sealing-and-background-work) in Wide events. ## Error Handling `createError` produces structured errors with `why`, `fix`, and `link` fields that help both humans and AI agents understand what went wrong. ```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', }) }) ``` In Nitro v3, import `createError` from `evlog/nitro/v3` - it wraps the Nitro error handler. In Nitro v2, import `createError` from `evlog` directly. ## Configuration Every option goes in `evlog.config.ts`. See the [Configuration reference](https://www.evlog.dev/reference/configuration) for the full list. Options passed to `evlog()` in `nitro.config.ts` override the file, merged key by key. The file's `drain`, `enrich` and `keep` are never overridden: they run alongside the `evlog:*` hooks, so an event reaches both. ### Route Filtering Use `include` and `exclude` to control which routes are logged, and `routes` to assign different service names to different route groups: ```typescript [evlog.config.ts] export default defineEvlog({ include: ['/api/**'], exclude: ['/api/health'], routes: { '/api/auth/**': { service: 'auth-service' }, '/api/payment/**': { service: 'payment-service' }, }, }) ``` **Exclusions take precedence.** If a path matches both `include` and `exclude`, it will be excluded. ## Drain & Enrichers Set `drain` and `enrich` in `evlog.config.ts`: ```typescript [evlog.config.ts] import { defineEvlog } from 'evlog' import { createAxiomDrain } from 'evlog/axiom' export default defineEvlog({ service: 'my-app', drain: createAxiomDrain(), enrich: (ctx) => { ctx.event.region = process.env.FLY_REGION }, }) ``` A buffered drain from the file, such as one built with `createDrainPipeline`, is flushed when the server closes. Nitro plugins hooking `evlog:drain` and `evlog:enrich`, as below, still work and run alongside the file: both receive every event. ### 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) }) ``` 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) }) }) ``` 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 [evlog.config.ts] export default defineEvlog({ 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 }) }) ``` 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 # Nuxt > Add the Nuxt module and every server route emits one wide event. `useLogger(event)` and `parseError` are auto-imported, `createError` comes from `evlog`. evlog provides a first-class Nuxt module with auto-imported `useLogger` and `parseError`. Add it to your config and start logging with zero boilerplate. 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 - Create evlog.config.ts at the app root with export default defineEvlog({ service: 'my-app' }); the module loads it - useLogger and parseError are auto-imported, import createError from 'evlog' - Create a server/api route using useLogger(event) and log.set() to build a wide event - Throw errors with createError({ message, status, why, fix }), imported from 'evlog' - Wide events are auto-emitted when each request completes Docs: [https://www.evlog.dev/integrate/frameworks/nuxt](https://www.evlog.dev/integrate/frameworks/nuxt) Adapters: [https://www.evlog.dev/integrate/adapters/overview](https://www.evlog.dev/integrate/adapters/overview) ## Quick Start ### 1. Install ```bash [pnpm] pnpm add evlog ``` ```bash [bun] bun add evlog ``` ```bash [yarn] yarn add evlog ``` ```bash [npm] npm install evlog ``` ### 2. Create evlog.config.ts ```typescript [evlog.config.ts] import { defineEvlog } from 'evlog' export default defineEvlog({ service: 'my-app', }) ``` Every option lives in this file: sampling, drain, `enrich`, route filters. `npx evlog init` writes it for you. See [Configuration](https://www.evlog.dev/reference/configuration). ### 3. Add the module ```typescript [nuxt.config.ts] export default defineNuxtConfig({ modules: ['evlog/nuxt'], }) ``` That's it. The module finds `evlog.config.ts` and loads it, with nothing to import. `useLogger` and `parseError` are auto-imported. `createError` is imported from `evlog`. ## 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. Import it from `evlog`: a bare `createError` in a server route is h3's, and h3 drops any field it does not know. ```typescript [server/api/payment/process.post.ts] import { createError } from 'evlog' 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 } }) ``` Nuxt's error handler automatically catches `EvlogError` and returns a structured JSON response with `why`, `fix`, and `link` fields. Errors thrown with h3's `createError` keep their `data` payload, as they do without the module. ## Configuration See the [Configuration reference](https://www.evlog.dev/reference/configuration) for the full list of shared options (`enabled`, `pretty`, `silent`, `sampling`, middleware options, etc.). Put options in `evlog.config.ts`. The `evlog` key in `nuxt.config.ts` takes the same options and overrides the file, merged key by key. The file's `drain`, `enrich` and `keep` are never overridden: they run alongside the `evlog:*` hooks, so an event reaches both. The browser logger takes `enabled`, `pretty` and `minLevel` from the file unless the `evlog` key or `NUXT_PUBLIC_EVLOG_*` sets them. `console` and `transport` come from `nuxt.config.ts` only. The table below uses the file's keys; in `evlog.config.ts` you can write `service` and `environment` at the top level instead of under `env`.
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
**Dev terminal presets:** `'evlog'` (default) gives one clean signal, evlog-only stack. `'nitro'` gives the wide event context + Nitro Youch stack (evlog prints Why/Fix only). `'both'` gives the full evlog block and the 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 [evlog.config.ts] export default defineEvlog({ include: ['/api/**', '/auth/**'], exclude: [ '/api/_nuxt_icon/**', '/api/_content/**', '/api/health', ], }) ``` **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 [evlog.config.ts] export default defineEvlog({ service: 'default-service', routes: { '/api/auth/**': { service: 'auth-service' }, '/api/payment/**': { service: 'payment-service' }, '/api/booking/**': { service: 'booking-service' }, }, }) ``` ## Drain & Enrichers Set `drain` and `enrich` in `evlog.config.ts`: ```typescript [evlog.config.ts] import { defineEvlog } from 'evlog' import { createAxiomDrain } from 'evlog/axiom' export default defineEvlog({ service: 'my-app', drain: createAxiomDrain(), enrich: (ctx) => { ctx.event.region = process.env.FLY_REGION }, }) ``` A buffered drain from the file, such as one built with `createDrainPipeline`, is flushed when the server closes. Nitro plugins hooking `evlog:drain` and `evlog:enrich`, as below, still work and run alongside the file: both receive every event. ### 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) }) }) ``` 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 } }) }) ``` 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 # oRPC > `withEvlog(handler)` wraps an oRPC handler and the procedure middleware puts the logger on `context.log`, tagging each procedure with its `operation`. `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`. **oRPC v2:** The oRPC maintainers have [announced first-party evlog support in v2](https://github.com/middleapi/orpc/discussions/1293#discussioncomment-17032656) (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. Set up evlog in my oRPC app. - Install evlog: pnpm add evlog - Create evlog.config.ts with export default defineEvlog({ service: 'my-rpc' }) - Call initLogger(toLoggerConfig(config)) at startup, importing config from evlog.config.ts - 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 into ORPCError - Put drain, enrich, include and keep in evlog.config.ts; pass toMiddlewareOptions(config) to withEvlog() Docs: [https://www.evlog.dev/integrate/frameworks/orpc](https://www.evlog.dev/integrate/frameworks/orpc) Adapters: [https://www.evlog.dev/integrate/adapters/overview](https://www.evlog.dev/integrate/adapters/overview) ## Quick Start ### 1. Install ```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. Create evlog.config.ts ```typescript [evlog.config.ts] import { defineEvlog } from 'evlog' export default defineEvlog({ service: 'my-rpc', }) ``` Every option lives in this file: sampling, drain, `enrich`, route filters. `npx evlog init` writes it, and the wiring below, for you. See [Configuration](https://www.evlog.dev/reference/configuration). ### 3. Initialize and wire the wrappers ```typescript [server/orpc.ts] import { os } from '@orpc/server' import { RPCHandler } from '@orpc/server/fetch' import { initLogger, toLoggerConfig, toMiddlewareOptions } from 'evlog' import { evlog, withEvlog, type EvlogOrpcContext } from 'evlog/orpc' import config from '../evlog.config' initLogger(toLoggerConfig(config)) 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), toMiddlewareOptions(config)) 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 }) } ``` **Using Vite?** The [`evlog/vite` plugin](https://www.evlog.dev/reference/vite-plugin) strips `log.debug()` from production builds and injects source locations. It reads `evlog.config.ts` too, so the service and the other serializable settings come from the file. It cannot carry the drain, so keep the `initLogger(toLoggerConfig(config))` call: it runs after the plugin's auto-init and replaces it. `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. ## Background work (`log.fork`) `useLogger()` returns a logger that also supports `fork` for child wide events, so background work runs under its own wide event. See [After emit](https://www.evlog.dev/learn/wide-events#after-emit-sealing-and-background-work) in Wide events. ```typescript [server/services/user.ts] import { useLogger } from 'evlog/orpc' export async function findUser(id: string) { const log = useLogger() log.fork!('background', async () => { const child = useLogger() child.set({ step: 'complete' }) }) const user = await db.findUser(id) log.set({ user: { name: user.name, plan: user.plan } }) return user } ``` ## 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" } } ``` 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 Every option goes in `evlog.config.ts`. See the [Configuration reference](https://www.evlog.dev/reference/configuration) for the full list. ## Drain & Enrichers Set `drain` and `enrich` in `evlog.config.ts` so they reach the app through `toMiddlewareOptions(config)`. You can also pass them inline in the `withEvlog()` options; an inline key replaces the one from the file instead of merging with it: ```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 }) ``` 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/evloghq/evlog.git cd evlog pnpm install pnpm example orpc ``` Open [http://localhost:3000](http://localhost:3000) to explore the interactive test UI. 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 --- - [Source Code](https://github.com/evloghq/evlog/tree/main/examples/orpc) # Framework Integrations > Choose an evlog integration for Nuxt, Next.js, SvelteKit, Hono, Express, or another listed framework. Follow its setup and request-logger access guide. Choose your framework from the table below. `log.set()`, `createError()` and `parseError()` use the same core APIs, while initialization and request-logger access depend on the integration. Some stacks have a module or middleware, others need the manual setup shown in their guide. Every framework reads its options from one [`evlog.config.ts`](https://www.evlog.dev/reference/configuration). Nuxt, Nitro and TanStack Start load it for you; other frameworks import it. 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. ## Pick your framework
Framework Import Type Logger Access Status
Nuxt evlog/nuxt Module useLogger(event) Stable
Next.js evlog/next Factory useLogger() Stable
SvelteKit evlog/sveltekit Hooks event.locals.log / useLogger() Stable
Nitro evlog/nitro Module useLogger(event) Stable
TanStack Start evlog/nitro/v3 Module useRequest().context.log Stable
TanStack Router evlog/nitro/v3 Module Via TanStack Start (uses Nitro v3) Stable
React Router evlog/react-router Middleware context.get(loggerContext) / useLogger() Stable
NestJS evlog/nestjs Module useLogger() Stable
Express evlog/express Middleware req.log / useLogger() Stable
Hono evlog/hono Middleware c.get('log') / useLogger() Stable
Fastify evlog/fastify Plugin request.log / useLogger() Stable
Elysia evlog/elysia Plugin log (context) / useLogger() Stable
oRPC evlog/orpc Handler wrapper + middleware context.log / useLogger() Stable
Effect evlog/effect Layer yield* EvlogLogger / yield* useLogger() Beta
Cloudflare Workers evlog/workers Factory createWorkersLogger() Stable
AWS Lambda evlog Manual createLogger() / createRequestLogger() Guide
Standalone evlog Manual createLogger() / createRequestLogger() Stable
Astro evlog Manual createRequestLogger() Guide
Custom evlog/toolkit Build your own createMiddlewareLogger() Beta
## What differs from one framework to the next Two things differ per framework: how you start evlog, and how a handler reaches the request logger. ### Start evlog
Pattern Frameworks
evlog(options) middleware / plugin Hono, Express, Fastify, Elysia, SvelteKit, React Router
createEvlog(options) factory Next.js
EvlogModule.forRoot() NestJS
routerLayer(options) layer Effect
Module default export Nuxt, Nitro v2/v3
Manual factory Cloudflare Workers ( createWorkersLogger ), Standalone, AWS Lambda, Astro
### Reach the logger from a handler
Pattern Frameworks
useLogger(event) Nuxt, Nitro
useLogger() Next.js, NestJS, Express, Fastify, Elysia, SvelteKit, React Router, Hono
c.get('log') Hono
req.log Express
request.log Fastify
event.locals.log SvelteKit
context.get(loggerContext) React Router
yield* EvlogLogger / yield* useLogger() Effect
createRequestLogger() / createLogger() Standalone, Workers, manual setups
On Hono, use `c.get('log')` inside handlers and `useLogger()` from `evlog/hono` in the layers underneath. See [Hono integration](https://www.evlog.dev/integrate/frameworks/hono). ## Full-stack frameworks Register `evlog/nuxt` in your modules to get auto-imported `useLogger`, `createError`, and `parseError`. `createEvlog()` factory with `withEvlog()` handler wrapper and client provider. Handle and handleError hooks with request-scoped logger on `event.locals.log`. Module for both Nitro v2 and v3 with plugin-based drain and enrichment hooks. Uses Nitro v3 module with async context, so `useLogger()` works inside server functions. Also covers TanStack Router (full-stack mode). Middleware with `context.get(loggerContext)` and `useLogger()` for loaders and services. `EvlogModule.forRoot()` with global middleware, exception filter, and async config. ## Server frameworks Middleware with `req.log` and 4-argument error handler. Middleware with typed `c.get('log')` via `EvlogVariables`. Plugin with `request.log` that shadows Fastify's built-in pino logger. Plugin with `log` in route context via Elysia's `derive`. Handler wrapper + procedure middleware exposing `context.log` and per-procedure `operation`. Beta. `routerLayer` for `HttpRouter` and `HttpApi`, `EvlogLogger` in the Effect context, and `withLogger` for jobs and alarms. Factory for creating request-scoped loggers with Cloudflare-specific context. `initLogger` once per runtime; `createLogger` per invocation (SQS, events, HTTP API). For scripts, CLI tools, queues, and any TypeScript process. Build your own middleware with the evlog toolkit API. 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). ## Strip client logs at build time 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 It works with SvelteKit, Hono through vite-node, and any Vite-powered setup. Under Nuxt the same three options live on the `evlog/nuxt` module, so the plugin does not need to be added by hand. # React Router > Automatic wide events, structured errors, drain adapters, enrichers, and tail sampling in React Router applications. 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. React Router has three [modes](https://reactrouter.com/start/modes): **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. Set up evlog in my React Router app. - Install evlog: pnpm add evlog - Create evlog.config.ts with export default defineEvlog({ service: 'my-api' }) - Call initLogger(toLoggerConfig(config)) at startup, importing config from evlog.config.ts - 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 - Put drain, enrich, include and keep in evlog.config.ts; toMiddlewareOptions(config) passes them to evlog() Docs: [https://www.evlog.dev/integrate/frameworks/react-router](https://www.evlog.dev/integrate/frameworks/react-router) Adapters: [https://www.evlog.dev/integrate/adapters/overview](https://www.evlog.dev/integrate/adapters/overview) ## Quick Start ### 1. Install ```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. Create evlog.config.ts ```typescript [evlog.config.ts] import { defineEvlog } from 'evlog' export default defineEvlog({ service: 'my-api', }) ``` Every option lives in this file: sampling, drain, `enrich`, route filters. `npx evlog init` writes it, and the wiring below, for you. See [Configuration](https://www.evlog.dev/reference/configuration). ### 3. Enable middleware ```typescript [react-router.config.ts] import type { Config } from '@react-router/dev/config' export default { future: { v8_middleware: true, }, } satisfies Config ``` ### 4. Initialize and register the middleware ```typescript [app/root.tsx] import { Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router' import { initLogger, toLoggerConfig, toMiddlewareOptions } from 'evlog' import { evlog } from 'evlog/react-router' import config from '../evlog.config' initLogger(toLoggerConfig(config)) export const middleware: Route.MiddlewareFunction[] = [ evlog(toMiddlewareOptions(config)), ] export default function Root() { return ( ) } ``` ### 5. 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 } } ``` **Using Vite?** The [`evlog/vite` plugin](https://www.evlog.dev/reference/vite-plugin) strips `log.debug()` from production builds and injects source locations. It reads `evlog.config.ts` too, so the service and the other serializable settings come from the file. Keep the `initLogger(toLoggerConfig(config))` call: it runs after the plugin's auto-init and replaces it. 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 [After emit](https://www.evlog.dev/learn/wide-events#after-emit-sealing-and-background-work) in Wide events. ```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 Every option goes in `evlog.config.ts`. See the [Configuration reference](https://www.evlog.dev/reference/configuration) for the full list. ## Drain & Enrichers Set `drain` and `enrich` in `evlog.config.ts` so they reach the app through `toMiddlewareOptions(config)`. You can also pass them inline in the middleware options; an inline key replaces the one from the file instead of merging with it: ```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 }), ] ``` 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/evloghq/evlog.git cd evlog pnpm install pnpm example react-router ``` Open [http://localhost:5173](http://localhost:5173) to explore the interactive test UI. 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 --- - [Source Code](https://github.com/evloghq/evlog/tree/main/examples/react-router) # Standalone TypeScript > Use evlog in standalone TypeScript scripts, CLI tools, queues, cron jobs, and any TypeScript process. 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. 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`). 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: [https://www.evlog.dev/integrate/frameworks/standalone](https://www.evlog.dev/integrate/frameworks/standalone) Adapters: [https://www.evlog.dev/integrate/adapters/overview](https://www.evlog.dev/integrate/adapters/overview) ## Quick Start ### 1. Install ```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() ``` Always call `drain.flush()` before the process exits to ensure all buffered events are sent. **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 A small script can keep its options inline, as above. For anything bigger, move them to an `evlog.config.ts` next to the `package.json`, the file the CLI reads too, and pass it to `initLogger`: ```typescript [src/index.ts] import { initLogger, toLoggerConfig } from 'evlog' import config from '../evlog.config' initLogger(toLoggerConfig(config)) ``` `toLoggerConfig` keeps every option `initLogger` takes, such as the service, sampling, `minLevel`, `redact`, the drain and the plugins. The file's `enrich`, `keep`, `include`, `exclude` and `routes` are framework middleware options, so they do not apply to a script. ## 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, }) ``` See the [Adapters](https://www.evlog.dev/integrate/adapters/overview) docs for all available drain adapters (Axiom, OTLP, PostHog, Sentry, Better Stack). See the full [bun-script example](https://github.com/evloghq/evlog/tree/main/examples/bun-script) for a complete working script. ## Next Steps - [Node.js request logging](https://www.evlog.dev/use-cases/structured-logging-nodejs): apply these primitives in a complete HTTP server and trace success and failure from the response to the log. - [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 > Wire the handle and handleError hooks once, and every SvelteKit request carries a logger on `event.locals.log` until the response ends. 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. Set up evlog in my SvelteKit app. - Install evlog: pnpm add evlog - Create evlog.config.ts with export default defineEvlog({ service: 'my-api' }) - In hooks.server.ts, call initLogger(toLoggerConfig(config)) and export handle and handleError from createEvlogHooks(toMiddlewareOptions(config)) - Optionally add the evlog/vite plugin for debug stripping and source locations (it reads evlog.config.ts too) - 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: [https://www.evlog.dev/integrate/frameworks/sveltekit](https://www.evlog.dev/integrate/frameworks/sveltekit) Adapters: [https://www.evlog.dev/integrate/adapters/overview](https://www.evlog.dev/integrate/adapters/overview) ## Quick Start ### 1. Install ```bash [pnpm] pnpm add evlog ``` ```bash [bun] bun add evlog ``` ```bash [yarn] yarn add evlog ``` ```bash [npm] npm install evlog ``` ### 2. Create evlog.config.ts ```typescript [evlog.config.ts] import { defineEvlog } from 'evlog' export default defineEvlog({ service: 'my-api', }) ``` Every option lives in this file: sampling, drain, `enrich`, route filters. `npx evlog init` writes it for you. See [Configuration](https://www.evlog.dev/reference/configuration). ### 3. Create hooks ```typescript [src/hooks.server.ts] import { initLogger, toLoggerConfig, toMiddlewareOptions } from 'evlog' import { createEvlogHooks } from 'evlog/sveltekit' import config from '../evlog.config' initLogger(toLoggerConfig(config)) export const { handle, handleError } = createEvlogHooks(toMiddlewareOptions(config)) ``` `toLoggerConfig` passes the logger options of the file and `toMiddlewareOptions` the request options, so the sampling, drain, `enrich` and route filters all apply. **Optional:** the [`evlog/vite` plugin](https://www.evlog.dev/reference/vite-plugin) strips `log.debug()` from production builds and injects source locations. It reads `evlog.config.ts` too, so add it without a service and let the file set it. The `initLogger` call above runs after its auto-initialization and replaces it. ### 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 [After emit](https://www.evlog.dev/learn/wide-events#after-emit-sealing-and-background-work) in Wide events. ```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 Every option goes in `evlog.config.ts`. See the [Configuration reference](https://www.evlog.dev/reference/configuration) for the full list. ## Drain & Enrichers Set `drain` and `enrich` in `evlog.config.ts` so they reach the app through `toMiddlewareOptions(config)`. You can also pass them inline in the hooks options; an inline key replaces the one from the file instead of merging with it: ```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 }) ``` 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/evloghq/evlog.git cd evlog pnpm install pnpm example sveltekit ``` Open [http://localhost:5173](http://localhost:5173) to explore the interactive test UI. 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 --- - [Source Code](https://github.com/evloghq/evlog/tree/main/examples/sveltekit) # TanStack Start > Automatic wide events, structured errors, and logging in TanStack Start API routes and server functions. 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. **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. 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 - Create evlog.config.ts at the app root with export default defineEvlog({ service: 'my-app' }); the evlog/nitro/v3 module loads it, so call evlog() with no options - 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: [https://www.evlog.dev/integrate/frameworks/tanstack-start](https://www.evlog.dev/integrate/frameworks/tanstack-start) Adapters: [https://www.evlog.dev/integrate/adapters/overview](https://www.evlog.dev/integrate/adapters/overview) ## Quick Start Starting from a TanStack Start project created with `npm create @tanstack/start@latest`: ### 1. Install ```bash [pnpm] pnpm add evlog ``` ```bash [bun] bun add evlog ``` ```bash [yarn] yarn add evlog ``` ```bash [npm] npm install evlog ``` ### 2. Create evlog.config.ts ```typescript [evlog.config.ts] import { defineEvlog } from 'evlog' export default defineEvlog({ service: 'my-app', }) ``` Every option lives in this file: sampling, drain, `enrich`, route filters. `npx evlog init` writes it for you. See [Configuration](https://www.evlog.dev/reference/configuration). ### 3. 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(), ], }) ``` The `evlog/nitro/v3` module finds `evlog.config.ts` and bundles it into the server, with nothing to import. Enabling `asyncContext` lets you access the request-scoped logger from anywhere in the call stack via `useRequest()`. ### 4. 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. **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-... ``` `useRequest()` is an experimental Nitro v3 feature powered by `AsyncLocalStorage`. It works on Node.js and Bun runtimes. ## Background work `log.fork()` is not available in this integration. The `evlog/nitro/v3` module attaches the logger to the request event and registers no `AsyncLocalStorage` storage of its own, so there is no scope for a child wide event to run in, even with `experimental.asyncContext` enabled (that powers Nitro's `useRequest()`, not `log.fork()`). Await background work inside the handler instead; mutating the logger after the wide event has emitted triggers a post-emit warning. See [After emit](https://www.evlog.dev/learn/wide-events#after-emit-sealing-and-background-work) in Wide events. ## Error Handling Use `createError` for structured errors with `why`, `fix`, and `link` fields: ```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 Every option goes in `evlog.config.ts`. See the [Configuration reference](https://www.evlog.dev/reference/configuration) for the full list. Options passed to `evlog()` in `nitro.config.ts` override the file, merged key by key. The file's `drain`, `enrich` and `keep` are never overridden: they run alongside the `evlog:*` hooks, so an event reaches both. ## Route Filtering Control which routes are logged with `include` and `exclude` in the file: ```typescript [evlog.config.ts] export default defineEvlog({ service: 'my-app', include: ['/api/**'], exclude: ['/_internal/**', '/health'], routes: { '/api/auth/**': { service: 'auth-service' }, '/api/payment/**': { service: 'payment-service' }, }, }) ``` ## Drain & Enrichers Set `drain` and `enrich` in `evlog.config.ts`: ```typescript [evlog.config.ts] import { defineEvlog } from 'evlog' import { createAxiomDrain } from 'evlog/axiom' import { createUserAgentEnricher } from 'evlog/enrichers' const userAgent = createUserAgentEnricher() export default defineEvlog({ service: 'my-app', drain: createAxiomDrain(), enrich: (ctx) => { userAgent(ctx) }, }) ``` A buffered drain from the file, such as one built with `createDrainPipeline`, is flushed when the server closes. Nitro plugins hooking `evlog:drain` and `evlog:enrich` also work and run alongside the file: ```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) }) }) ``` 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) }) ``` 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/evloghq/evlog.git cd evlog/examples/tanstack-start pnpm install pnpm run dev ``` Open [http://localhost:3000](http://localhost:3000) and navigate to the evlog Demo page to test the API endpoints. 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 --- - [Source Code](https://github.com/evloghq/evlog/tree/main/examples/tanstack-start) # Integrate evlog > Pick a framework integration to capture requests, then pick adapters to ship events. Frameworks decide where the logger lives, adapters where events go. 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 map`) to see which entry points are still dark, or [`evlog doctor`](https://www.evlog.dev/cli/doctor) if nothing is showing up. Early; optional, worth a run. Nuxt, Next.js, SvelteKit, Nitro, TanStack Start, NestJS, Express, Hono, Fastify, Elysia, React Router, Cloudflare Workers, AWS Lambda, Astro, Standalone, Custom integration. Cloud destinations (Axiom, OTLP, HyperDX, Sazabi, 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. --- - [Frameworks](https://www.evlog.dev/integrate/frameworks/overview) - [Adapters](https://www.evlog.dev/integrate/adapters/overview) # Access Metadata > Read AI metadata from your handler — persist it, surface it to end-users, bill against it, or stream incremental progress to the client. 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) ``` ## Every field the middleware records 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.prompt First call's params.prompt Formatted text of the prompt sent to the first model call (only when the prompt option is enabled)
ai.output Content / stream chunks Text generated by the last model call (only when the output option is enabled)
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)
--- - [Overview](https://www.evlog.dev/use-cases/ai-sdk/overview) - [Options](https://www.evlog.dev/use-cases/ai-sdk/options) # Options > Configure prompt and output capture (with redaction and truncation), tool input capture, cost estimation per model, and error handling during AI calls. `createAILogger(log, options?)` accepts a single options bag. Every option is opt-in. The defaults stay quiet, and they stay safe: nothing a model was sent or returned reaches your drain until you ask for it by name.
Option Type Default Description
toolInputs boolean | ToolInputsOptions false Capture tool call inputs alongside their names (off by default to avoid leaking sensitive data).
prompt boolean | CaptureOptions false Capture the prompt sent to the first model call as ai.prompt (off by default to avoid leaking sensitive data).
output boolean | CaptureOptions false Capture the text generated by the last model call as ai.output (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, which suits debugging agent behaviour or auditing what data the model reached for. Tool inputs get large, and they carry SQL, API keys and customer PII. Reach for `maxLength` and `transform` before 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. ## Prompt and Output Capture By default, nothing the model was sent or returned reaches your drain. Two independent options turn capture on, one per direction: `prompt` records what was sent, `output` records what came back. Enable one, the other, or both. ```typescript const ai = createAILogger(log, { prompt: true, output: true }) ``` The wide event then carries: - `ai.prompt`: the formatted text of the prompt sent to the **first** model call. One block per message prefixed with its role; text parts are inlined, and other part types become `[tool-call name]`, `[tool-result name]` or `[file]` markers. - `ai.output`: the text generated by the **last** model call, the final answer in a multi-step run. Streamed responses accumulate from the text chunks. Capture only the direction you need: ```typescript const ai = createAILogger(log, { prompt: true }) // ai.prompt only const ai = createAILogger(log, { output: true }) // ai.output only ``` Prompts and outputs can be large, and they carry whatever your users typed. Reach for `maxLength` and `transform` before raw capture in production. ### Truncate long content Each option takes its own `maxLength`: ```typescript const ai = createAILogger(log, { prompt: { maxLength: 500 } }) ``` ### Redact or reshape `transform` receives the captured text and runs before `maxLength`: ```typescript const ai = createAILogger(log, { prompt: { maxLength: 500, transform: text => redact(text), }, }) ```
Sub-option Type Description
maxLength number Truncate captured text exceeding this character length (appends … ).
transform (content) => string Custom transform applied before maxLength .
Prompt and output capture is a middleware capability: it works with `createAILogger` and `createAIMiddleware`. The standalone `createEvlogIntegration` observes telemetry events and has no access to the model call parameters, so `ai.prompt` and `ai.output` stay absent there. ## 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), which suits billing dashboards or warning users before expensive calls. Keep your `cost` map in one file alongside model selection so renaming a model in production also updates pricing. Per-route maps drift the moment two routes disagree about which model they call, so keep one. ## 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, since the middleware only observes. --- - [Overview](https://www.evlog.dev/use-cases/ai-sdk/overview) - [Usage Patterns](https://www.evlog.dev/use-cases/ai-sdk/usage) # AI SDK Integration > Capture token usage, tool calls, model info, and streaming metrics from the Vercel AI SDK into wide events. Wrap your model, get the rest free. `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. 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: [https://www.evlog.dev/use-cases/ai-sdk/overview](https://www.evlog.dev/use-cases/ai-sdk/overview) Adapters: [https://www.evlog.dev/integrate/adapters/overview](https://www.evlog.dev/integrate/adapters/overview) ## Install Add the AI SDK as a dependency: ```bash [pnpm] pnpm add ai ``` ```bash [bun] bun add ai ``` ```bash [yarn] yarn add ai ``` ```bash [npm] npm install ai ``` ## Quick Start Two lines to add, one param to change: ```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 .
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 `streamText`, `generateText`, multi-step agents, RAG, multiple models: every common pattern, ready to copy. Capture tool inputs (with redaction and truncation), enable cost estimation, and handle errors. Read the captured `ai` data inside your handler: persist it, bill against it, or stream it to the client. 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: ```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]src/index.ts import { createAILogger } from 'evlog/ai' app.post('/api/chat', (req, res) => { const ai = createAILogger(req.log) // ... }) ``` ```typescript [Hono]src/index.ts import { createAILogger } from 'evlog/ai' app.post('/api/chat', (c) => { const ai = createAILogger(c.get('log')) // ... }) ``` ```typescript [Fastify]src/index.ts import { createAILogger } from 'evlog/ai' app.post('/api/chat', async (request) => { const ai = createAILogger(request.log) // ... }) ``` ```typescript [NestJS]src/app.module.ts 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](https://www.evlog.dev/use-cases/ai-sdk/usage) - [Options](https://www.evlog.dev/use-cases/ai-sdk/options) - [Access Metadata](https://www.evlog.dev/use-cases/ai-sdk/metadata) - [Deeper Telemetry](https://www.evlog.dev/use-cases/ai-sdk/telemetry) # Deeper Telemetry > Add tool execution timing and total wall time with createEvlogIntegration. Compose with other middleware like supermemory or guardrails. `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 [Nuxt]server/api/agent.post.ts import { useLogger } from 'evlog' 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 } }) ``` ```typescript [Nitro]routes/api/agent.post.ts import { defineHandler } from 'nitro/h3' import { useLogger } from 'evlog/nitro/v3' import { generateText } from 'ai' import { createAILogger, createEvlogIntegration } from 'evlog/ai' export default defineHandler(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 } }) ``` ```typescript [Next.js]app/api/agent/route.ts import { withEvlog, useLogger } from '@/lib/evlog' import { generateText } from 'ai' import { createAILogger, createEvlogIntegration } from 'evlog/ai' export const POST = withEvlog(async () => { const log = useLogger() const ai = createAILogger(log) const result = await generateText({ model: ai.wrap('anthropic/claude-sonnet-4.6'), tools: { getWeather, searchDB }, telemetry: { integrations: [createEvlogIntegration(ai)], }, }) return Response.json({ text: result.text }) }) ``` ```typescript [Hono]src/index.ts import { generateText } from 'ai' import { createAILogger, createEvlogIntegration } from 'evlog/ai' app.post('/api/agent', async (c) => { const log = c.get('log') const ai = createAILogger(log) const result = await generateText({ model: ai.wrap('anthropic/claude-sonnet-4.6'), tools: { getWeather, searchDB }, telemetry: { integrations: [createEvlogIntegration(ai)], }, }) return c.json({ text: result.text }) }) ``` ```typescript [Express]src/index.ts import { generateText } from 'ai' import { createAILogger, createEvlogIntegration } from 'evlog/ai' app.post('/api/agent', async (req, res) => { const log = req.log const ai = createAILogger(log) const result = await generateText({ model: ai.wrap('anthropic/claude-sonnet-4.6'), tools: { getWeather, searchDB }, telemetry: { integrations: [createEvlogIntegration(ai)], }, }) return res.json({ 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 lands on the wide event
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. ## Compose it with the rest `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. --- - [Overview](https://www.evlog.dev/use-cases/ai-sdk/overview) - [Usage Patterns](https://www.evlog.dev/use-cases/ai-sdk/usage) # Usage Patterns > streamText, generateText, multi-step agents, RAG, and multiple models — every common AI SDK pattern wired into evlog. 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 is streaming chat with full observability: ```typescript [Nuxt]server/api/chat.post.ts import { useLogger } from 'evlog' 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() }) ``` ```typescript [Nitro]routes/api/chat.post.ts import { defineHandler } from 'nitro/h3' import { useLogger } from 'evlog/nitro/v3' import { streamText } from 'ai' import { createAILogger } from 'evlog/ai' export default defineHandler(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() }) ``` ```typescript [Next.js]app/api/chat/route.ts import { withEvlog, useLogger } from '@/lib/evlog' import { streamText } from 'ai' import { createAILogger } from 'evlog/ai' export const POST = withEvlog(async (request: Request) => { const log = useLogger() const ai = createAILogger(log) const { messages } = await request.json() 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() }) ``` ```typescript [Hono]src/index.ts import { streamText } from 'ai' import { createAILogger } from 'evlog/ai' app.post('/api/chat', async (c) => { const log = c.get('log') const ai = createAILogger(log) const { messages } = await c.req.json() 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() }) ``` ```typescript [Express]src/index.ts import { streamText } from 'ai' import { createAILogger } from 'evlog/ai' app.post('/api/chat', async (req, res) => { const log = req.log const ai = createAILogger(log) const { messages } = req.body log.set({ action: 'chat', messagesCount: messages.length }) const result = streamText({ model: ai.wrap('anthropic/claude-sonnet-4.6'), messages, onFinish: ({ text }) => { saveConversation(text) }, }) result.pipeTextStreamToResponse(res) }) ``` The middleware never touches your `onFinish` callback, so your code runs as usual. ## generateText Synchronous generation. The middleware captures the result automatically: ```typescript [Nuxt]server/api/summarize.post.ts import { useLogger } from 'evlog' 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 } }) ``` ```typescript [Nitro]routes/api/summarize.post.ts import { defineHandler } from 'nitro/h3' import { useLogger } from 'evlog/nitro/v3' import { generateText } from 'ai' import { createAILogger } from 'evlog/ai' export default defineHandler(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 } }) ``` ```typescript [Next.js]app/api/summarize/route.ts import { withEvlog, useLogger } from '@/lib/evlog' import { generateText } from 'ai' import { createAILogger } from 'evlog/ai' export const POST = withEvlog(async () => { const log = useLogger() const ai = createAILogger(log) const result = await generateText({ model: ai.wrap('anthropic/claude-sonnet-4.6'), prompt: 'Summarize this document', }) return Response.json({ text: result.text }) }) ``` ```typescript [Hono]src/index.ts import { generateText } from 'ai' import { createAILogger } from 'evlog/ai' app.post('/api/summarize', async (c) => { const log = c.get('log') const ai = createAILogger(log) const result = await generateText({ model: ai.wrap('anthropic/claude-sonnet-4.6'), prompt: 'Summarize this document', }) return c.json({ text: result.text }) }) ``` ```typescript [Express]src/index.ts import { generateText } from 'ai' import { createAILogger } from 'evlog/ai' app.post('/api/summarize', async (req, res) => { const log = req.log const ai = createAILogger(log) const result = await generateText({ model: ai.wrap('anthropic/claude-sonnet-4.6'), prompt: 'Summarize this document', }) return res.json({ text: result.text }) }) ``` ## Trace a multi-step agent The middleware fires for each step automatically. Steps, tool calls, and tokens are accumulated across the agent loop: ```typescript [Nuxt]server/api/agent.post.ts import { useLogger } from 'evlog' import { ToolLoopAgent, createAgentUIStreamResponse, stepCountIs } from 'ai' 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, }) }) ``` ```typescript [Nitro]routes/api/agent.post.ts import { defineHandler } from 'nitro/h3' import { useLogger } from 'evlog/nitro/v3' import { ToolLoopAgent, createAgentUIStreamResponse, stepCountIs } from 'ai' import { createAILogger } from 'evlog/ai' export default defineHandler(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, }) }) ``` ```typescript [Next.js]app/api/agent/route.ts import { withEvlog, useLogger } from '@/lib/evlog' import { ToolLoopAgent, createAgentUIStreamResponse, stepCountIs } from 'ai' import { createAILogger } from 'evlog/ai' export const POST = withEvlog(async (request: Request) => { const log = useLogger() const { messages } = await request.json() 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, }) }) ``` ```typescript [Hono]src/index.ts import { ToolLoopAgent, createAgentUIStreamResponse, stepCountIs } from 'ai' import { createAILogger } from 'evlog/ai' app.post('/api/agent', async (c) => { const log = c.get('log') const { messages } = await c.req.json() 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, }) }) ``` ```typescript [Express]src/index.ts import { ToolLoopAgent, pipeAgentUIStreamToResponse, stepCountIs } from 'ai' import { createAILogger } from 'evlog/ai' app.post('/api/agent', async (req, res) => { const log = req.log const { messages } = req.body 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), }) await pipeAgentUIStreamToResponse({ response: res, 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 } } ``` 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 [Nuxt]server/api/rag.post.ts import { useLogger } from 'evlog' import { embed, generateText } from 'ai' 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 } }) ``` ```typescript [Nitro]routes/api/rag.post.ts import { defineHandler } from 'nitro/h3' import { useLogger } from 'evlog/nitro/v3' import { embed, generateText } from 'ai' import { createAILogger } from 'evlog/ai' export default defineHandler(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 } }) ``` ```typescript [Next.js]app/api/rag/route.ts import { withEvlog, useLogger } from '@/lib/evlog' import { embed, generateText } from 'ai' import { createAILogger } from 'evlog/ai' export const POST = withEvlog(async () => { const log = useLogger() 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 Response.json({ text: result.text }) }) ``` ```typescript [Hono]src/index.ts import { embed, generateText } from 'ai' import { createAILogger } from 'evlog/ai' app.post('/api/rag', async (c) => { const log = c.get('log') 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 c.json({ text: result.text }) }) ``` ```typescript [Express]src/index.ts import { embed, generateText } from 'ai' import { createAILogger } from 'evlog/ai' app.post('/api/rag', async (req, res) => { const log = req.log 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 res.json({ 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 }) ``` ## Wrap more than one model 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): ```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 } } ``` ## Pass a model object from any provider `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')) ``` --- - [Overview](https://www.evlog.dev/use-cases/ai-sdk/overview) - [Options](https://www.evlog.dev/use-cases/ai-sdk/options) # Compliance > Integrity, redact presets, GDPR vs append-only, retention windows, and the most common pitfalls when shipping audit logs to production. 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 }, ) ``` **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 credential key names (`password`, `passwordHash`, `token`, `apiKey`, `secret`, `accessToken`, `refreshToken`, `cardNumber`, `cvv`, `ssn`) **at any nesting depth**, including inside `audit.changes.before` / `audit.changes.after`. The [`secrets` built-in](https://www.evlog.dev/learn/redaction) also catches any key ending in `password`, `passwd`, `secret` or `token`, so a key such as `newPassword` is caught even when the preset does not name it. ## 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/evloghq/evlog/issues). ## Retention Retention is a storage-layer concern by design. evlog's audit layer doesn't enforce retention windows because every supported drain already has a stronger, audited mechanism for it. Pick the one matching your drain:
Drain 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. Do not 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. --- - [Drains & Integrity](https://www.evlog.dev/use-cases/audit/pipeline) - [Recipes](https://www.evlog.dev/use-cases/audit/recipes) # Audit Logs > Audit logs as a thin layer over wide events. A tamper-evident trail from one enricher, one drain wrapper, and one helper. 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**. 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: [https://www.evlog.dev/use-cases/audit/overview](https://www.evlog.dev/use-cases/audit/overview) Adapters: [https://www.evlog.dev/integrate/adapters/overview](https://www.evlog.dev/integrate/adapters/overview) ## 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 drains, 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. **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, which is why the schema is reserved and the event is force-kept past sampling. ## 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 }, )) }) ``` ```typescript [Nuxt] 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 [Nitro] import { defineHandler } from 'nitro/h3' import { useLogger } from 'evlog/nitro/v3' export default defineHandler(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]src/index.ts 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]src/index.ts 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]scripts/refund-job.ts 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 drain (FS journal). - Carries `requestId`, `traceId`, `ip`, and `userAgent` automatically via `auditEnricher`. **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 drain 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. ## Composition Each layer is **opt-in and replaceable**. Every node except `log.audit`, `auditEnricher`, and `auditOnly` / `signed` is shared with regular wide events. ## Where to next The `AuditFields` type, action naming conventions, actor types, and idempotency. `log.audit`, `log.audit.deny`, standalone `audit()`, `withAudit`, `defineAuditAction`, `defineAuditCatalog`, and `auditDiff`. `auditEnricher`, `auditOnly`, and `signed` (HMAC and hash-chain) drain wrappers. Integrity, redact presets, GDPR vs append-only, retention, and common pitfalls. FS, Axiom, and Postgres recipes, plus testing with `mockAudit` and the API reference. --- - [Schema](https://www.evlog.dev/use-cases/audit/schema) - [Recording](https://www.evlog.dev/use-cases/audit/recording) - [Drains & Integrity](https://www.evlog.dev/use-cases/audit/pipeline) - [Compliance](https://www.evlog.dev/use-cases/audit/compliance) - [Recipes](https://www.evlog.dev/use-cases/audit/recipes) # Drains & Integrity > auditEnricher to auto-fill request context, auditOnly to route audits to a dedicated sink, and signed for tamper-evident HMAC or hash-chain 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, and auditors and incident responders need at least `requestId` and `ip` to triangulate a recorded action. ## `auditOnly()` **Why filter audits to a separate drain?** Three reasons: **cost** (audit volume is tiny next to product telemetry, so keep them separate and 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, so crash-safe by default): ```typescript auditOnly(createFsDrain({ dir: '.audit' }), { await: true }) ``` The `await: true` flag costs one awaited drain call per request that records an audit: the drain step that carries the audit finishes only once the write is done, instead of firing it and moving on. With `waitUntil` set (Next.js, Cloudflare Workers, Vercel Edge), that step runs after the response and the runtime stays alive until the write lands. An audit recorded inside `log.fork()` is drained by the child, so `await log.fork(...)` when the write must land before the handler returns. 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.
**What signed() actually buys you.** Detection, not prevention. Anyone with write access to the underlying drain 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. 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`. --- - [Recording](https://www.evlog.dev/use-cases/audit/recording) - [Compliance](https://www.evlog.dev/use-cases/audit/compliance) # Recipes & Reference > File system, Axiom, and Postgres recipes for audit logs, plus mockAudit for tests and the full API reference. Pick the recipe that matches your drain, 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 ```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, and a verifier replays the hashes and reports the first mismatch. ## Audit logs to a dedicated Axiom dataset ```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 ```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. ## Test what you recorded `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. ## Every audit export
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. --- - [Compliance](https://www.evlog.dev/use-cases/audit/compliance) - [Adapters](https://www.evlog.dev/integrate/adapters/overview) # Recording Events > log.audit and log.audit.deny, standalone audit(), withAudit, the defineAuditAction and defineAuditCatalog registries, and auditDiff. 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: ```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()`: ```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" } } ``` Standalone `audit()` events have no `requestId`, no `context.ip`, no `userAgent`, because 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}`. ```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. **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: **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. ```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: **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"). ```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. --- - [Schema](https://www.evlog.dev/use-cases/audit/schema) - [Drains & Integrity](https://www.evlog.dev/use-cases/audit/pipeline) # Audit Schema > The AuditFields type, action naming conventions, actor types, idempotency, and how the schema sits inside a regular wide event. `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 ```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 **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, because 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 ``` 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. ## Say who acted **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 ).
## Record how it ended
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, and 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#logauditdeny)). ## Make a retry safe `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, since it is filled in for you. Use the field as the primary key in Postgres / Bigtable / DynamoDB so retries stay safe by construction. ## Tie an action to what caused it
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. --- - [Overview](https://www.evlog.dev/use-cases/audit/overview) - [Recording](https://www.evlog.dev/use-cases/audit/recording) # Client Identity Sync > Mirror the server-side user identity into client-side logs by watching the Better Auth session and calling setIdentity. 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. ## What the browser sends Client-side logs now include the user identity: ```json [Client Log] { "level": "info", "tag": "checkout", "message": "User clicked checkout", "userId": "QBX9tPjJQExWawAbNll75", "userName": "Hugo Richard" } ``` `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 drain when client logs are forwarded to your server, so a single user shows up identified across browser **and** API logs. --- - [Overview](https://www.evlog.dev/use-cases/better-auth/overview) - [Client Logging](https://www.evlog.dev/use-cases/client-logging) # identifyUser > The core building block — extract safe user fields, mask emails, and capture plugin data (organizations, roles, 2FA) from a Better Auth session. `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' }) } } ``` **Safe by default.** Only whitelisted fields are extracted, and 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 [services/checkout.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" } ``` Keep `extend` deterministic, because it runs on every request. Avoid heavy computations or extra database calls inside it; query the data Better Auth already loaded into the session. ## Fields that reach the event
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.
--- - [Overview](https://www.evlog.dev/use-cases/better-auth/overview) - [Middleware](https://www.evlog.dev/use-cases/better-auth/middleware) # createAuthMiddleware > Framework-agnostic factory with route filtering, session timing, and lifecycle hooks. Call it once at startup and reuse across requests. `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.
## Skip the routes that do not need a session 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 and 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, and `exclude` wins over `include`. ## React to identification Use `onIdentify` to react to user identification, for example to 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. 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. ## What happens when auth fails The middleware catches every error from `getSession` and logs nothing, so 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. --- - [Identify User](https://www.evlog.dev/use-cases/better-auth/identify-user) - [Performance](https://www.evlog.dev/use-cases/better-auth/performance) # Better Auth Integration > Identify the user on authenticated requests, so each wide event carries who made it without a manual log.set call. `evlog/better-auth` turns anonymous wide events into identified ones. Every request automatically includes who made it, with no manual `log.set({ user })` needed. ## Before you wire it Use [Better Auth](https://better-auth.com/) 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/evloghq/evlog/tree/main/apps/playground)). ```bash [pnpm] pnpm add better-auth ``` ```bash [bun] bun add better-auth ``` ```bash [yarn] yarn add better-auth ``` ```bash [npm] npm install better-auth ``` 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, and 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: [https://www.evlog.dev/use-cases/better-auth/overview](https://www.evlog.dev/use-cases/better-auth/overview) Adapters: [https://www.evlog.dev/integrate/adapters/overview](https://www.evlog.dev/integrate/adapters/overview) ## Quick Start One middleware, all requests identified: ```typescript [Nuxt]server/middleware/auth-identify.ts 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 [Next.js]app/api/checkout/route.ts 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 [Express]src/index.ts 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 [Hono]src/index.ts 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 [Fastify]src/index.ts 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 [Elysia]src/index.ts 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 [NestJS]src/auth-identify.middleware.ts 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 [Standalone]scripts/sync-job.ts 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: ```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 the session reaches the event 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 The core building block: extract safe fields, mask emails, capture plugin data (organizations, roles, 2FA). Filter routes with `include`/`exclude`, react to identification with lifecycle hooks, and tune behaviour per app. Mirror the user identity into client-side logs with `setIdentity` and the Better Auth client. Watch session resolution time, enable session caching, and combine with the AI SDK integration. ## What the package exports
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 .
maskEmail(email) Mask an email: hugo@example.com → h***@example.com .
--- - [Identify User](https://www.evlog.dev/use-cases/better-auth/identify-user) - [Middleware](https://www.evlog.dev/use-cases/better-auth/middleware) - [Client Sync](https://www.evlog.dev/use-cases/better-auth/client-sync) - [Performance](https://www.evlog.dev/use-cases/better-auth/performance) # Performance & Composition > Watch session resolution time, enable session caching, wire the standalone Nitro hook, and combine with the AI SDK integration. `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** 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. For **Nuxt**, use `createAuthMiddleware` in a server middleware instead, because 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 what a wide event buys you: one event per request, all context in one place: who made the request, what they did, how the AI responded, and how it performed. --- - [Middleware](https://www.evlog.dev/use-cases/better-auth/middleware) - [AI SDK](https://www.evlog.dev/use-cases/ai-sdk/overview) # Client Logging > Capture browser events with structured logging. Same API as the server, with automatic console styling, user identity context, and optional server transport. 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. 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: [https://www.evlog.dev/use-cases/client-logging](https://www.evlog.dev/use-cases/client-logging) HTTP transport: [https://www.evlog.dev/extend/drain-pipeline](https://www.evlog.dev/extend/drain-pipeline) ## Quick Start evlog provides a client-side logging API that works in any browser environment: ```typescript [Nuxt]app/plugins/logger.client.ts 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: `trace` < `debug` < `info` < `warn` < `error` < `fatal`. 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](#sending-logs-to-the-server) payloads. ## Call it two ways 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 ``` The message accepts printf-style interpolation, the same [formatting as server-side logs](https://www.evlog.dev/learn/simple-logging#interpolate-values-into-the-message). When it contains `%s`, `%d`, or `%j` and extra arguments follow, evlog formats it before writing to the console: ```typescript [composables/useCart.ts] log.info('cart', 'Item %s added, cart total %d', 'SKU-42', 9999) ``` ```bash [Browser console] [cart] Item SKU-42 added, cart total 9999 ``` ### Levels you can use Both forms support six levels: `log.fatal()`, `log.info()`, `log.warn()`, `log.error()`, `log.debug()`, and `log.trace()`. 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'`. ## Errors in browser events Errors inside browser events are serialized the same way as on the server. `log.error(err)`, or an `Error` inside an object-form event such as `log.error({ action: 'checkout', error })`, is logged with its `name`, `message`, `stack` and fields, not as `{}`. The rules are in [How errors are logged](https://www.evlog.dev/learn/structured-errors#how-errors-are-logged). With `pretty: true` (the default), the browser console receives the real `Error`, so DevTools shows a live error you can expand. The JSON console output and the transport receive the serialized form. ## Attach who the log belongs to 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: trace < debug < info < warn < error < fatal
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 }, }) ``` `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: ### Ship logs with the built-in drain 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. ```typescript [Nuxt]app/plugins/logger.client.ts import { initLog } from 'evlog/client' export default defineNuxtPlugin(() => { initLog({ service: 'web', transport: { enabled: true, endpoint: '/api/_evlog/ingest', }, }) }) ``` ```tsx [Next.js]app/layout.tsx 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', }, }) ``` 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. The endpoint has no auth: the Nuxt one rejects requests from other origins, which stops other websites but not a script. Treat client events as untrusted input, and rate-limit the route where traffic enters (your platform firewall or a middleware), as the [telemetry ingest](https://www.evlog.dev/use-cases/telemetry/ingest) page does. ### 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. ```typescript [Nuxt]app/plugins/logger.client.ts 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) 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 --- - [HTTP drain](https://www.evlog.dev/extend/drain-pipeline) - [Wide Events](https://www.evlog.dev/learn/wide-events) # Enrichers > Add derived context to every wide event, from user agent and geo to your own computed fields. Enrichers add derived context to your wide events after they are emitted, before they are printed and 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). 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: [https://www.evlog.dev/use-cases/enrichers](https://www.evlog.dev/use-cases/enrichers) Framework setup: [https://www.evlog.dev/integrate/frameworks/overview](https://www.evlog.dev/integrate/frameworks/overview) ```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 [Nuxt]server/plugins/evlog.ts import { createDefaultEnrichers } from 'evlog/enrichers' const enrich = createDefaultEnrichers() export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:enrich', enrich) }) ``` ```typescript [Nitro]server/plugins/evlog.ts import { definePlugin } from 'nitro' import { createDefaultEnrichers } from 'evlog/enrichers' const enrich = createDefaultEnrichers() export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:enrich', enrich) }) ``` ```typescript [Next.js]lib/evlog.ts import { createEvlog } from 'evlog/next' import { createDefaultEnrichers } from 'evlog/enrichers' const enrich = createDefaultEnrichers() export const { withEvlog, useLogger, log, createError } = createEvlog({ service: 'my-app', enrich: enrich, }) ``` ```typescript [SvelteKit]src/hooks.server.ts import { createEvlogHooks } from 'evlog/sveltekit' import { createDefaultEnrichers } from 'evlog/enrichers' const enrich = createDefaultEnrichers() export const { handle, handleError } = createEvlogHooks({ enrich: enrich }) ``` ```typescript [TanStack Start]server/plugins/evlog.ts import { definePlugin } from 'nitro' import { createDefaultEnrichers } from 'evlog/enrichers' const enrich = createDefaultEnrichers() export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:enrich', enrich) }) ``` ```typescript [React Router]app/root.tsx import { evlog } from 'evlog/react-router' import { createDefaultEnrichers } from 'evlog/enrichers' const enrich = createDefaultEnrichers() export const middleware: Route.MiddlewareFunction[] = [ evlog({ enrich: enrich }), ] ``` ```typescript [Hono]src/index.ts import { evlog } from 'evlog/hono' import { createDefaultEnrichers } from 'evlog/enrichers' const enrich = createDefaultEnrichers() app.use(evlog({ enrich: enrich })) ``` ```typescript [Express]src/index.ts import { evlog } from 'evlog/express' import { createDefaultEnrichers } from 'evlog/enrichers' const enrich = createDefaultEnrichers() app.use(evlog({ enrich: enrich })) ``` ```typescript [Fastify]src/index.ts import { evlog } from 'evlog/fastify' import { createDefaultEnrichers } from 'evlog/enrichers' const enrich = createDefaultEnrichers() await app.register(evlog, { enrich: enrich }) ``` ```typescript [Elysia]src/index.ts import { evlog } from 'evlog/elysia' import { createDefaultEnrichers } from 'evlog/enrichers' const enrich = createDefaultEnrichers() app.use(evlog({ enrich: enrich })) ``` ```typescript [NestJS]src/app.module.ts import { EvlogModule } from 'evlog/nestjs' import { createDefaultEnrichers } from 'evlog/enrichers' const enrich = createDefaultEnrichers() EvlogModule.forRoot({ enrich: enrich }) ``` ```typescript [oRPC]server/orpc.ts import { withEvlog } from 'evlog/orpc' import { createDefaultEnrichers } from 'evlog/enrichers' const enrich = createDefaultEnrichers() const handler = withEvlog(new RPCHandler(router), { enrich: enrich }) ``` ```typescript [Cloudflare Workers]src/worker.ts import { withEvlog } from 'evlog/workers' import { createDefaultEnrichers } from 'evlog/enrichers' const enrich = createDefaultEnrichers() export default withEvlog(handler, { enrich: enrich }) ``` ```typescript [Standalone]src/index.ts import { initLogger } from 'evlog' import { createDefaultEnrichers } from 'evlog/enrichers' const enrich = createDefaultEnrichers() initLogger({ enrich: enrich }) ``` Each enricher accepts `{ overwrite?: boolean }` (default `false`) so user-set fields are preserved. ## `userAgentEnricher()` 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). ## `geoEnricher()` 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
**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. ## `requestSizeEnricher()` 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 } } ``` 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`. ## `traceContextEnricher()` Extract W3C trace context from the `traceparent` and `tracestate` headers. **Sets:** `event.traceContext`, `event.traceId`, `event.parentSpanId` ```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 // Span of this request, copied from event.spanId when already set parentSpanId?: string // 16-char hex span ID of the caller (parsed from traceparent) } ``` **Example output:** ```json [Example wide event: traceContext] { "traceContext": { "traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01", "traceId": "4bf92f3577b34da6a3ce929d0e0e4736", "parentSpanId": "00f067aa0ba902b7" }, "traceId": "4bf92f3577b34da6a3ce929d0e0e4736", "parentSpanId": "00f067aa0ba902b7" } ``` `traceId` and `parentSpanId` are also set at the top level of the event for querying and correlation. The span id in an incoming `traceparent` belongs to the service that made the call, not to the request being handled, so the enricher records it as `parentSpanId` and leaves `event.spanId` alone. Set `event.spanId` from your tracer's active span (`trace.getActiveSpan()?.spanContext().spanId` with OpenTelemetry) to link the event to the server span. The traceparent format follows the [W3C Trace Context](https://www.w3.org/TR/trace-context/) specification: `{version}-{traceId}-{spanId}-{flags}`. ## Wire all four at once Use all built-in enrichers together. The list of enrichers is identical across frameworks. Only the wiring changes. ```typescript [Nuxt]server/plugins/evlog-enrich.ts import { createUserAgentEnricher, createGeoEnricher, createRequestSizeEnricher, createTraceContextEnricher, } from 'evlog/enrichers' import { composeEnrichers } from 'evlog/toolkit' export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:enrich', composeEnrichers([ createUserAgentEnricher(), createGeoEnricher(), createRequestSizeEnricher(), createTraceContextEnricher(), ])) }) ``` ```typescript [Nitro]server/plugins/evlog-enrich.ts import { definePlugin } from 'nitro' import { createUserAgentEnricher, createGeoEnricher, createRequestSizeEnricher, createTraceContextEnricher, } from 'evlog/enrichers' import { composeEnrichers } from 'evlog/toolkit' export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:enrich', composeEnrichers([ createUserAgentEnricher(), createGeoEnricher(), createRequestSizeEnricher(), createTraceContextEnricher(), ])) }) ``` ```typescript [Next.js]lib/evlog.ts import { createEvlog } from 'evlog/next' import { createUserAgentEnricher, createGeoEnricher, createRequestSizeEnricher, createTraceContextEnricher, } from 'evlog/enrichers' import { composeEnrichers } from 'evlog/toolkit' export const { withEvlog, useLogger, log, createError } = createEvlog({ service: 'my-app', enrich: composeEnrichers([ createUserAgentEnricher(), createGeoEnricher(), createRequestSizeEnricher(), createTraceContextEnricher(), ]), }) ``` ```typescript [SvelteKit]src/hooks.server.ts import { createEvlogHooks } from 'evlog/sveltekit' import { composeEnrichers } from 'evlog/toolkit' import { createUserAgentEnricher, createGeoEnricher, createRequestSizeEnricher, createTraceContextEnricher, } from 'evlog/enrichers' const enrich = composeEnrichers([ createUserAgentEnricher(), createGeoEnricher(), createRequestSizeEnricher(), createTraceContextEnricher(), ]) export const { handle, handleError } = createEvlogHooks({ enrich }) ``` ```typescript [TanStack Start]server/plugins/evlog-enrich.ts import { definePlugin } from 'nitro' import { createUserAgentEnricher, createGeoEnricher, createRequestSizeEnricher, createTraceContextEnricher, } from 'evlog/enrichers' import { composeEnrichers } from 'evlog/toolkit' export default definePlugin((nitroApp) => { nitroApp.hooks.hook('evlog:enrich', composeEnrichers([ createUserAgentEnricher(), createGeoEnricher(), createRequestSizeEnricher(), createTraceContextEnricher(), ])) }) ``` ```typescript [React Router]app/root.tsx import { evlog } from 'evlog/react-router' import { composeEnrichers } from 'evlog/toolkit' import { createUserAgentEnricher, createGeoEnricher, createRequestSizeEnricher, createTraceContextEnricher, } from 'evlog/enrichers' const enrich = composeEnrichers([ createUserAgentEnricher(), createGeoEnricher(), createRequestSizeEnricher(), createTraceContextEnricher(), ]) export const middleware: Route.MiddlewareFunction[] = [ evlog({ enrich }), ] ``` ```typescript [Hono]src/index.ts import { evlog } from 'evlog/hono' import { composeEnrichers } from 'evlog/toolkit' import { createUserAgentEnricher, createGeoEnricher, createRequestSizeEnricher, createTraceContextEnricher, } from 'evlog/enrichers' const enrich = composeEnrichers([ createUserAgentEnricher(), createGeoEnricher(), createRequestSizeEnricher(), createTraceContextEnricher(), ]) app.use(evlog({ enrich })) ``` ```typescript [Express]src/index.ts import { evlog } from 'evlog/express' import { composeEnrichers } from 'evlog/toolkit' import { createUserAgentEnricher, createGeoEnricher, createRequestSizeEnricher, createTraceContextEnricher, } from 'evlog/enrichers' const enrich = composeEnrichers([ createUserAgentEnricher(), createGeoEnricher(), createRequestSizeEnricher(), createTraceContextEnricher(), ]) app.use(evlog({ enrich })) ``` ```typescript [Fastify]src/index.ts import { evlog } from 'evlog/fastify' import { composeEnrichers } from 'evlog/toolkit' import { createUserAgentEnricher, createGeoEnricher, createRequestSizeEnricher, createTraceContextEnricher, } from 'evlog/enrichers' const enrich = composeEnrichers([ createUserAgentEnricher(), createGeoEnricher(), createRequestSizeEnricher(), createTraceContextEnricher(), ]) await app.register(evlog, { enrich }) ``` ```typescript [Elysia]src/index.ts import { evlog } from 'evlog/elysia' import { composeEnrichers } from 'evlog/toolkit' import { createUserAgentEnricher, createGeoEnricher, createRequestSizeEnricher, createTraceContextEnricher, } from 'evlog/enrichers' const enrich = composeEnrichers([ createUserAgentEnricher(), createGeoEnricher(), createRequestSizeEnricher(), createTraceContextEnricher(), ]) app.use(evlog({ enrich })) ``` ```typescript [NestJS]src/app.module.ts import { EvlogModule } from 'evlog/nestjs' import { composeEnrichers } from 'evlog/toolkit' import { createUserAgentEnricher, createGeoEnricher, createRequestSizeEnricher, createTraceContextEnricher, } from 'evlog/enrichers' const enrich = composeEnrichers([ createUserAgentEnricher(), createGeoEnricher(), createRequestSizeEnricher(), createTraceContextEnricher(), ]) EvlogModule.forRoot({ enrich }) ``` ```typescript [oRPC]server/orpc.ts import { withEvlog } from 'evlog/orpc' import { composeEnrichers } from 'evlog/toolkit' import { createUserAgentEnricher, createGeoEnricher, createRequestSizeEnricher, createTraceContextEnricher, } from 'evlog/enrichers' const enrich = composeEnrichers([ createUserAgentEnricher(), createGeoEnricher(), createRequestSizeEnricher(), createTraceContextEnricher(), ]) const handler = withEvlog(new RPCHandler(router), { enrich }) ``` ```typescript [Cloudflare Workers]src/worker.ts import { withEvlog } from 'evlog/workers' import { composeEnrichers } from 'evlog/toolkit' import { createUserAgentEnricher, createGeoEnricher, createRequestSizeEnricher, createTraceContextEnricher, } from 'evlog/enrichers' const enrich = composeEnrichers([ createUserAgentEnricher(), createGeoEnricher(), createRequestSizeEnricher(), createTraceContextEnricher(), ]) export default withEvlog(handler, { enrich }) ``` ```typescript [Standalone]index.ts import { initLogger } from 'evlog' import { composeEnrichers, enricherPlugin } from 'evlog/toolkit' import { createUserAgentEnricher, createGeoEnricher, createRequestSizeEnricher, createTraceContextEnricher, } from 'evlog/enrichers' initLogger({ plugins: [ enricherPlugin('defaults', composeEnrichers([ 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 --- - [Custom Enrichers](https://www.evlog.dev/extend/custom-enrichers) # eve > One evlog wide event per eve agent turn: token usage, tool executions, and business context, through your own drains and tail sampling. [eve](https://eve.dev/docs/introduction) 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). `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. Add evlog wide events to my eve agent. - Install evlog: pnpm add evlog - Create evlog.config.ts at the project root with defineEvlog from 'evlog': service, drain, enrich, sampling (same options as HTTP middleware integrations) - Create agent/hooks/evlog.ts with defineEvlogHook from 'evlog/eve' and pass it the config: defineEvlogHook({ ...config, /* eve options */ }) - 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: [https://www.evlog.dev/use-cases/eve](https://www.evlog.dev/use-cases/eve) Adapters: [https://www.evlog.dev/integrate/adapters/overview](https://www.evlog.dev/integrate/adapters/overview) ## 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 )
## Quick Start ### 1. Install ```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 Put the agent's settings in `evlog.config.ts` at the project root. It is the same file the [evlog CLI reads](https://www.evlog.dev/cli/config), and its `extends` can pull in a preset your organization shares across apps: ```typescript [evlog.config.ts] import { defineEvlog } from 'evlog' import { createAxiomDrain } from 'evlog/axiom' export default defineEvlog({ service: 'my-agent', drain: createAxiomDrain(), enrich: (ctx) => { ctx.event.region = process.env.VERCEL_REGION }, }) ``` Then create `agent/hooks/evlog.ts` and pass the config to `defineEvlogHook()`: ```typescript [agent/hooks/evlog.ts] import { defineEvlogHook } from 'evlog/eve' import config from '../../evlog.config' export default defineEvlogHook(config) ``` eve auto-discovers hook files under `agent/hooks/`. No HTTP middleware, because the unit of work is an agent **turn**, not a request. The first hook invocation starts the logger from these settings, so an event emitted outside a turn, such as a scheduled job's, reaches the same drain with the same service. Spread the config to add the eve options, and anything after the spread overrides the file: ```typescript [agent/hooks/evlog.ts] export default defineEvlogHook({ ...config, message: 'preview', sessionEvent: true, }) ``` Without a config file, pass the same settings to `defineEvlogHook()` directly. The service defaults to `eve-agent`. `enrich` is HTTP-shaped: it sees the event, never the eve session. To stamp session context onto the turn event, such as a caller detail `eve.caller` does not carry, use `enrichTurn`, which runs once per turn where the turn logger is created: ```typescript [agent/hooks/evlog.ts] export default defineEvlogHook({ enrichTurn: (ctx) => ({ tier: ctx.session.auth.current?.principalId === 'github:1' ? 'pro' : 'free', }), }) ``` Returned fields merge onto the turn event, over the built-in `eve`, `agent` and `channel` fields, and stay turn-scoped: the next turn of the session does not inherit them. A throwing `enrichTurn` is logged and skipped, never fails the turn. ### 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, the same ergonomics as `useLogger(event)` in Nuxt or Hono. In the [example agent](https://github.com/evloghq/evlog/tree/main/examples/eve), 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 }, }) ``` **Resolution:** pass the eve tool `ctx` as `useLogger(ctx)` when AsyncLocalStorage does not propagate, such as separate hook and tool bundles. With a standard eve agent layout and `agent/hooks/evlog.ts` registered, you should not need it. `useLogger()` never throws: when it cannot reach the turn's logger it warns once and returns a logger that accepts every call and emits nothing. A turn resumed in another process after a step boundary is the common case, and losing enrichment must never fail the tool that was being enriched. The turn keeps running; only its extra fields are dropped. ### 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, so tool calls, approvals, and `ask_question` work out of the box. See the [example agent](https://github.com/evloghq/evlog/tree/main/examples/eve). ## 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": { "model": "deepseek/deepseek-v4-flash", "provider": "deepseek", "calls": 2, "steps": 2, "inputTokens": 11724, "outputTokens": 282, "finishReason": "tool-calls", "tools": [ { "name": "lookup_customer", "durationMs": 26, "success": true, "inputTokens": 8900 }, { "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`, and 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 and 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 Subagent and remote agent calls with their name, status ( called , completed or failed ), duration and error message. remote: true marks a remote agent
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
ai.model / ai.provider The gateway slug ( deepseek/deepseek-v4-flash ) and the resolved provider ( deepseek ) that served the turn. The model is the one eve reports on the turn's last step.started , so a gateway fallback shows up, then the model option, then the model eve reports on session.started . ai.provider is split from that model id
ai.tools[].inputTokens The input tokens a tool result added to the next model call: the delta between the step that called the tool and the step that consumed its result. Parallel results in one step share the delta evenly; left unset when the turn ended before the result was consumed, or when a compaction reset the context
## 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`, so 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. **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, since `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#link-a-log-to-a-person-and-a-session-replay). ## Production Long-running eve agents should disable terminal pretty-printing and use a non-blocking drain: ```typescript [evlog.config.ts] import { defineEvlog } from 'evlog' import { createAxiomDrain } from 'evlog/axiom' import { createDrainPipeline } from 'evlog/pipeline' export default defineEvlog({ service: 'my-agent', environment: 'production', pretty: false, sampling: { rates: { info: 10 } }, drain: createDrainPipeline({ batch: { size: 50, intervalMs: 5000 } })( createAxiomDrain(), ), }) ``` ```typescript [agent/hooks/evlog.ts] import { defineEvlogHook } from 'evlog/eve' import config from '../../evlog.config' export default defineEvlogHook({ ...config, maxSessions: 256 }) ```
Concern Recommendation
Terminal output pretty: false , since pretty-print is for local dev only
Drain latency Batch or async HTTP drains; never block the turn on I/O
Head sampling sampling.rates , since 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 `defineEvlogHook()` accepts every runtime setting of [`evlog.config.ts`](https://www.evlog.dev/cli/config), plus the eve options below. `map` and `logs` only drive the CLI, and the hook ignores them.
Option Description
service / environment / sampling / silent / redact Logger settings, applied on the first hook invocation. service defaults to eve-agent
drain / enrich / keep / plugins Same as HTTP integrations ( plugins ). drain and plugins also apply to events emitted outside a turn
init Passed to initLogger() as is, in place of the logger settings. Use it to give events emitted outside a turn their own drain or service
enrichTurn Turn-scoped enrichment with the eve session in scope ( ctx.session.auth ); runs once per turn, returned fields merge onto the turn event
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
subagentEvents false skips subagent lifecycle subscriptions when their event source has no hook session context
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): pass `auditEnricher()` as `enrich` (or call it from your own `enrich`), 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/evloghq/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 **http://localhost:3000**, and click a starter prompt. ## What to read next - [eve hooks guide](https://eve.dev/docs/guides/hooks): stream event vocabulary - [eve instrumentation](https://eve.dev/docs/guides/instrumentation): 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 --- - [Source Code](https://github.com/evloghq/evlog/tree/main/examples/eve) # Use Cases > Recipes that each solve one problem: browser logs, AI SDK calls, user identity, a tamper-evident audit trail, tool telemetry, derived context. 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
Follow a Node.js request from response ID to a JSON event, including a failed payment Node.js request logging
Send browser logs to your server with batching, retries, and sendBeacon fallback Client Logging
Capture every AI SDK call with token usage, tool calls, streaming metrics, and cost AI SDK
Identify the authenticated user (and their org / role) on every wide event Better Auth
Build a tamper-evident audit trail with hash chains, denials, redaction-aware diffs Audit Logs
Instrument CLIs and automation with one wide event per run, consent, and disclosure Telemetry
Ask a decision model one question per event and keep the 200s that were silent failures Signals
Export wide events from eve agent turns (tokens, tools, drains) eve
Add derived context (User-Agent, geo, request size, trace context) to every event 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 the ones your application needs. 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](https://www.evlog.dev/use-cases/client-logging) - [AI SDK](https://www.evlog.dev/use-cases/ai-sdk/overview) - [Audit Logs](https://www.evlog.dev/use-cases/audit/overview) - [Telemetry](https://www.evlog.dev/use-cases/telemetry/overview) - [Signals](https://www.evlog.dev/use-cases/signals/overview) # What You Can Ask > Three question shapes, each with the column it produces: a yes/no with a probability, one option out of a list, or a position on a rubric. Every signal is one question about one event. The shape of the input decides the shape of the answer, and the shape of the answer decides what you can do with the column.
You want to Write You get Do this with it
Flag events ask value: boolean , confidence WHERE signals.x.value = true , or keep the event
Categorise events ask + choice value is one option name, confidence when the model gives one GROUP BY signals.x.value
Rank events ask + score value is a level name, score between levels, confidence when the model gives one ORDER BY signals.x.score DESC
## Flag: a yes/no with a probability The shortest form. The model returns P(yes), and the column reads `value: true, confidence: 0.9`. `criteria` is optional and tells the model what each answer looks like, which raises confidence on borderline events: ```typescript [signals/retryable.ts] import { defineSignal } from '@evlog/signals' export const retryable = defineSignal({ name: 'retryable', when: e => (e.status ?? 0) >= 500, ask: 'Would the same request most likely succeed if retried in a few seconds?', criteria: { true: 'Timeout, connection reset, rate limit, transient upstream error', false: 'Bug, bad data, missing resource', }, }) ``` ```json [column] "retryable": { "value": true, "confidence": 0.9 } ``` This is the shape for `keep`: a retry policy, a "did the customer get what they came for", a "does this deserve a reviewer". The verdict is a boolean, so `keep: v => v.value && v.confidence > 0.8` reads as written. ## Categorise: one option out of a list Name the options, each with the description the model matches against. The column's `value` is one of the keys, which makes it the shape to reach for when the question is "which kind": ```typescript [signals/fault.ts] export const fault = defineSignal({ name: 'fault', when: e => (e.status ?? 0) >= 400, ask: 'Who is responsible for this failure?', choice: { client: 'Bad input, expired session, missing permission, client mistake', app: 'A bug, a misconfiguration or a validation error in our own code', upstream: 'A third-party dependency failed, timed out or rate-limited us', }, }) ``` ```json [column] "fault": { "value": "upstream", "confidence": 0.93 } ``` In `keep`, `v.value` autocompletes to the option names, so `v.value === 'app'` is checked by the compiler. Add an `unknown` option when the event may not carry the answer; without it the model picks the least wrong of the others, and you lose the signal that your events are missing a field. ## Rank: a position on a rubric Ordered levels, lowest first. The column carries the most likely level as `value` and the probability-weighted position as `score`, so `severity.score > 1.5` reads as "closer to page than to watch": ```typescript [signals/severity.ts] export const severity = defineSignal({ name: 'severity', when: e => (e.status ?? 0) >= 500, ask: 'How urgent is this failure for the on-call engineer?', score: ['noise', 'watch', 'page'], }) ``` ```json [column] "severity": { "value": "watch", "score": 1.2, "confidence": 0.6 } ``` Use `score` to sort and `value` to bucket. The confidence of a score signal is the probability of the top level, so a `0.6` here says the model was between `watch` and `page`. A model that answers without a distribution leaves `confidence` off choice and score columns; `value` and `score` are still there. ## Scope with `when`, always `when` is a plain predicate and runs before anything else. No model call happens when it returns `false`, which is what keeps a signal cheap: `fault` above costs nothing on the 99% of requests that succeed. It is required on any signal with `keep`. The event a signal sees is the wide event in `enrich`, or the request context plus `status`, `path`, `method` and `durationMs` in `keep`. The fields evlog sets are typed; what your handlers logged with `log.set()` is there too, as `unknown`: ```typescript when: e => e.status === 200 && (e.payment as { provider?: string } | undefined)?.provider === 'stripe' ``` ## Write a question the model can answer The model reads the event and the question, nothing else. Three checks before you ship one: 1. **The answer is in the event.** "Would a retry succeed" works because the error name and status are there. "Is this user about to churn" does not. 2. **The answer is one value.** "Was it slow, and was it our fault" is two signals. 3. **A rule could not do it.** If `when` alone answers the question, you do not need the model. `status >= 500` is a rule. "Transient or permanent" is not. [Recipes](https://www.evlog.dev/use-cases/signals/recipes) has twelve questions that pass, and the ones that do not. --- - [Overview](https://www.evlog.dev/use-cases/signals/overview) - [Reference](https://www.evlog.dev/use-cases/signals/reference) # What It Costs > About $6 per 100k judged events, 1,200 calls a minute, up to 2 s before a kept event drains, and the event leaving your process. Each with its lever. Four prices, in the order they will bite. Every one has a lever next to it, and every one fails open: when a limit is reached, events flow through unjudged and nothing in the request path waits.
At the default The lever
Money $0.042 per million input tokens, about $6 per 100k judged events when , cacheKey , state
Rate 1,200 calls per minute per account budget.perMinute , cacheKey
Latency up to 2 s before a keep event drains, client unaffected timeoutMs , a tight when
Egress the whole event, before redaction state , providerOptions.gateway.zeroDataRetention
## Money Jev bills input tokens only, at $0.042 per million at the time of writing. The fifteen requests on the [overview](https://www.evlog.dev/use-cases/signals/overview) cost $0.00007. A 1.5k-token event is about $0.00006, so 100k judged events are about $6, and a million are about $60. Prices move; the model page has the current figure. What decides the bill is how many events reach the model, not how many signals you define. All signals due for one event share one call, so adding a question to a signal that already runs is close to free: - `when` is the first lever. `fault` on `status >= 400` costs nothing on successful requests. - `cacheKey` is the second. An outage produces thousands of identical 502s; a key on `path + error.name` makes it one call and serves the rest from cache. - `state` is the third. The default sends the whole event. Picking the six fields the question needs cuts tokens and is the same change that fixes egress below. ## Rate The limit that binds before money is the account rate: 1,200 requests per minute per TypeSafe account, about 20 a second. `budget.perMinute` (default 600) is a fixed one-minute window per process, so with four instances on one account set it to 300. Past the window, events pass through unjudged and `stats().skipped` goes up. A failed call opens a breaker for `budget.cooldownMs` (default 30 s), so a 429 or an outage at the model costs you a 30 s gap in verdicts, not a queue. ## Latency A `keep` signal runs before the sampling decision and waits for the verdict, up to `timeoutMs` (default 2 s). Two things make that acceptable: - The response is already sent. `keep` and `enrich` run after the request finishes, so the client never waits on the model. - `durationMs` on the event is the request's duration, measured before keep hooks run. The judgment does not inflate your latency charts. What it does delay is the event reaching your drain, by the model's round trip. Keep signals belong on narrow paths (`/api/checkout`, not every 200), and `enrich`-only signals have no such wait. ## Egress The model reads what `state` gives it. By default that is the whole wide event minus the `signals` columns, and for a `keep` signal it is the request context *before* [redaction](https://www.evlog.dev/learn/redaction) runs. If your handlers log anything you would not paste into a third-party API, set `state` to pick fields: ```typescript createSignals({ signals, state: e => ({ status: e.status, path: e.path, durationMs: e.durationMs, error: e.error, payment: e.payment }), providerOptions: { gateway: { zeroDataRetention: true } }, }) ``` `zeroDataRetention` is passed through to AI Gateway untouched. Inference runs in your process with your key; evlog holds no key and proxies nothing. ## What never happens - A keep signal never drops an event. It promotes, and head sampling stays deterministic. - A verdict is never prose. Columns are typed values with a probability, and nothing else is added to the event unless you ask for it (`stampModel`). - A limit never surfaces as an error. Budget spent, breaker open, state too large, model down: the event drains as it would have without signals, and the counter in [`stats()`](https://www.evlog.dev/use-cases/signals/reference#stats) moves. --- - [Overview](https://www.evlog.dev/use-cases/signals/overview) - [Reference](https://www.evlog.dev/use-cases/signals/reference) # Signals > Ask a model one question about each request and get the answer back as a column: who broke it, is it worth paging, did the customer get what they came for. Your logs tell you a request returned 502. A signal tells you it was Stripe, that a retry would have worked, and that it is not worth waking anyone. Your logs tell you a checkout returned 200. A signal tells you the customer left without an order. `@evlog/signals` asks a decision model one question per event and writes the answer onto the event as a column, with a confidence. You query it like any other field. ## What you get A signal is a question in English, attached to the events it applies to. When an event matches, the model reads it, answers with a probability for each option, and the answer is written back onto the event as a typed column: The question is the whole configuration. There is no prompt to tune and no parser to write: a yes/no gives a boolean, a list of options gives one of them, an ordered rubric gives a level and a position. Every column carries the model's confidence, so `WHERE signals.fault.value = 'upstream' AND signals.fault.confidence > 0.9` is a query, not a guess. Three things that gives you, that a rule on the event's fields could not: - **Every error gets a reason.** `fault=upstream` on the Stripe 502, `fault=app` on the null deref. Two 5xx that look identical on a status chart are two rows in a `GROUP BY`. Over a week that is a number: how much of your error budget is yours. - **A 200 can be a failure.** A checkout that answered 200 and created no order, a webhook acknowledged and dropped, an agent looping on the same tool call. No field on those events says anything is wrong; a signal with `keep` carries them past sampling, and the next section shows that. - **It costs nothing you would notice.** A decision model answers in one short call, every due signal in it: 15 requests in the demo run came to 14 calls, 1,569 input tokens, $0.00007. [What It Costs](https://www.evlog.dev/use-cases/signals/cost) has what that becomes at your traffic. ## Add your first signal Add @evlog/signals to my evlog app. - Install: pnpm add @evlog/signals ai (ai 7.0.105 or later) - Define signals with defineSignal({ name, when, ask }) plus choice: {...} for a pick-one answer or score: ... for a rubric; keep: v => ... promotes the event past sampling - Register with createSignals({ signals }): on Nuxt or Nitro hook plugin.keep on 'evlog:emit:keep' and plugin.enrich on 'evlog:enrich' in a server plugin; on Next.js or standalone pass it in initLogger({ plugins: ... }); on Hono, Express, Fastify or Elysia pass it in the middleware's plugins option - Set AI_GATEWAY_API_KEY (default model typesafe-ai/jev through AI Gateway) - Verdicts land on event.signals. .{ value, confidence } in stdout and in every drain - In tests, pass evaluate from scriptedEvaluate() in @evlog/signals/testing so no model is called Docs: [https://www.evlog.dev/use-cases/signals/overview](https://www.evlog.dev/use-cases/signals/overview) ### Install ```bash [Terminal] pnpm add @evlog/signals ai ``` The default model is `typesafe-ai/jev` through Vercel AI Gateway. Set `AI_GATEWAY_API_KEY`, or nothing on Vercel, where OIDC is picked up. ### Write the question A signal is a name, a predicate that decides which events it applies to, and the question. This one labels every failure: ```typescript [server/signals.ts] import { defineSignal } from '@evlog/signals' export const fault = defineSignal({ name: 'fault', when: e => (e.status ?? 0) >= 400, ask: 'Who is responsible for this failure?', choice: { client: 'Bad input, expired session, client mistake', app: 'A bug or misconfiguration in our own code', upstream: 'A third-party dependency failed', }, }) ``` ### Register it `createSignals` returns an evlog [plugin](https://www.evlog.dev/extend/plugins). Nuxt and Nitro expose the two hooks directly; every other integration takes it in a `plugins` array: ```typescript [Nuxt]server/plugins/evlog-signals.ts import { createSignals } from '@evlog/signals' import { fault } from '../signals' export default defineNitroPlugin((nitroApp) => { const plugin = createSignals({ signals: [fault] }) nitroApp.hooks.hook('evlog:emit:keep', plugin.keep) nitroApp.hooks.hook('evlog:enrich', plugin.enrich) }) ``` ```typescript [Nitro]server/plugins/evlog-signals.ts import { definePlugin } from 'nitro' import { createSignals } from '@evlog/signals' import { fault } from '../signals' export default definePlugin((nitroApp) => { const plugin = createSignals({ signals: [fault] }) nitroApp.hooks.hook('evlog:emit:keep', plugin.keep) nitroApp.hooks.hook('evlog:enrich', plugin.enrich) }) ``` ```typescript [Next.js]lib/evlog.ts import { initLogger } from 'evlog' import { createSignals } from '@evlog/signals' import { fault } from './signals' initLogger({ plugins: [createSignals({ signals: [fault] })] }) ``` ```typescript [SvelteKit]src/hooks.server.ts import { createEvlogHooks } from 'evlog/sveltekit' import { createSignals } from '@evlog/signals' import { fault } from './signals' export const { handle, handleError } = createEvlogHooks({ plugins: [createSignals({ signals: [fault] })] }) ``` ```typescript [TanStack Start]server/plugins/evlog-signals.ts import { definePlugin } from 'nitro' import { createSignals } from '@evlog/signals' import { fault } from '../signals' export default definePlugin((nitroApp) => { const plugin = createSignals({ signals: [fault] }) nitroApp.hooks.hook('evlog:emit:keep', plugin.keep) nitroApp.hooks.hook('evlog:enrich', plugin.enrich) }) ``` ```typescript [React Router]app/root.tsx import { evlog } from 'evlog/react-router' import { createSignals } from '@evlog/signals' import { fault } from './signals' export const middleware: Route.MiddlewareFunction[] = [ evlog({ plugins: [createSignals({ signals: [fault] })] }), ] ``` ```typescript [Hono]src/index.ts import { evlog } from 'evlog/hono' import { createSignals } from '@evlog/signals' import { fault } from './signals' app.use(evlog({ plugins: [createSignals({ signals: [fault] })] })) ``` ```typescript [Express]src/index.ts import { evlog } from 'evlog/express' import { createSignals } from '@evlog/signals' import { fault } from './signals' app.use(evlog({ plugins: [createSignals({ signals: [fault] })] })) ``` ```typescript [Fastify]src/index.ts import { evlog } from 'evlog/fastify' import { createSignals } from '@evlog/signals' import { fault } from './signals' await app.register(evlog, { plugins: [createSignals({ signals: [fault] })] }) ``` ```typescript [Elysia]src/index.ts import { evlog } from 'evlog/elysia' import { createSignals } from '@evlog/signals' import { fault } from './signals' app.use(evlog({ plugins: [createSignals({ signals: [fault] })] })) ``` ```typescript [NestJS]src/app.module.ts import { EvlogModule } from 'evlog/nestjs' import { createSignals } from '@evlog/signals' import { fault } from './signals' EvlogModule.forRoot({ plugins: [createSignals({ signals: [fault] })] }) ``` ```typescript [oRPC]server/orpc.ts import { withEvlog } from 'evlog/orpc' import { createSignals } from '@evlog/signals' import { fault } from './signals' const handler = withEvlog(new RPCHandler(router), { plugins: [createSignals({ signals: [fault] })] }) ``` ```typescript [Cloudflare Workers]src/worker.ts import { withEvlog } from 'evlog/workers' import { createSignals } from '@evlog/signals' import { fault } from './signals' export default withEvlog(handler, { plugins: [createSignals({ signals: [fault] })] }) ``` ```typescript [Standalone] import { initLogger } from 'evlog' import { createSignals } from '@evlog/signals' import { fault } from './signals' initLogger({ plugins: [createSignals({ signals: [fault] })] }) ``` ### Read the column The next 500 that reaches your drain carries the answer: ```json [Wide Event] { "method": "POST", "path": "/api/checkout", "status": 502, "error": { "name": "FetchError", "message": "request to https://api.stripe.com/... failed, reason: read ECONNRESET" }, "signals": { "fault": { "value": "upstream", "confidence": 0.93 } } } ``` `value` is the answer, `confidence` is its probability (always on a yes/no, on choice and score when the model returns a distribution). Columns are written before the console line, so they show in the dev terminal, in platform logs such as Vercel, and in every [drain](https://www.evlog.dev/integrate/adapters/overview). ## Then keep what sampling drops Add `keep` to a signal and a `true` verdict forces the event past sampling. Six requests on a production rule, `info` kept at 0%: sampling keeps the two errors, and three of the four 200s come back because a signal said so: This is the signal that rescued the silent checkout: ```typescript [server/signals.ts] export const silentFailure = defineSignal({ name: 'silent-failure', when: e => e.status === 200 && e.path === '/api/checkout', ask: 'Returned 200, but the customer did not get what they came for', keep: v => v.value && v.confidence > 0.8, }) ``` `keep` only promotes. It never drops an event a [sampling rule](https://www.evlog.dev/learn/sampling) would have kept, and the promoted event carries `kept: true` on the column so your counts stay honest. ## Where to next Yes/no, pick one, or a rubric. Which shape gives you which column, and how to write a question the model can actually answer. Price per event, the rate limit that binds first, latency on the keep path, and what leaves your process. Every option of `defineSignal` and `createSignals`, the column format, `stats()`, and the test helpers, on one page. Three signals most apps want on day one, and nine more to copy from. --- - [What You Can Ask](https://www.evlog.dev/use-cases/signals/ask) - [What It Costs](https://www.evlog.dev/use-cases/signals/cost) - [Reference](https://www.evlog.dev/use-cases/signals/reference) # Recipes > Three signals to start with, then nine more to copy: failures explained, 200s that lied, agent turns, audit actions, flaky jobs. Every signal here is a question a rule cannot answer from the fields alone. Change the `when` to your paths and they are yours. ## Start with these three One file, three columns, and a week later you have numbers you did not have before. ```typescript [server/signals.ts] import { defineSignal } from '@evlog/signals' const errorName = (e: Record) => (e.error as { name?: string } | undefined)?.name ?? 'none' /** Every 4xx/5xx. Splits the error budget between the client, the code and the dependencies. */ export const fault = defineSignal({ name: 'fault', when: e => (e.status ?? 0) >= 400, ask: 'Who is responsible for this failure?', choice: { client: 'Bad input, expired session, missing permission, client mistake', app: 'A bug, a misconfiguration or a validation error in our own code', upstream: 'A third-party dependency failed, timed out or rate-limited us', }, cacheKey: e => e.error ? `${e.method} ${e.path} ${e.status} ${errorName(e)}` : undefined, }) /** Every 5xx. Tells a retry policy which failures are worth a second attempt. */ export const retryable = defineSignal({ name: 'retryable', when: e => (e.status ?? 0) >= 500, ask: 'Would the same request most likely succeed if retried in a few seconds?', criteria: { true: 'Timeout, connection reset, rate limit, transient upstream error', false: 'Bug, bad data, missing resource' }, cacheKey: e => e.error ? `${e.path} ${errorName(e)}` : undefined, }) /** Successful checkouts. The 200 that sampling deletes and nobody notices. */ export const silentFailure = defineSignal({ name: 'silent-failure', when: e => e.status === 200 && e.path === '/api/checkout', ask: 'The request returned 200, but the customer did not get what they came for', criteria: { true: 'No order or confirmation, a fallback path, an empty or partial result', false: 'Order created and confirmed' }, keep: v => v.value && v.confidence > 0.8, }) ``` What each one gives you: - `GROUP BY signals.fault.value` over a week says whether the next sprint goes to validation messages, your own bugs, or a dependency's SLA. `cacheKey` on the error name makes an outage one call, not one per request. - `signals.retryable.value = true` on a 502 is the difference between a retry policy and a guess. - `silent-failure` promotes the checkouts that returned 200 with no order. Point `when` at your own money path. ## All twelve
Signal Hook Question Why a rule cannot
fault enrich, cached Who is responsible: user, us, upstream A Stripe timeout and a null deref are both a 500
severity enrich noise, watch or page Urgency depends on what failed and for whom
retryable enrich, cached Would a retry succeed Transient and permanent errors share status codes
slow-cause enrich database, upstream, compute Reading the timings takes a human
silent-failure keep 200, but the customer left empty-handed Sampling only keeps what a predicate can name
webhook-ignored keep Acknowledged but not acted on The provider only sees the 200
validation-bug keep The rejected input was valid Looks identical to bad input
turn-resolved enrich The agent did what was asked A sampled LLM-as-judge misses the rest
turn-looping keep Repeated a tool call without progress Loops hide in successful turns
turn-off-script keep Did something nobody asked for Same
audit-review keep This action deserves a reviewer Risk is in the combination, not one field
job-flaky enrich Succeeded only because it retried The retry hides the cause
### Rank failures and explain slow requests ```typescript export const severity = defineSignal({ name: 'severity', when: e => (e.status ?? 0) >= 500, ask: 'How urgent is this failure for the on-call engineer?', score: ['noise', 'watch', 'page'], }) export const slowCause = defineSignal({ name: 'slow-cause', when: e => (e.durationMs ?? 0) > 2_000, ask: 'What dominated the duration of this request?', choice: { database: 'Query time, lock waits, many round trips', upstream: 'A third-party API or another service', compute: 'Serialization, rendering, large payloads, CPU work', unknown: 'Nothing in the event explains the time', }, }) ``` The `unknown` option on `slow-cause` is deliberate: without it the model picks the least wrong of three, and you lose the signal that your events do not carry the timings they should. ### Rescue the other 200s that lied ```typescript export const webhookIgnored = defineSignal({ name: 'webhook-ignored', when: e => e.status === 200 && (e.path ?? '').startsWith('/api/webhooks/'), ask: 'The webhook was acknowledged but not acted on', criteria: { true: 'Unhandled event type, skipped, no side effect recorded', false: 'The event was processed' }, keep: v => v.value && v.confidence > 0.85, }) export const validationBug = defineSignal({ name: 'validation-bug', when: e => e.status === 400, ask: 'The rejected input was actually valid and our validation is wrong', keep: v => v.value && v.confidence > 0.85, }) ``` A validator that rejects `hugo+test@example.com` returns the same 400 as one that rejects `notanemail`. `validation-bug` reads the rejected value and the reason, and keeps the ones where the reason is wrong. ### Judge agent turns `evlog/eve` emits one wide event per agent turn with `method: 'EVE'`. A sampled LLM-as-judge reads a few percent; a signal reads every turn. See [eve](https://www.evlog.dev/use-cases/eve) for the event shape. ```typescript const isTurn = (e: Record) => e.method === 'EVE' && typeof (e.eve as { turnId?: string } | undefined)?.turnId === 'string' export const turnResolved = defineSignal({ name: 'turn-resolved', when: isTurn, ask: 'The agent accomplished what the user asked for in this turn', }) export const turnLooping = defineSignal({ name: 'turn-looping', when: isTurn, ask: 'The agent repeated the same tool call without making progress', keep: v => v.value && v.confidence > 0.85, }) export const turnOffScript = defineSignal({ name: 'turn-off-script', when: isTurn, ask: 'The agent took an action the user did not ask for', keep: v => v.value && v.confidence > 0.85, }) ``` ### Flag audit actions and flaky jobs ```typescript export const auditReview = defineSignal({ name: 'audit-review', when: e => typeof e.audit === 'object' && e.audit !== null, ask: 'This action deserves a human review', criteria: { true: 'Privilege change, bulk deletion, export of personal data, action outside business hours by a non-admin', false: 'Routine self-service action', }, keep: v => v.value && v.confidence > 0.8, }) export const jobFlaky = defineSignal({ name: 'job-flaky', when: e => typeof e.operation === 'string' && ((e.retries as number | undefined) ?? 0) > 0, ask: 'The job only succeeded because it retried, and the underlying cause is still there', }) ``` [Audit events](https://www.evlog.dev/use-cases/audit/overview) carry the actor, the action and the target; the risk is in the combination, and `audit-review` is one reviewer queue instead of a rule per combination. ## More that fit the shape `first-seen` (is this error new for this route), `user-impact` (none, degraded, blocked), `owner` (which team should look), `known-error` (which catalog entry is this, from your own `why` and `fix`), `frustration` on support conversations, `did-succeed` on session events. ## Not a fit - **Secret or PII detection.** Sending a value to a model to ask whether it is a secret is the leak. Use [redaction](https://www.evlog.dev/learn/redaction). - **Paging.** Never page on a probability. `severity` ranks what to read first; an alert on `status` and `durationMs` decides who wakes up. - **Anything adversarial as a single verdict.** A model that can be asked can be prompted. - **Anything numeric.** A decision model returns a distribution over options, not a number. Compute it in the handler and log it. --- - [Overview](https://www.evlog.dev/use-cases/signals/overview) - [What You Can Ask](https://www.evlog.dev/use-cases/signals/ask) # Reference > Every option of defineSignal and createSignals with its default, the column format, the stats() counters, and the test helpers in @evlog/signals/testing. Everything configurable, on one page. The other pages link here instead of repeating it. ```bash [Terminal] pnpm add @evlog/signals ai ``` `ai` 7.0.105 or later is a peer dependency. Entry points: `@evlog/signals` and `@evlog/signals/testing`. ## Options `defineSignal(options)` validates and returns a `Signal`. The shape of the answer follows the input: `ask` alone is yes/no, `ask` + `choice` picks an option, `ask` + `score` positions on a rubric.
Option Type Notes
name string Column name under event.signals . Letters, digits, dashes, starting with a letter. Unique across registered signals.
ask string The question, in plain English.
when (event) => boolean Predicate over the event. No model call when false . Required with keep .
choice Record At least two options. Makes a choice signal.
score [level, level, ...] Ordered lowest first, at least two. Makes a score signal.
criteria { true?, false? } Boolean signals only. What makes each answer true.
keep (verdict) => boolean Promote the event past sampling when true . Never drops. Typed on the verdict the shape produces.
cacheKey (event) => string | undefined Reuse the verdict for events sharing a key. undefined skips caching. Last 1,000 verdicts per process.
`defineSignal` throws at definition time on an invalid name, an empty `ask`, `keep` without `when`, fewer than two options or levels.
`createSignals(options)` returns a `SignalsPlugin`: an evlog plugin with `keep`, `enrich` and `stats()`. The only required field is `signals`.
Option Default Notes
signals The signals to run. Duplicate names throw at startup.
model 'typesafe-ai/jev' AI SDK evaluation model: a gateway id or a provider instance.
budget.perMinute 600 Model calls per minute, shared by all signals, per process.
budget.cooldownMs 30000 Pause after a failed call.
timeoutMs 2000 Per call. An overrun aborts the call and counts as an error.
state whole event minus signals and signalsModel What the model reads. May return a string.
maxStateChars 100000 Largest state sent, in characters of JSON. Larger events are skipped.
providerOptions Forwarded to the model call, e.g. { gateway: { zeroDataRetention: true } } .
evaluate AI SDK experimental_evaluate Replace the model call. Tests, record and replay.
stampModel false Also write the answering model's id on event.signalsModel . Set it while comparing two models so their verdicts can be told apart in the drain; one more column on every judged event otherwise.
`model` takes any AI SDK evaluation model. See [Pick the model](#pick-the-model).
`SignalsPlugin` is a standard evlog [plugin](https://www.evlog.dev/extend/plugins). Where it goes depends on how the framework exposes plugins:
Framework Register
Nuxt, Nitro In a server plugin: nitroApp.hooks.hook('evlog:emit:keep', plugin.keep) and nitroApp.hooks.hook('evlog:enrich', plugin.enrich)
Next.js, standalone initLogger({ plugins: [plugin] })
Hono, Express, Fastify, Elysia, oRPC, SvelteKit, React Router The middleware's plugins option: evlog({ plugins: [plugin] })
createMiddlewareLogger (toolkit) plugins: [plugin] on the call
## Pick the model The default goes through AI Gateway: one key, every decision model, swap by changing a string. A direct provider is the same option with an instance instead of a string. The default. Reads `AI_GATEWAY_API_KEY`, or OIDC on Vercel with no key at all. ```typescript createSignals({ model: 'typesafe-ai/jev', // the default; 'liquid/d1' is the other decision model signals: [fault, silentFailure], }) ``` Both gateway models return a probability distribution, so every column has a `confidence`. The id string is the only thing to change to compare them; set `stampModel: true` while you do, so verdicts from each model can be told apart in the drain. A direct key to the model behind the default. Install `@ai-sdk/typesafe-ai`; it reads `TYPESAFE_AI_API_KEY`. ```typescript import { typeSafeAi } from '@ai-sdk/typesafe-ai' createSignals({ model: typeSafeAi.evaluationModel('jev-latest'), signals: [fault, silentFailure], }) ``` Same distributions, same columns as through the gateway. A general model prompted to answer. Install `@ai-sdk/openai`; it reads `OPENAI_API_KEY`. ```typescript import { openai } from '@ai-sdk/openai' createSignals({ model: openai.evaluationModel('gpt-6-luna'), signals: [fault, silentFailure], }) ``` A prompted model returns P(true) for yes/no questions and a bare answer for choice and score, so those columns carry `value` without `confidence`. Expect a slower call than a decision model; raise `timeoutMs` if `stats().errors` climbs. Any other provider that exposes `evaluationModel()` plugs in the same way. Options the provider understands go through `providerOptions`, keyed by provider name. ## The column Verdicts land on `event.signals.`. With `stampModel: true`, the answering model's id is on `event.signalsModel`.
Shape value confidence Extra
Boolean boolean probability of value
Choice one of the option names probability of that option, when the model returns a distribution
Score one of the level names (most likely) probability of that level, when the model returns a distribution score: number , probability-weighted position from 0 to levels - 1
A signal that promoted the event past sampling adds `kept: true` to its column. ```ts interface BooleanVerdict { value: boolean; confidence: number } interface ChoiceVerdict