CLI

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.

Terminal
npx evlog init

On a terminal it reads your project, asks what it cannot infer, and shows you the plan before touching anything:

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 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 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, a CLAUDE.md pointing at it, and the agent skills via npx skills add. Same thing evlog 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:

OfferAppears when
Error catalogThe scan found the same createError in more than one file
Audit actionsmap flagged sensitive entry points with no log.audit()
AI SDK loggingai is a dependency
Auth identitybetter-auth is a dependency
Batching and retryA production destination was picked — there is nothing to batch otherwise
Vite pluginThe 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:

ConditionWhy
--yesYou said so
--jsonThe payload is the contract; half a TUI in front of it helps nobody
stdin or stdout is not a TTYPiped, redirected, or spawned by a tool
CI is set to anything but false / 0A workflow runner has no keyboard
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:

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:

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: import.meta.dev ? createFsDrain() : undefined,
})

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.

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.

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:

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)

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.

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

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.

IdDestinationNeeds
fsLocal files under .evlog/logs (dev default)nothing
axiomAxiomAXIOM_DATASET, AXIOM_API_KEY
otlpAny OpenTelemetry collectorOTEL_EXPORTER_OTLP_ENDPOINT
posthogPostHogPOSTHOG_API_KEY
sentrySentrySENTRY_DSN
better-stackBetter StackBETTER_STACK_API_KEY
datadogDatadogDATADOG_API_KEY, DATADOG_SITE
hyperdxHyperDXHYPERDX_API_KEY
nonePretty console output onlynothing

With one destination on each side, the generated config picks the drain in one line:

evlog.config.ts
export default defineEvlog({
  service: 'checkout',
  drain: import.meta.dev ? createFsDrain() : createAxiomDrain(),
})

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:

evlog.config.ts
const drains = import.meta.dev
  ? [createFsDrain()]
  : [pipeline(createAxiomDrain()), pipeline(createSentryDrain())]

On Next.js, Hono, Express and Fastify the branch reads process.env.NODE_ENV instead, because their builds do not replace import.meta.dev.

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

IdWhat you get
enrichersPick from user agent, geo, request size, trace context (--enrichers)
pipelineBatching and retry instead of one HTTP call per request
samplingA rate profile, see below (--sampling)
error-catalogA typed catalog seeded with the errors you already repeat
audit-catalogTyped audit actions for the sensitive routes with no trail
aiToken usage, tool calls and cost on AI SDK generations
better-authThe signed-in user on every event
viteThe 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.

IdInfoWarningsFor
alleverythingeverythingThe right answer until volume or cost says otherwise
low50%100%A small app that has started to repeat itself
medium25%100%Steady traffic with a bill worth watching
high10%100%Info is most of what you are paying for
very-high1%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.

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:

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:

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:

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:

evlog.config.ts
export default defineEvlog({
  service: 'api',
  // Local NDJSON under .evlog/logs, in development only.
  drain: import.meta.dev ? createFsDrain() : undefined,
})

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

FlagWhat it does
--yes, -ySkip every question and take the defaults
--framework <name>Override detection (nuxt, nitro, next, tanstack-start, hono, express, fastify)
--service <name>Service name on every wide event (default: the one your evlog.config.ts sets, else your package name, unscoped)
--drain <id>Development drain: fs (default) or none
--prod-drain <a,b>Production destinations — see the table above
--extras <a,b>Comma-separated, see Extras
--enrichers <a,b>user-agent, geo, request-size, trace-context (default: all)
--sampling <id>all, low, medium (default), high, very-high
--apps <a,b>Workspace packages to set up (monorepo root only)
--dry-runPrint the plan, write nothing
--no-installDo not run the package manager; print the command instead
--no-agentsSkip all of it — no AGENTS.md block, no CLAUDE.md, no agent skills
--cwd <dir>Set up another app in the workspace
--jsonThe plan and result as JSON on stdout (implies non-interactive)

Next

  • evlog agents: re-run the agent guidelines on their own, whenever the skills move on
  • evlog doctor: confirm the wiring resolves and logs are being written
  • evlog map: score what is still dark now that evlog is in place
  • Quick Start: the concepts behind the wiring