Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/cli-config-module-options.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@evlog/cli": minor
---

`evlog config` reads the options passed to the evlog module in `nuxt.config.ts` or `nitro.config.ts`, which override `evlog.config.ts` at runtime. A note under the file name says so, and each setting they replace shows the value that applies with its line, and the value of the file under it. `--json` adds `moduleOptions` and an `overrides: { value, source }` field on those settings.
5 changes: 5 additions & 0 deletions .changeset/config-file-runtime.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"evlog": minor
---

The Nuxt and Nitro modules (v2 and v3) load `evlog.config.ts` (or `.mts`, `.js`, `.mjs`), the nearest one from the app up to the workspace root, and bundle it into the server. Options passed to the module, or set under the `evlog` key in `nuxt.config.ts`, override the file with the same merge as `extends`. The `drain`, `enrich` and `keep` of the file run next to the `evlog:drain`, `evlog:enrich` and `evlog:emit:keep` hooks, and a drain built with `createDrainPipeline()` is flushed when the server closes. On Nuxt the browser logger takes `enabled`, `pretty` and `minLevel` from the file too, unless the `evlog` key in `nuxt.config.ts` or a `NUXT_PUBLIC_EVLOG_*` variable sets them. `defineEvlogHook()` from `evlog/eve` takes the settings of `evlog.config.ts`, so an Eve agent spreads its config into the hook: `defineEvlogHook({ ...config, message: 'preview' })`. Its logger settings start the logger on the first turn, and `init` still replaces them when given.
93 changes: 86 additions & 7 deletions apps/docs/content/3.cli/11.config.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: evlog.config
description: "One evlog.config.ts sets what evlog map checks, where evlog logs reads, and the sampling, redaction and drains your app imports."
description: "One evlog.config.ts sets what evlog map checks, where evlog logs reads, and the sampling, redaction and drains your app applies."
navigation:
title: config
icon: i-lucide-settings-2
Expand All @@ -17,7 +17,7 @@ links:
variant: subtle
---

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, and the app imports it like any other module.
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:

Expand Down Expand Up @@ -91,11 +91,11 @@ CLI Β· read by evlog map and evlog logs

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" }`.
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 the CLI finds the file
## Where evlog finds the file

The CLI looks for `evlog.config.ts`, `evlog.config.mts`, `evlog.config.js` and `evlog.config.mjs`, in that order, starting in the package it runs on 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.
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.

Expand All @@ -110,6 +110,8 @@ Paths in `map.ignore`, `map.baseline` and `logs.dir` are relative to the package
| `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:<ref>` | `--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](/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:
Expand Down Expand Up @@ -193,9 +195,84 @@ 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:

::code-group
```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

Nothing loads `evlog.config.ts` at runtime: the app imports it. `toLoggerConfig` keeps the options `initLogger` takes, `toMiddlewareOptions` keeps the ones a framework middleware takes, and both leave `map` and `logs` out:
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`](/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'
Expand All @@ -209,7 +286,9 @@ const app = new Hono<EvlogVariables>()
app.use(evlog(toMiddlewareOptions(config)))
```

The Nuxt and Nitro modules do not read `evlog.config.ts`. Their options stay in `nuxt.config.ts` or `nitro.config.ts`, and the file there carries the `map` and `logs` settings the CLI applies.
An Eve agent spreads the config into its hook and adds its own options, see [Eve](/use-cases/eve).

The [`evlog/vite`](/reference/vite-plugin) plugin does not read the file. Its auto-init is serialized at build time and cannot carry a drain, so import the config where you call `initLogger` instead.

## When the config cannot be read

Expand Down
4 changes: 2 additions & 2 deletions apps/docs/content/4.integrate/frameworks/01.nuxt.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ Nuxt's error handler automatically catches `EvlogError` and returns a structured
See the [Configuration reference](/reference/configuration) for the full list of shared options (`enabled`, `pretty`, `silent`, `sampling`, middleware options, etc.).
::

All options are set in `nuxt.config.ts` under the `evlog` key:
Options are set in `nuxt.config.ts` under the `evlog` key, or in an [`evlog.config.ts`](/cli/config#nuxt-and-nitro-load-it-for-you) at the root of the app. The module loads that file on its own, and the `evlog` key overrides it. The file applies on the server only: the browser logger reads `enabled`, `pretty`, `console`, `minLevel` and `transport` from the `evlog` key.

| Option | Type | Default | Description |
|--------|------|---------|-------------|
Expand Down Expand Up @@ -201,7 +201,7 @@ export default defineNuxtConfig({

## Drain & Enrichers

Use Nitro plugin hooks to send logs to external services and enrich them with additional context.
Set `drain` and `enrich` in [`evlog.config.ts`](/cli/config#nuxt-and-nitro-load-it-for-you), or hook them from a Nitro plugin as below. Both receive every event.

### Drain Plugin

Expand Down
4 changes: 3 additions & 1 deletion apps/docs/content/4.integrate/frameworks/04.nitro.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,8 @@ In Nitro v3, import `createError` from `evlog/nitro/v3` - it wraps the Nitro err

See the [Configuration reference](/reference/configuration) for all available options (`enabled`, `pretty`, `silent`, `sampling`, etc.).

The same settings can live in an [`evlog.config.ts`](/cli/config#nuxt-and-nitro-load-it-for-you) at the root of the app. The module loads it on its own, and the options passed to `evlog()` override it.

### Route Filtering

Use `include` and `exclude` to control which routes are logged, and `routes` to assign different service names to different route groups:
Expand Down Expand Up @@ -220,7 +222,7 @@ export default defineNitroConfig({

## Drain & Enrichers

Use Nitro plugin hooks to send logs to external services and enrich them with additional context.
Set `drain` and `enrich` in [`evlog.config.ts`](/cli/config#nuxt-and-nitro-load-it-for-you), or hook them from a Nitro plugin as below. Both receive every event.

### Drain Plugin

Expand Down
79 changes: 53 additions & 26 deletions apps/docs/content/5.use-cases/5.eve.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ actions:
Add evlog wide events to my eve agent.

- Install evlog: pnpm add evlog
- Create agent/hooks/evlog.ts with defineEvlogHook from 'evlog/eve'
- Pass drain, enrich, and keep options (same as HTTP middleware integrations)
- 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
Expand Down Expand Up @@ -73,23 +73,44 @@ npm install evlog eve

### 2. Add the hook

Create `agent/hooks/evlog.ts`:
Put the agent's settings in `evlog.config.ts` at the project root. It is the same file the [evlog CLI reads](/cli/config), and its `extends` can pull in a preset your organization shares across apps:

```typescript [agent/hooks/evlog.ts]
import { defineEvlogHook } from 'evlog/eve'
```typescript [evlog.config.ts]
import { defineEvlog } from 'evlog'
import { createAxiomDrain } from 'evlog/axiom'

export default defineEvlogHook({
init: { env: { service: 'my-agent' } },
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]
Expand Down Expand Up @@ -314,40 +335,46 @@ Turns then land on the same PostHog person as the LLM traces they produced. See

Long-running eve agents should disable terminal pretty-printing and use a non-blocking drain:

```typescript [agent/hooks/evlog.ts]
import { defineEvlogHook } from 'evlog/eve'
```typescript [evlog.config.ts]
import { defineEvlog } from 'evlog'
import { createAxiomDrain } from 'evlog/axiom'
import { createDrainPipeline } from 'evlog/pipeline'

const drain = createDrainPipeline({ batch: { size: 50, intervalMs: 5000 } })(
createAxiomDrain(),
)

export default defineEvlogHook({
init: {
env: { service: 'my-agent', environment: 'production' },
pretty: false,
sampling: { rates: { info: 10 } },
},
drain,
maxSessions: 256,
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 | `init.pretty: false`, since pretty-print is for local dev only |
| 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 | `init.sampling.rates`, since eve emits one event per turn, not per token |
| 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`](/cli/config), plus the eve options below. `map` and `logs` only drive the CLI, and the hook ignores them.

| Option | Description |
| --- | --- |
| `init` | Passed to `initLogger()` on first hook invocation |
| `drain` / `enrich` / `keep` / `plugins` | Same as HTTP integrations ([plugins](/extend/plugins)) |
| `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](/extend/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`) |
Expand Down Expand Up @@ -379,7 +406,7 @@ export default defineEvlogHook({

## Audit logs

Combine with [Audit Logs](/use-cases/audit/overview): register `auditEnricher()` via `init.plugins` or a global plugin, and call `log.audit()` inside tools when a human approval gate fires. Tool rejections surface on `action.result` with `status: "rejected"`.
Combine with [Audit Logs](/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

Expand Down
Loading
Loading