From 3f25bfc1929185ce1a3dab8055f17ad0ed7d8f6a Mon Sep 17 00:00:00 2001 From: Hugo Richard Date: Sat, 10 Oct 2026 13:28:53 +0100 Subject: [PATCH 1/6] feat: load evlog.config.ts in the nuxt and nitro modules and eve hooks --- .changeset/config-file-runtime.md | 5 + apps/docs/content/3.cli/11.config.md | 75 ++++++++- .../content/4.integrate/frameworks/01.nuxt.md | 4 +- .../4.integrate/frameworks/04.nitro.md | 4 +- apps/docs/content/5.use-cases/5.eve.md | 79 ++++++--- examples/eve/agent/hooks/evlog.ts | 15 +- examples/eve/evlog.config.ts | 21 +++ packages/cli/src/commands/config.ts | 2 +- packages/evlog/src/eve/index.ts | 33 +++- packages/evlog/src/nitro-v3/module.ts | 2 + packages/evlog/src/nitro-v3/plugin.ts | 6 +- packages/evlog/src/nitro/module.ts | 2 + packages/evlog/src/nitro/plugin.ts | 6 +- packages/evlog/src/nuxt/module.ts | 15 +- packages/evlog/src/shared/configMerge.ts | 41 +++++ packages/evlog/src/shared/configPlugin.ts | 80 ++++++++++ packages/evlog/src/shared/define.ts | 34 +--- packages/evlog/src/shared/fileConfig.ts | 76 +++++++++ packages/evlog/test/eve.test.ts | 72 ++++++++- .../evlog/test/nitro-v2/config-file.test.ts | 85 ++++++++++ .../nitro-v2/config-fixture/evlog.config.ts | 13 ++ .../nitro-v2/config-fixture/nitro.config.ts | 11 ++ .../nitro-v2/config-fixture/routes/works.ts | 3 + .../evlog/test/nitro/module-paths.test.ts | 5 + packages/evlog/test/nuxt/config-file.test.ts | 69 ++++++++ .../evlog/test/shared/configPlugin.test.ts | 151 ++++++++++++++++++ packages/evlog/test/shared/fileConfig.test.ts | 119 ++++++++++++++ skills/analyze-logs/SKILL.md | 7 +- skills/review-logging-patterns/SKILL.md | 19 ++- 29 files changed, 959 insertions(+), 95 deletions(-) create mode 100644 .changeset/config-file-runtime.md create mode 100644 examples/eve/evlog.config.ts create mode 100644 packages/evlog/src/shared/configMerge.ts create mode 100644 packages/evlog/src/shared/configPlugin.ts create mode 100644 packages/evlog/src/shared/fileConfig.ts create mode 100644 packages/evlog/test/nitro-v2/config-file.test.ts create mode 100644 packages/evlog/test/nitro-v2/config-fixture/evlog.config.ts create mode 100644 packages/evlog/test/nitro-v2/config-fixture/nitro.config.ts create mode 100644 packages/evlog/test/nitro-v2/config-fixture/routes/works.ts create mode 100644 packages/evlog/test/nuxt/config-file.test.ts create mode 100644 packages/evlog/test/shared/configPlugin.test.ts create mode 100644 packages/evlog/test/shared/fileConfig.test.ts diff --git a/.changeset/config-file-runtime.md b/.changeset/config-file-runtime.md new file mode 100644 index 000000000..9b487f649 --- /dev/null +++ b/.changeset/config-file-runtime.md @@ -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 file applies to the server only, and the browser logger keeps reading the `evlog` key. `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. diff --git a/apps/docs/content/3.cli/11.config.md b/apps/docs/content/3.cli/11.config.md index 5889de225..21a91ba45 100644 --- a/apps/docs/content/3.cli/11.config.md +++ b/apps/docs/content/3.cli/11.config.md @@ -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 @@ -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: @@ -93,9 +93,9 @@ Each redaction path keeps the line of the file that adds it, so a path the prese 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" }`. -## 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. @@ -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:` | `--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: @@ -193,9 +195,68 @@ 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. Keep a setting in one of the two places: `evlog config` lists only the file, so a value overridden in `nuxt.config.ts` is not the one it shows. + +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 file applies to the server. The browser logger keeps reading `enabled`, `pretty`, `console`, `minLevel` and `transport` from the `evlog` key in `nuxt.config.ts`. + +### 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' @@ -209,7 +270,9 @@ const app = new Hono() 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 diff --git a/apps/docs/content/4.integrate/frameworks/01.nuxt.md b/apps/docs/content/4.integrate/frameworks/01.nuxt.md index 876533e97..633670c9e 100644 --- a/apps/docs/content/4.integrate/frameworks/01.nuxt.md +++ b/apps/docs/content/4.integrate/frameworks/01.nuxt.md @@ -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 | |--------|------|---------|-------------| @@ -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 diff --git a/apps/docs/content/4.integrate/frameworks/04.nitro.md b/apps/docs/content/4.integrate/frameworks/04.nitro.md index 36a52e45e..6f9e65292 100644 --- a/apps/docs/content/4.integrate/frameworks/04.nitro.md +++ b/apps/docs/content/4.integrate/frameworks/04.nitro.md @@ -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: @@ -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 diff --git a/apps/docs/content/5.use-cases/5.eve.md b/apps/docs/content/5.use-cases/5.eve.md index 8e1c9a40d..c7df7fcda 100644 --- a/apps/docs/content/5.use-cases/5.eve.md +++ b/apps/docs/content/5.use-cases/5.eve.md @@ -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 @@ -73,14 +73,14 @@ 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 @@ -88,8 +88,29 @@ export default defineEvlogHook({ }) ``` +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] @@ -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`) | @@ -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 diff --git a/examples/eve/agent/hooks/evlog.ts b/examples/eve/agent/hooks/evlog.ts index edeedaddc..4c107fa4b 100644 --- a/examples/eve/agent/hooks/evlog.ts +++ b/examples/eve/agent/hooks/evlog.ts @@ -1,19 +1,8 @@ -import type { DrainContext } from 'evlog' import { defineEvlogHook } from 'evlog/eve' -import { createFsDrain } from 'evlog/fs' -import { createDrainPipeline } from 'evlog/pipeline' - -const batchedFsDrain = createDrainPipeline({ - batch: { size: 5, intervalMs: 2000 }, -})(createFsDrain()) +import config from '../../evlog.config' export default defineEvlogHook({ - init: { env: { service: 'clearbill-support-agent' } }, - drain: batchedFsDrain, - enrich: (ctx) => { - ctx.event.runtime = process.env.VERCEL_REGION ?? 'local' - ctx.event.demo = 'evlog-eve-support-refund' - }, + ...config, keep: (ctx) => { const { context } = ctx const tools = (context.ai as { tools?: Array<{ success: boolean }> } | undefined)?.tools diff --git a/examples/eve/evlog.config.ts b/examples/eve/evlog.config.ts new file mode 100644 index 000000000..9d0abcb34 --- /dev/null +++ b/examples/eve/evlog.config.ts @@ -0,0 +1,21 @@ +import type { DrainContext } from 'evlog' +import { defineEvlog } from 'evlog' +import { createFsDrain } from 'evlog/fs' +import { createDrainPipeline } from 'evlog/pipeline' + +/** + * Settings for the support agent, read by `agent/hooks/evlog.ts` and by the + * evlog CLI. + * + * @see https://evlog.dev/cli/config + */ +export default defineEvlog({ + service: 'clearbill-support-agent', + drain: createDrainPipeline({ + batch: { size: 5, intervalMs: 2000 }, + })(createFsDrain()), + enrich: (ctx) => { + ctx.event.runtime = process.env.VERCEL_REGION ?? 'local' + ctx.event.demo = 'evlog-eve-support-refund' + }, +}) diff --git a/packages/cli/src/commands/config.ts b/packages/cli/src/commands/config.ts index e8f9224ff..f4ec6d65e 100644 --- a/packages/cli/src/commands/config.ts +++ b/packages/cli/src/commands/config.ts @@ -28,7 +28,7 @@ export interface ConfigResult { extends: { specifier: string | null, file: string } | null /** Settings the CLI applies itself (`map`, `logs`). */ cli: ConfigEntry[] - /** Settings the app reads where it imports the config. */ + /** Settings the app applies at runtime. */ app: ConfigEntry[] /** Where each key is written, by {@link formatPath}, for rows that stand for a whole object such as `redact`. */ sources: ReadonlyMap diff --git a/packages/evlog/src/eve/index.ts b/packages/evlog/src/eve/index.ts index ceeca547e..7480e6c15 100644 --- a/packages/evlog/src/eve/index.ts +++ b/packages/evlog/src/eve/index.ts @@ -11,6 +11,8 @@ import type { AuditableLogger } from '../audit' import type { AIToolExecution, AIEventData, ModelCost } from '../ai/index' import { initLogger, isLoggerInitialized, isLoggerLocked, noopLogger } from '../logger' import type { LoggerConfig } from '../types' +import type { EvlogConfig } from '../shared/define' +import { toLoggerConfig } from '../shared/define' import type { BaseEvlogOptions, MiddlewareLoggerOptions } from '../shared/middleware' import { createMiddlewareLogger, pickBaseEvlogOptions } from '../shared/middleware' import { @@ -34,9 +36,20 @@ const CANCELLED_STATUS = 499 */ export type EveMessageMode = 'omit' | 'preview' | 'full' -/** Options for {@link defineEvlogHook}. */ -export interface EvlogEveOptions extends BaseEvlogOptions { - /** Passed to {@link initLogger} on the first hook invocation. */ +/** + * Options for {@link defineEvlogHook}: every runtime setting of + * `evlog.config.ts`, plus the eve-specific ones below. The logger settings + * (`service`, `environment`, `sampling`, `silent`, `drain`, `plugins`...) + * initialise the logger on the first hook invocation, so out-of-turn events + * reach the same drain as turns. `map` and `logs` are CLI-only and not + * accepted here. + */ +export interface EvlogEveOptions extends Omit { + /** + * Passed to {@link initLogger} as is on the first hook invocation, in place + * of the logger settings above. Use it to give out-of-turn events their own + * drain or service. + */ init?: LoggerConfig /** * How much of the user message to record. Default `'omit'`. @@ -399,6 +412,11 @@ function summarizeMessageParts(parts: readonly unknown[]): Array { setActiveNitroRuntime('v3') - const evlogConfig = await resolveEvlogConfigForNitroPlugin() + const evlogConfig = withEvlogFileConfig(await resolveEvlogConfigForNitroPlugin()) const redact = normalizeRedactConfig(evlogConfig?.redact as boolean | Record | undefined) @@ -216,11 +217,14 @@ export default definePlugin(async (nitroApp) => { silent: evlogConfig?.silent, sampling: evlogConfig?.sampling, minLevel: evlogConfig?.minLevel, + stringify: evlogConfig?.stringify, redact, + plugins: evlogFileConfigPlugins(), _suppressDrainWarning: true, }) const hooks = nitroApp.hooks as unknown as Hooks + hooks.hook('close', flushEvlogFileConfigDrain) // When globally disabled, createRequestLogger returns a no-op logger — still // attach it so handlers can call useLogger without throwing. diff --git a/packages/evlog/src/nitro/module.ts b/packages/evlog/src/nitro/module.ts index dfa13f220..e8f0a2a2f 100644 --- a/packages/evlog/src/nitro/module.ts +++ b/packages/evlog/src/nitro/module.ts @@ -4,6 +4,7 @@ import type { Nitro } from 'nitropack' import type { NitroModuleOptions } from '../nitro' import { prependNitroErrorHandler, serializeBundledEvlogConfig } from '../nitro' import { prepareRedactForBridge } from '../redact' +import { registerEvlogConfigPlugin } from '../shared/configPlugin' export type { NitroModuleOptions } @@ -23,6 +24,7 @@ export default function evlog(options?: NitroModuleOptions) { setup(nitro: Nitro) { // Push the plugin (no extension — Nitro's bundler resolves it) nitro.options.plugins.push(resolveModulePath('plugin')) + registerEvlogConfigPlugin(nitro) // Prepend so evlog runs before any framework handler (Nuxt registers its own). nitro.options.errorHandler = prependNitroErrorHandler( diff --git a/packages/evlog/src/nitro/plugin.ts b/packages/evlog/src/nitro/plugin.ts index 2bd6fba12..a096d5c35 100644 --- a/packages/evlog/src/nitro/plugin.ts +++ b/packages/evlog/src/nitro/plugin.ts @@ -10,6 +10,7 @@ import { readCodeSnippetFromDisk } from '../shared/pretty-error-snippet.node' import { enrichErrorStackForDev } from '../shared/enrich-error-stack.node' import { shouldLog, getServiceForPath, extractErrorStatus, type NitroPluginEvlogConfig } from '../nitro' import { normalizeRedactConfig } from '../redact' +import { evlogFileConfigPlugins, flushEvlogFileConfigDrain, withEvlogFileConfig } from '../shared/fileConfig' import { resolveEvlogConfigForNitroPlugin, setActiveNitroRuntime } from '../shared/nitroConfigBridge' import { bindStreamingResponseLifecycle, shouldDeferEmitForResponse } from '../shared/streamResponse' import { startStreamServer, type StreamServerOptions } from '../stream' @@ -69,7 +70,7 @@ function getTransportEndpoint(config: NitroPluginEvlogConfig | undefined): strin export default defineNitroPlugin(async (nitroApp) => { setActiveNitroRuntime('v2') - const evlogConfig = await resolveEvlogConfigForNitroPlugin() + const evlogConfig = withEvlogFileConfig(await resolveEvlogConfigForNitroPlugin()) const redact = normalizeRedactConfig(evlogConfig?.redact as boolean | Record | undefined) const transportEndpoint = getTransportEndpoint(evlogConfig) @@ -84,9 +85,12 @@ export default defineNitroPlugin(async (nitroApp) => { silent: evlogConfig?.silent, sampling: evlogConfig?.sampling, minLevel: evlogConfig?.minLevel, + stringify: evlogConfig?.stringify, redact, + plugins: evlogFileConfigPlugins(), _suppressDrainWarning: true, }) + nitroApp.hooks.hook('close', flushEvlogFileConfigDrain) // When `evlog.stream` is set (or auto-on in dev), boot the mini stream // server and hook every drained event into it. The server runs on its diff --git a/packages/evlog/src/nuxt/module.ts b/packages/evlog/src/nuxt/module.ts index b96db8278..67366a745 100644 --- a/packages/evlog/src/nuxt/module.ts +++ b/packages/evlog/src/nuxt/module.ts @@ -1,3 +1,4 @@ +import { relative } from 'node:path' import { addImports, addPlugin, @@ -9,11 +10,12 @@ import { createResolver, defineNuxtModule, } from '@nuxt/kit' -import type { NitroConfig } from 'nitropack' +import type { Nitro, NitroConfig } from 'nitropack' import type { EnvironmentContext, LogLevel, RedactConfig, RouteConfig, SamplingConfig, TransportConfig } from '../types' import type { DevTerminalInput } from '../shared/dev-terminal' import { prependNitroErrorHandler, serializeBundledEvlogConfig } from '../nitro' import { prepareRedactForBridge } from '../redact' +import { findEvlogConfigFile, registerEvlogConfigPlugin } from '../shared/configPlugin' import { createStripPlugin } from '../vite/strip' import { createSourceLocationPlugin } from '../vite/source-location' import { name, version } from '../../package.json' @@ -417,6 +419,17 @@ export default defineNuxtModule({ } addServerPlugin(resolver.resolve('../nitro/plugin')) + // @ts-expect-error nitro:init hook exists but is not in NuxtHooks type + nuxt.hook('nitro:init', (nitro: Nitro) => { + registerEvlogConfigPlugin(nitro) + }) + // Root files are in no generated tsconfig, so `import.meta.dev` would be untyped in the editor. + nuxt.hook('prepare:types', ({ nodeTsConfig }) => { + const configFile = findEvlogConfigFile(nuxt.options.rootDir) + if (!configFile) return + nodeTsConfig.include ||= [] + nodeTsConfig.include.push(relative(nuxt.options.buildDir, configFile).replace(/\\/g, '/')) + }) addPlugin({ src: resolver.resolve('../runtime/client/plugin'), diff --git a/packages/evlog/src/shared/configMerge.ts b/packages/evlog/src/shared/configMerge.ts new file mode 100644 index 000000000..729a328d1 --- /dev/null +++ b/packages/evlog/src/shared/configMerge.ts @@ -0,0 +1,41 @@ +import { isPlainObject } from './event' +import type { EvlogPlugin } from './plugin' + +/** Lists that grow across layers instead of being replaced: dropping a base entry would loosen redaction or lose kept events. */ +const APPENDED_LISTS = new Set(['redact.paths', 'redact.patterns', 'sampling.keep']) + +function mergePlugins(base: EvlogPlugin[], override: EvlogPlugin[]): EvlogPlugin[] { + const byName = new Map(base.map(plugin => [plugin.name, plugin])) + for (const plugin of override) byName.set(plugin.name, plugin) + return [...byName.values()] +} + +function mergeValue(base: unknown, override: unknown, path: string): unknown { + if (override === undefined) return base + if (path === 'plugins' && Array.isArray(base) && Array.isArray(override)) return mergePlugins(base, override) + /* `redact: true` is the built-in patterns with nothing else, which a base + object already includes; letting it replace the object would drop the + base's paths. `false` is the only way to turn redaction off. */ + if (path === 'redact' && override === true && isPlainObject(base)) return base + if (Array.isArray(base) && Array.isArray(override)) return APPENDED_LISTS.has(path) ? [...base, ...override] : override + if (isPlainObject(base) && isPlainObject(override)) return mergeRecords(base, override, path) + return override +} + +function mergeRecords(base: Record, override: Record, prefix: string): Record { + const out: Record = { ...base } + for (const [key, value] of Object.entries(override)) { + out[key] = mergeValue(base[key], value, prefix ? `${prefix}.${key}` : key) + } + return out +} + +/** + * Lay `override` over `base` with the `extends` rules: scalars and functions + * are replaced, plain objects merge key by key, `redact.paths`, + * `redact.patterns` and `sampling.keep` are concatenated, `plugins` merge by + * `name`. + */ +export function layerEvlogConfig(base: T, override: object): T { + return mergeRecords(base as Record, override as Record, '') as T +} diff --git a/packages/evlog/src/shared/configPlugin.ts b/packages/evlog/src/shared/configPlugin.ts new file mode 100644 index 000000000..49d8caa9b --- /dev/null +++ b/packages/evlog/src/shared/configPlugin.ts @@ -0,0 +1,80 @@ +import { existsSync, readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { EVLOG_FILE_CONFIG_KEY } from './fileConfig' + +/** Names `evlog.config` is looked up under, in order, in each directory. Same list as the CLI. */ +const CONFIG_FILES = ['evlog.config.ts', 'evlog.config.mts', 'evlog.config.js', 'evlog.config.mjs'] + +/** Virtual module id of the server plugin that loads `evlog.config.ts`. */ +export const EVLOG_CONFIG_PLUGIN_ID = '#evlog/config' + +function isWorkspaceRoot(dir: string): boolean { + const pkgPath = join(dir, 'package.json') + if (!existsSync(pkgPath)) return false + if (existsSync(join(dir, 'pnpm-workspace.yaml'))) return true + return (JSON.parse(readFileSync(pkgPath, 'utf8')) as { workspaces?: unknown }).workspaces !== undefined +} + +function findWorkspaceRoot(start: string): string | undefined { + let dir = start + for (;;) { + if (isWorkspaceRoot(dir)) return dir + const parent = dirname(dir) + if (parent === dir) return undefined + dir = parent + } +} + +/** + * The `evlog.config` that applies to the app in `rootDir`: the nearest one + * from `rootDir` up to the workspace root, which is the file the CLI reads. + * Outside a workspace only `rootDir` is searched. + */ +export function findEvlogConfigFile(rootDir: string): string | undefined { + const root = findWorkspaceRoot(rootDir) ?? rootDir + let dir = rootDir + for (;;) { + for (const name of CONFIG_FILES) { + const file = join(dir, name) + if (existsSync(file)) return file + } + const parent = dirname(dir) + if (dir === root || parent === dir) return undefined + dir = parent + } +} + +/** Source of the server plugin that imports `file` and hands its default export to the evlog Nitro plugin. */ +export function evlogConfigPluginSource(file: string): string { + // Raw-interpolated into a JS string literal: POSIX separators keep Windows backslashes from reading as escapes. + return [ + `import config from ${JSON.stringify(file.replace(/\\/g, '/'))}`, + `globalThis[Symbol.for(${JSON.stringify(EVLOG_FILE_CONFIG_KEY)})] = config`, + 'export default function evlogConfig() {}', + '', + ].join('\n') +} + +/** The slice of Nitro options the config plugin is registered on, shared by nitropack v2 and Nitro v3. */ +interface NitroOptionsTarget { + options: { + rootDir: string + plugins: string[] + virtual: Record + } +} + +/** + * Load `evlog.config.ts` into the server bundle when the app has one, through + * a virtual plugin placed first so the file is evaluated before any plugin + * runs. Call it once options are resolved (from a Nitro module's `setup` or + * Nuxt's `nitro:init`): a plugin id added earlier is resolved as a path. + * Returns the file it registered. + */ +export function registerEvlogConfigPlugin(nitro: NitroOptionsTarget): string | undefined { + const file = findEvlogConfigFile(nitro.options.rootDir) + if (!file) return undefined + nitro.options.virtual[EVLOG_CONFIG_PLUGIN_ID] = evlogConfigPluginSource(file) + nitro.options.plugins.unshift(EVLOG_CONFIG_PLUGIN_ID) + return file +} diff --git a/packages/evlog/src/shared/define.ts b/packages/evlog/src/shared/define.ts index 50d631eca..fa9d549a0 100644 --- a/packages/evlog/src/shared/define.ts +++ b/packages/evlog/src/shared/define.ts @@ -1,9 +1,8 @@ import type { EnvironmentContext, LoggerConfig, SamplingConfig } from '../types' +import { layerEvlogConfig } from './configMerge' import type { DevTerminalInput } from './dev-terminal' -import { isPlainObject } from './event' import type { BaseEvlogOptions } from './middleware' import { pickBaseEvlogOptions } from './middleware' -import type { EvlogPlugin } from './plugin' /** * Ids of the checks `evlog map` runs. The CLI asserts at compile time that @@ -94,37 +93,8 @@ export interface EvlogConfig extends BaseEvlogOptions { logs?: EvlogLogsConfig } -/** Lists that grow across `extends` instead of being replaced: dropping a parent entry would loosen redaction or lose kept events. */ -const APPENDED_LISTS = new Set(['redact.paths', 'redact.patterns', 'sampling.keep']) - const EXTENDED = Symbol.for('evlog.config.extended') -function mergePlugins(parent: EvlogPlugin[], child: EvlogPlugin[]): EvlogPlugin[] { - const byName = new Map(parent.map(plugin => [plugin.name, plugin])) - for (const plugin of child) byName.set(plugin.name, plugin) - return [...byName.values()] -} - -function mergeValue(parent: unknown, child: unknown, path: string): unknown { - if (child === undefined) return parent - if (path === 'plugins' && Array.isArray(parent) && Array.isArray(child)) return mergePlugins(parent, child) - /* `redact: true` is the built-in patterns with nothing else, which a parent - object already includes; letting it replace the object would drop the - parent's paths. `false` is the only way to turn redaction off. */ - if (path === 'redact' && child === true && isPlainObject(parent)) return parent - if (Array.isArray(parent) && Array.isArray(child)) return APPENDED_LISTS.has(path) ? [...parent, ...child] : child - if (isPlainObject(parent) && isPlainObject(child)) return mergeRecords(parent, child, path) - return child -} - -function mergeRecords(parent: Record, child: Record, prefix: string): Record { - const out: Record = { ...parent } - for (const [key, value] of Object.entries(child)) { - out[key] = mergeValue(parent[key], value, prefix ? `${prefix}.${key}` : key) - } - return out -} - /** * Merge a config onto the one it extends. * @@ -148,7 +118,7 @@ export function mergeEvlogConfig(parent: EvlogConfig, child: EvlogConfig): Evlog ) } const { extends: _parent, ...own } = child - const merged = mergeRecords(parent as Record, own, '') as EvlogConfig + const merged = layerEvlogConfig(parent, own) Object.defineProperty(merged, EXTENDED, { value: true }) return merged } diff --git a/packages/evlog/src/shared/fileConfig.ts b/packages/evlog/src/shared/fileConfig.ts new file mode 100644 index 000000000..77ded8739 --- /dev/null +++ b/packages/evlog/src/shared/fileConfig.ts @@ -0,0 +1,76 @@ +import type { NitroPluginEvlogConfig } from '../nitro' +import { layerEvlogConfig } from './configMerge' +import type { EvlogConfig } from './define' +import { toLoggerConfig } from './define' +import type { EvlogPlugin } from './plugin' +import { definePlugin } from './plugin' + +/** + * Global slot the plugin generated by the evlog Nitro and Nuxt modules fills + * with the default export of `evlog.config.ts`. The generated plugin imports + * nothing from evlog, so it reaches this runtime wherever Nitro resolved it from. + */ +export const EVLOG_FILE_CONFIG_KEY = 'evlog.config.file' + +/** Name of the plugin that carries the `drain`, `enrich` and `keep` of `evlog.config.ts`. */ +export const EVLOG_FILE_CONFIG_PLUGIN = 'evlog-config' + +/** Settings the Nitro plugins read once `evlog.config.ts` and the module options are layered. */ +export interface NitroRuntimeEvlogConfig extends NitroPluginEvlogConfig { + stringify?: boolean +} + +/** The default export of `evlog.config.ts` loaded into this server, if any. */ +export function readEvlogFileConfig(): EvlogConfig | undefined { + return (globalThis as Record)[Symbol.for(EVLOG_FILE_CONFIG_KEY)] +} + +/** + * Lay the module options over `evlog.config.ts`: the file is the base, and + * what `nuxt.config` or the Nitro module sets wins, merged with the + * `extends` rules. Functions are left out: {@link evlogFileConfigPlugins} + * carries them. + */ +export function withEvlogFileConfig(options: NitroPluginEvlogConfig | undefined): NitroRuntimeEvlogConfig | undefined { + const file = readEvlogFileConfig() + if (!file) return options + const { + extends: _extends, + service: _service, + environment: _environment, + map: _map, + logs: _logs, + drain: _drain, + enrich: _enrich, + keep: _keep, + plugins: _plugins, + waitUntil: _waitUntil, + ...settings + } = file + const { env } = toLoggerConfig(file) + const base: NitroRuntimeEvlogConfig = env ? { ...settings, env } : settings + return options ? layerEvlogConfig(base, options) : base +} + +/** + * The plugins of `evlog.config.ts`, plus one named `evlog-config` that runs + * its `drain`, `enrich` and `keep` next to the `evlog:*` Nitro hooks. + */ +export function evlogFileConfigPlugins(): EvlogPlugin[] | undefined { + const file = readEvlogFileConfig() + if (!file) return undefined + const { drain, enrich, keep } = file + const hooks = drain || enrich || keep + ? [definePlugin({ name: EVLOG_FILE_CONFIG_PLUGIN, drain, enrich, keep })] + : [] + return [...(file.plugins ?? []), ...hooks] +} + +/** + * Flush the `drain` of `evlog.config.ts` when it buffers events, as a + * `createDrainPipeline` drain does. The Nitro plugins call it on `close`. + */ +export async function flushEvlogFileConfigDrain(): Promise { + const drain = readEvlogFileConfig()?.drain as { flush?: () => Promise } | undefined + if (typeof drain?.flush === 'function') await drain.flush() +} diff --git a/packages/evlog/test/eve.test.ts b/packages/evlog/test/eve.test.ts index 452e5c1d2..deb4844e6 100644 --- a/packages/evlog/test/eve.test.ts +++ b/packages/evlog/test/eve.test.ts @@ -1,6 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { HookContext, HookEventMap } from 'eve/hooks' -import { initLogger } from '../src/logger' +import { getEnvironment, getGlobalDrain, initLogger, log } from '../src/logger' +import { defineEvlog } from '../src/shared/define' +import { resetGlobalRegistry } from '../src/shared/globalRegistry' import { resetEvlogEveForTests, defineEvlogHook, @@ -2073,3 +2075,71 @@ describe('evlogRuntimeContext', () => { expect({ ...evlogRuntimeContext(stepStartedInput()) }).toEqual({}) }) }) + +describe('evlog.config settings', () => { + beforeEach(() => { + resetEvlogEveForTests() + resetGlobalRegistry() + }) + + afterEach(() => { + resetEvlogEveForTests() + resetGlobalRegistry() + }) + + it('initialises the logger from the config on the first turn', async () => { + const spies = createPipelineSpies() + const config = defineEvlog({ + service: 'support-agent', + environment: 'staging', + silent: true, + drain: spies.drain, + map: { minScore: 80 }, + logs: { dir: '.evlog/logs' }, + }) + + await runTurn(defineEvlogHook({ ...config, message: 'preview' }), { message: 'hello' }) + + expect(getEnvironment()).toMatchObject({ service: 'support-agent', environment: 'staging' }) + expect(getGlobalDrain()).toBe(spies.drain) + await waitForDrainCalls(spies.drain) + expect(spies.drain).toHaveBeenCalledTimes(1) + expect(spies.drain.mock.calls[0]![0].event).toMatchObject({ + service: 'support-agent', + message: { received: 'hello' }, + }) + }) + + it('sends events emitted outside a turn to the same drain', async () => { + const spies = createPipelineSpies() + await runTurn(defineEvlogHook({ service: 'support-agent', silent: true, drain: spies.drain })) + await waitForDrainCalls(spies.drain) + + log.info({ job: 'nightly-digest' }) + + await waitForDrainCalls(spies.drain, 2) + expect(findEventViaDrain(spies.drain, e => e.job === 'nightly-digest')).toMatchObject({ service: 'support-agent' }) + }) + + it('names the service eve-agent when the config has none', async () => { + await runTurn(defineEvlogHook({ silent: true, drain: createPipelineSpies().drain })) + expect(getEnvironment().service).toBe('eve-agent') + }) + + it('keeps a service set through env', async () => { + await runTurn(defineEvlogHook({ env: { service: 'from-env' }, silent: true, drain: createPipelineSpies().drain })) + expect(getEnvironment().service).toBe('from-env') + }) + + it('initialises from init alone when it is set', async () => { + const turnDrain = createPipelineSpies().drain + await runTurn(defineEvlogHook({ + service: 'from-options', + drain: turnDrain, + init: { env: { service: 'from-init' }, silent: true, _suppressDrainWarning: true }, + })) + + expect(getEnvironment().service).toBe('from-init') + expect(getGlobalDrain()).toBeUndefined() + }) +}) diff --git a/packages/evlog/test/nitro-v2/config-file.test.ts b/packages/evlog/test/nitro-v2/config-file.test.ts new file mode 100644 index 000000000..0f3dee857 --- /dev/null +++ b/packages/evlog/test/nitro-v2/config-file.test.ts @@ -0,0 +1,85 @@ +import { existsSync, mkdtempSync, readFileSync } from 'node:fs' +import { createServer } from 'node:http' +import type { Server } from 'node:http' +import type { AddressInfo } from 'node:net' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { build, createDevServer, createNitro, prepare } from 'nitropack' +import { toNodeListener } from 'h3' +import { resolve } from 'pathe' +import evlog from '../../src/nitro/module' + +const rootDir = resolve(__dirname, './config-fixture') +const out = join(mkdtempSync(join(tmpdir(), 'evlog-config-file-')), 'events.ndjson') + +async function readDrained(): Promise[]> { + for (let attempt = 0; attempt < 50; attempt++) { + if (existsSync(out)) { + const lines = readFileSync(out, 'utf8').trim().split('\n').filter(Boolean) + if (lines.length > 0) return lines.map(line => JSON.parse(line) as Record) + } + await new Promise(resolve => setTimeout(resolve, 100)) + } + throw new Error(`no event was drained to ${out}`) +} + +/** + * Boots a real Nitro v2 dev server whose root holds an `evlog.config.ts`, so + * the module's virtual plugin, the bundled import of the file and the global + * hand-off to the evlog plugin are all exercised as an app would run them. + */ +describe.sequential('Nitro v2 app with an evlog.config.ts', () => { + let nitro: Awaited> + let devServer: ReturnType + let server: Server + let baseURL: string + + beforeAll(async () => { + process.env.EVLOG_CONFIG_FIXTURE_OUT = out + // Passed here rather than imported by nitro.config.ts: c12 would load the + // module through jiti, and that second copy of src breaks v8 coverage. + nitro = await createNitro({ + dev: true, + rootDir, + modules: [evlog({ env: { environment: 'from-module' }, silent: true })], + }) + devServer = createDevServer(nitro) + server = createServer(toNodeListener(devServer.app)) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + baseURL = `http://127.0.0.1:${(server.address() as AddressInfo).port}` + await prepare(nitro) + const ready = new Promise((resolve) => { + nitro.hooks.hook('dev:reload', () => resolve()) + }) + await build(nitro) + await ready + }, 120_000) + + afterAll(async () => { + if (server?.listening) { + await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())) + } + await devServer?.close() + await nitro?.close() + }) + + it('registers the file as the first server plugin', () => { + expect(nitro.options.plugins[0]).toBe('#evlog/config') + expect(nitro.options.virtual['#evlog/config']).toContain(join(rootDir, 'evlog.config.ts')) + }) + + it('runs the drain and enrich of the file, with the module options layered on top', async () => { + const res = await fetch(new URL('/works', baseURL), { signal: AbortSignal.timeout(8_000) }) + expect(res.status).toBe(200) + + const [event] = await readDrained() + expect(event).toMatchObject({ + service: 'from-config-file', + environment: 'from-module', + enrichedBy: 'evlog.config.ts', + method: 'GET', + path: '/works', + }) + }, 15_000) +}) diff --git a/packages/evlog/test/nitro-v2/config-fixture/evlog.config.ts b/packages/evlog/test/nitro-v2/config-fixture/evlog.config.ts new file mode 100644 index 000000000..7f67f16f5 --- /dev/null +++ b/packages/evlog/test/nitro-v2/config-fixture/evlog.config.ts @@ -0,0 +1,13 @@ +import { appendFileSync } from 'node:fs' +import { defineEvlog } from 'evlog' + +export default defineEvlog({ + service: 'from-config-file', + environment: 'from-config-file', + enrich: (ctx) => { + ctx.event.enrichedBy = 'evlog.config.ts' + }, + drain: (ctx) => { + appendFileSync(process.env.EVLOG_CONFIG_FIXTURE_OUT!, `${JSON.stringify(ctx.event)}\n`) + }, +}) diff --git a/packages/evlog/test/nitro-v2/config-fixture/nitro.config.ts b/packages/evlog/test/nitro-v2/config-fixture/nitro.config.ts new file mode 100644 index 000000000..e2201981a --- /dev/null +++ b/packages/evlog/test/nitro-v2/config-fixture/nitro.config.ts @@ -0,0 +1,11 @@ +import { resolve } from 'pathe' +import { defineNitroConfig } from 'nitropack/config' + +const evlogRoot = resolve(__dirname, '../../../src') + +export default defineNitroConfig({ + compatibilityDate: '2026-06-11', + alias: { + 'evlog': resolve(evlogRoot, 'index.ts'), + }, +}) diff --git a/packages/evlog/test/nitro-v2/config-fixture/routes/works.ts b/packages/evlog/test/nitro-v2/config-fixture/routes/works.ts new file mode 100644 index 000000000..a2d4bc902 --- /dev/null +++ b/packages/evlog/test/nitro-v2/config-fixture/routes/works.ts @@ -0,0 +1,3 @@ +import { defineEventHandler } from 'h3' + +export default defineEventHandler(() => ({ success: true })) diff --git a/packages/evlog/test/nitro/module-paths.test.ts b/packages/evlog/test/nitro/module-paths.test.ts index bca5edc9c..98c6c1e0e 100644 --- a/packages/evlog/test/nitro/module-paths.test.ts +++ b/packages/evlog/test/nitro/module-paths.test.ts @@ -1,3 +1,6 @@ +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { describe, expect, it } from 'vitest' import nitroV2Module from '../../src/nitro/module' import nitroV3Module from '../../src/nitro-v3/module' @@ -13,6 +16,8 @@ describe('nitro modules avoid backslash paths', () => { function makeNitroStub() { return { options: { + rootDir: mkdtempSync(join(tmpdir(), 'evlog-module-paths-')), + virtual: {} as Record, plugins: [] as string[], errorHandler: undefined as string | string[] | undefined, noExternals: undefined as undefined | true | string[], diff --git a/packages/evlog/test/nuxt/config-file.test.ts b/packages/evlog/test/nuxt/config-file.test.ts new file mode 100644 index 000000000..7e8c9e4b2 --- /dev/null +++ b/packages/evlog/test/nuxt/config-file.test.ts @@ -0,0 +1,69 @@ +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { EVLOG_CONFIG_PLUGIN_ID } from '../../src/shared/configPlugin' + +vi.mock('@nuxt/kit', () => ({ + addImports: vi.fn(), + addPlugin: vi.fn(), + addServerHandler: vi.fn(), + addServerImports: vi.fn(), + addServerPlugin: vi.fn(), + addTypeTemplate: vi.fn(), + addVitePlugin: vi.fn(), + createResolver: vi.fn(() => ({ resolve: (path: string) => path })), + defineNuxtModule: (definition: { setup: (options: unknown, nuxt: unknown) => unknown }) => definition, +})) + +/** Runs the module's setup against a fake Nuxt and returns the handler it registered for `name`. */ +async function registeredHook(name: string, rootDir: string): Promise<(...args: unknown[]) => void> { + const moduleDefinition = (await import('../../src/nuxt/module')).default as unknown as { + setup: (options: Record, nuxt: unknown) => void + } + const hook = vi.fn() + moduleDefinition.setup({}, { + hook, + options: { dev: false, rootDir, buildDir: join(rootDir, '.nuxt'), runtimeConfig: { public: {} } }, + }) + const call = hook.mock.calls.find(([hookName]) => hookName === name) + if (!call) throw new Error(`the module registers no ${name} hook`) + return call[1] +} + +function appWithConfig(): string { + const rootDir = mkdtempSync(join(tmpdir(), 'evlog-nuxt-config-')) + writeFileSync(join(rootDir, 'evlog.config.ts'), 'export default {}\n') + return rootDir +} + +describe('nuxt module and evlog.config.ts', () => { + it('loads the file into the server once Nitro options are resolved', async () => { + const rootDir = appWithConfig() + const nitroInit = await registeredHook('nitro:init', rootDir) + + const nitro = { options: { rootDir, plugins: ['/evlog/nitro/plugin'], virtual: {} as Record } } + nitroInit(nitro) + + expect(nitro.options.plugins).toEqual([EVLOG_CONFIG_PLUGIN_ID, '/evlog/nitro/plugin']) + expect(nitro.options.virtual[EVLOG_CONFIG_PLUGIN_ID]).toContain(join(rootDir, 'evlog.config.ts')) + }) + + it('adds the file to the tsconfig nuxt.config.ts is checked with', async () => { + const prepareTypes = await registeredHook('prepare:types', appWithConfig()) + + const nodeTsConfig: { include?: string[] } = { include: ['../nuxt.config.*'] } + prepareTypes({ nodeTsConfig }) + + expect(nodeTsConfig.include).toEqual(['../nuxt.config.*', '../evlog.config.ts']) + }) + + it('leaves the tsconfig alone when the app has no config file', async () => { + const prepareTypes = await registeredHook('prepare:types', mkdtempSync(join(tmpdir(), 'evlog-nuxt-config-'))) + + const nodeTsConfig: { include?: string[] } = { include: ['../nuxt.config.*'] } + prepareTypes({ nodeTsConfig }) + + expect(nodeTsConfig.include).toEqual(['../nuxt.config.*']) + }) +}) diff --git a/packages/evlog/test/shared/configPlugin.test.ts b/packages/evlog/test/shared/configPlugin.test.ts new file mode 100644 index 000000000..3abb6c8bf --- /dev/null +++ b/packages/evlog/test/shared/configPlugin.test.ts @@ -0,0 +1,151 @@ +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import nitroV2Module from '../../src/nitro/module' +import nitroV3Module from '../../src/nitro-v3/module' +import { + EVLOG_CONFIG_PLUGIN_ID, + evlogConfigPluginSource, + findEvlogConfigFile, + registerEvlogConfigPlugin, +} from '../../src/shared/configPlugin' + +function makeDir(): string { + return mkdtempSync(join(tmpdir(), 'evlog-config-plugin-')) +} + +function touch(file: string, content = 'export default {}\n'): string { + writeFileSync(file, content) + return file +} + +/** A pnpm workspace with one app at `apps/web`. */ +function makeWorkspace(): { root: string, app: string } { + const root = makeDir() + touch(join(root, 'package.json'), '{"private":true}') + touch(join(root, 'pnpm-workspace.yaml'), 'packages:\n - apps/*\n') + const app = join(root, 'apps', 'web') + mkdirSync(app, { recursive: true }) + touch(join(app, 'package.json'), '{"name":"web"}') + return { root, app } +} + +function makeNitroStub(rootDir: string) { + return { + options: { + rootDir, + virtual: {} as Record, + plugins: [] as string[], + errorHandler: undefined as string | string[] | undefined, + noExternals: undefined as undefined | true | string[], + runtimeConfig: {} as Record, + replace: {} as Record, + }, + } +} + +describe('findEvlogConfigFile', () => { + it('finds the file in the app directory', () => { + const dir = makeDir() + const file = touch(join(dir, 'evlog.config.ts')) + expect(findEvlogConfigFile(dir)).toBe(file) + }) + + it('looks the names up in the same order as the CLI', () => { + const dir = makeDir() + touch(join(dir, 'evlog.config.mjs')) + const ts = touch(join(dir, 'evlog.config.ts')) + expect(findEvlogConfigFile(dir)).toBe(ts) + }) + + it('falls back to the workspace root config', () => { + const { root, app } = makeWorkspace() + const file = touch(join(root, 'evlog.config.ts')) + expect(findEvlogConfigFile(app)).toBe(file) + }) + + it('prefers the nearest config over the workspace root one', () => { + const { root, app } = makeWorkspace() + touch(join(root, 'evlog.config.ts')) + const own = touch(join(app, 'evlog.config.ts')) + expect(findEvlogConfigFile(app)).toBe(own) + }) + + it('treats a package.json with a workspaces field as the workspace root', () => { + const root = makeDir() + touch(join(root, 'package.json'), '{"workspaces":["apps/*"]}') + const app = join(root, 'apps', 'web') + mkdirSync(app, { recursive: true }) + const file = touch(join(root, 'evlog.config.mjs')) + expect(findEvlogConfigFile(app)).toBe(file) + }) + + it('does not look above the workspace root', () => { + const outer = makeDir() + touch(join(outer, 'evlog.config.ts')) + const root = join(outer, 'repo') + mkdirSync(join(root, 'apps', 'web'), { recursive: true }) + touch(join(root, 'package.json'), '{"private":true}') + touch(join(root, 'pnpm-workspace.yaml'), 'packages:\n - apps/*\n') + expect(findEvlogConfigFile(join(root, 'apps', 'web'))).toBeUndefined() + }) + + it('only searches the app directory outside a workspace', () => { + const outer = makeDir() + touch(join(outer, 'evlog.config.ts')) + const app = join(outer, 'app') + mkdirSync(app) + expect(findEvlogConfigFile(app)).toBeUndefined() + }) +}) + +describe('evlogConfigPluginSource', () => { + it('imports the file and hands its default export over through the global slot', () => { + expect(evlogConfigPluginSource('/app/evlog.config.ts')).toBe([ + 'import config from "/app/evlog.config.ts"', + 'globalThis[Symbol.for("evlog.config.file")] = config', + 'export default function evlogConfig() {}', + '', + ].join('\n')) + }) + + it('writes Windows paths with forward slashes', () => { + const source = evlogConfigPluginSource('C:\\app\\evlog.config.ts') + expect(source).toContain('"C:/app/evlog.config.ts"') + expect(source).not.toMatch(/\\/) + }) +}) + +describe('registerEvlogConfigPlugin', () => { + it('leaves Nitro untouched when the app has no config', () => { + const nitro = makeNitroStub(makeDir()) + expect(registerEvlogConfigPlugin(nitro)).toBeUndefined() + expect(nitro.options.plugins).toEqual([]) + expect(nitro.options.virtual).toEqual({}) + }) + + it('registers the virtual plugin first, before plugins already registered', () => { + const dir = makeDir() + const file = touch(join(dir, 'evlog.config.ts')) + const nitro = makeNitroStub(dir) + nitro.options.plugins.push('/evlog/nitro/plugin') + + expect(registerEvlogConfigPlugin(nitro)).toBe(file) + expect(nitro.options.plugins).toEqual([EVLOG_CONFIG_PLUGIN_ID, '/evlog/nitro/plugin']) + expect(nitro.options.virtual[EVLOG_CONFIG_PLUGIN_ID]).toBe(evlogConfigPluginSource(file)) + }) + + it.each([ + ['nitro v2', nitroV2Module], + ['nitro v3', nitroV3Module], + ])('is called by the %s module', (_name, module) => { + const dir = makeDir() + touch(join(dir, 'evlog.config.ts')) + const nitro = makeNitroStub(dir) + module({}).setup(nitro as never) + + expect(nitro.options.plugins[0]).toBe(EVLOG_CONFIG_PLUGIN_ID) + expect(nitro.options.plugins[1]).toMatch(/\/plugin$/) + }) +}) diff --git a/packages/evlog/test/shared/fileConfig.test.ts b/packages/evlog/test/shared/fileConfig.test.ts new file mode 100644 index 000000000..6fe1880d6 --- /dev/null +++ b/packages/evlog/test/shared/fileConfig.test.ts @@ -0,0 +1,119 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { EvlogConfig } from '../../src/shared/define' +import { + EVLOG_FILE_CONFIG_KEY, + EVLOG_FILE_CONFIG_PLUGIN, + evlogFileConfigPlugins, + flushEvlogFileConfigDrain, + readEvlogFileConfig, + withEvlogFileConfig, +} from '../../src/shared/fileConfig' +import { definePlugin } from '../../src/shared/plugin' +import { createDrainPipeline } from '../../src/pipeline' +import type { DrainContext } from '../../src/types' + +const slot = Symbol.for(EVLOG_FILE_CONFIG_KEY) + +function loadFile(config: EvlogConfig): void { + (globalThis as Record)[slot] = config +} + +afterEach(() => { + delete (globalThis as Record)[slot] +}) + +describe('withEvlogFileConfig', () => { + it('returns the module options as they are without a file', () => { + const options = { env: { service: 'api' } } + expect(readEvlogFileConfig()).toBeUndefined() + expect(withEvlogFileConfig(options)).toBe(options) + expect(withEvlogFileConfig(undefined)).toBeUndefined() + }) + + it('maps service and environment of the file into env', () => { + loadFile({ service: 'checkout', environment: 'staging', sampling: { rates: { info: 10 } } }) + expect(withEvlogFileConfig(undefined)).toEqual({ + env: { service: 'checkout', environment: 'staging' }, + sampling: { rates: { info: 10 } }, + }) + }) + + it('lays the module options over the file', () => { + loadFile({ + service: 'checkout', + environment: 'staging', + silent: true, + sampling: { rates: { info: 10, warn: 50 } }, + redact: { paths: ['user.email'] }, + }) + expect(withEvlogFileConfig({ + env: { environment: 'production' }, + sampling: { rates: { info: 100 } }, + redact: { paths: ['card.number'] }, + })).toEqual({ + env: { service: 'checkout', environment: 'production' }, + silent: true, + sampling: { rates: { info: 100, warn: 50 } }, + redact: { paths: ['user.email', 'card.number'] }, + }) + }) + + it('leaves out the CLI sections and the functions', () => { + loadFile({ + service: 'checkout', + map: { minScore: 80 }, + logs: { dir: '.evlog/logs' }, + drain: vi.fn(), + enrich: vi.fn(), + keep: vi.fn(), + plugins: [definePlugin({ name: 'audit' })], + waitUntil: vi.fn(), + }) + expect(withEvlogFileConfig(undefined)).toEqual({ env: { service: 'checkout' } }) + }) +}) + +describe('evlogFileConfigPlugins', () => { + it('is undefined without a file', () => { + expect(evlogFileConfigPlugins()).toBeUndefined() + }) + + it('is empty when the file has no plugin, drain, enrich or keep', () => { + loadFile({ service: 'checkout' }) + expect(evlogFileConfigPlugins()).toEqual([]) + }) + + it('carries drain, enrich and keep in one plugin after the plugins of the file', () => { + const drain = vi.fn() + const enrich = vi.fn() + const keep = vi.fn() + const audit = definePlugin({ name: 'audit' }) + loadFile({ drain, enrich, keep, plugins: [audit] }) + + expect(evlogFileConfigPlugins()).toEqual([ + audit, + { name: EVLOG_FILE_CONFIG_PLUGIN, drain, enrich, keep }, + ]) + }) +}) + +describe('flushEvlogFileConfigDrain', () => { + it('delivers the events a pipeline drain of the file still buffers', async () => { + const send = vi.fn() + const drain = createDrainPipeline({ batch: { size: 50, intervalMs: 60_000 } })(send) + loadFile({ drain }) + + drain({ event: { timestamp: '', level: 'info', service: 'checkout', environment: 'test' } }) + expect(send).not.toHaveBeenCalled() + + await flushEvlogFileConfigDrain() + expect(send).toHaveBeenCalledOnce() + expect(drain.pending).toBe(0) + }) + + it('resolves without a file or with a drain that does not buffer', async () => { + await expect(flushEvlogFileConfigDrain()).resolves.toBeUndefined() + loadFile({ drain: vi.fn() }) + await expect(flushEvlogFileConfigDrain()).resolves.toBeUndefined() + }) +}) diff --git a/skills/analyze-logs/SKILL.md b/skills/analyze-logs/SKILL.md index caf87604d..d92227c5a 100644 --- a/skills/analyze-logs/SKILL.md +++ b/skills/analyze-logs/SKILL.md @@ -75,12 +75,11 @@ npx evlog init --yes --drain fs # apply Ask before running it. On other frameworks (or if the user declines), guide the manual setup: ```typescript +import { defineEvlog } from 'evlog' import { createFsDrain } from 'evlog/fs' -// Nuxt / Nitro: server/plugins/evlog-drain.ts -export default defineNitroPlugin((nitroApp) => { - nitroApp.hooks.hook('evlog:drain', createFsDrain()) -}) +// Nuxt / Nitro: evlog.config.ts at the app root, which the module loads +export default defineEvlog({ drain: createFsDrain() }) // Hono / Express / Elysia: pass in middleware options app.use(evlog({ drain: createFsDrain() })) diff --git a/skills/review-logging-patterns/SKILL.md b/skills/review-logging-patterns/SKILL.md index 3e4d0230b..134062a94 100644 --- a/skills/review-logging-patterns/SKILL.md +++ b/skills/review-logging-patterns/SKILL.md @@ -140,7 +140,20 @@ export default defineEventHandler(async (event) => { }) ``` -Drain, enrich, and tail sampling use Nitro hooks in server plugins: +Drain, enrich, sampling and redaction can live in `evlog.config.ts` at the app root. The module loads it on the server, and the `evlog` key in `nuxt.config.ts` overrides it. The browser logger reads only the `evlog` key. + +```typescript +// evlog.config.ts +import { defineEvlog } from 'evlog' +import { createAxiomDrain } from 'evlog/axiom' + +export default defineEvlog({ + service: 'my-app', + drain: createAxiomDrain(), +}) +``` + +Nitro hooks in server plugins keep working next to the file, and an event reaches both drains: ```typescript // server/plugins/evlog-drain.ts @@ -417,7 +430,7 @@ export default defineNitroConfig({ }) ``` -Import `useLogger` from `evlog/nitro` in routes. +Import `useLogger` from `evlog/nitro` in routes. On Nitro v2 and v3, an `evlog.config.ts` at the app root is loaded by the module, and the options passed to `evlog()` override it. ### NestJS @@ -899,6 +912,8 @@ log.emit() // Manual emit required in standalone All options work in Nuxt (`evlog` key), Nitro (passed to `evlog()`), Next.js (`createEvlog()`), and standalone (`initLogger()`). +They can also live in `evlog.config.ts`, written with `defineEvlog()`. Nuxt and Nitro load the file on their own, with module options overriding it. Other frameworks import it and pass `toLoggerConfig(config)` to `initLogger()` and `toMiddlewareOptions(config)` to the middleware. `evlog config` prints what it resolves to, and `extends` shares a base config across apps (one level). Docs: https://www.evlog.dev/cli/config + | Option | Type | Default | Description | |--------|------|---------|-------------| | `env.service` / `service` | `string` | `'app'` | Service name in logs | From f5522426827ef2f33ac2d53495d4a5e0421ccd31 Mon Sep 17 00:00:00 2001 From: Hugo Richard Date: Sun, 11 Oct 2026 08:24:26 +0100 Subject: [PATCH 2/6] fix(nuxt): read the browser settings from evlog.config.ts --- .changeset/config-file-runtime.md | 2 +- apps/docs/content/3.cli/11.config.md | 2 +- packages/evlog/src/nuxt/module.ts | 3 +- .../evlog/src/runtime/server/public-config.ts | 26 +++++++ packages/evlog/test/nuxt/config-file.test.ts | 33 ++++++-- .../evlog/test/nuxt/public-config.test.ts | 78 +++++++++++++++++++ packages/evlog/tsdown.config.ts | 1 + 7 files changed, 135 insertions(+), 10 deletions(-) create mode 100644 packages/evlog/src/runtime/server/public-config.ts create mode 100644 packages/evlog/test/nuxt/public-config.test.ts diff --git a/.changeset/config-file-runtime.md b/.changeset/config-file-runtime.md index 9b487f649..45ae7c1b2 100644 --- a/.changeset/config-file-runtime.md +++ b/.changeset/config-file-runtime.md @@ -2,4 +2,4 @@ "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 file applies to the server only, and the browser logger keeps reading the `evlog` key. `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. +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. diff --git a/apps/docs/content/3.cli/11.config.md b/apps/docs/content/3.cli/11.config.md index 21a91ba45..896ea6008 100644 --- a/apps/docs/content/3.cli/11.config.md +++ b/apps/docs/content/3.cli/11.config.md @@ -252,7 +252,7 @@ This app keeps every info event whatever the file says, and the other rates in t 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 file applies to the server. The browser logger keeps reading `enabled`, `pretty`, `console`, `minLevel` and `transport` from the `evlog` key in `nuxt.config.ts`. +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 diff --git a/packages/evlog/src/nuxt/module.ts b/packages/evlog/src/nuxt/module.ts index 67366a745..7810c2d89 100644 --- a/packages/evlog/src/nuxt/module.ts +++ b/packages/evlog/src/nuxt/module.ts @@ -386,7 +386,7 @@ export default defineNuxtModule({ } }) nuxt.options.runtimeConfig.public.evlog = { - enabled: options.enabled ?? true, + enabled: options.enabled, console: options.console, pretty: options.pretty, minLevel: options.minLevel, @@ -419,6 +419,7 @@ export default defineNuxtModule({ } addServerPlugin(resolver.resolve('../nitro/plugin')) + addServerPlugin(resolver.resolve('../runtime/server/public-config')) // @ts-expect-error nitro:init hook exists but is not in NuxtHooks type nuxt.hook('nitro:init', (nitro: Nitro) => { registerEvlogConfigPlugin(nitro) diff --git a/packages/evlog/src/runtime/server/public-config.ts b/packages/evlog/src/runtime/server/public-config.ts new file mode 100644 index 000000000..e61463f29 --- /dev/null +++ b/packages/evlog/src/runtime/server/public-config.ts @@ -0,0 +1,26 @@ +import { defineNitroPlugin, useRuntimeConfig } from 'nitropack/runtime' +import { readEvlogFileConfig } from '../../shared/fileConfig' + +const BROWSER_KEYS = ['enabled', 'pretty', 'minLevel'] as const + +/** + * Hands the browser settings of `evlog.config.ts` to the client plugin. The + * file is only evaluated by the server, so each request copies them into + * `runtimeConfig.public.evlog`, skipping the keys that `nuxt.config.ts` or a + * `NUXT_PUBLIC_EVLOG_*` variable already set. Nitro serves a key left unset + * as `''`. + */ +export default defineNitroPlugin((nitroApp) => { + const file = readEvlogFileConfig() + if (!file) return + const fromFile = BROWSER_KEYS.filter(key => file[key] !== undefined) + if (fromFile.length === 0) return + + nitroApp.hooks.hook('request', (event) => { + const browser = useRuntimeConfig(event).public?.evlog as Record | undefined + if (!browser) return + for (const key of fromFile) { + if (browser[key] === '' || browser[key] === undefined) browser[key] = file[key] + } + }) +}) diff --git a/packages/evlog/test/nuxt/config-file.test.ts b/packages/evlog/test/nuxt/config-file.test.ts index 7e8c9e4b2..f24974941 100644 --- a/packages/evlog/test/nuxt/config-file.test.ts +++ b/packages/evlog/test/nuxt/config-file.test.ts @@ -1,6 +1,7 @@ import { mkdtempSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { addServerPlugin } from '@nuxt/kit' import { describe, expect, it, vi } from 'vitest' import { EVLOG_CONFIG_PLUGIN_ID } from '../../src/shared/configPlugin' @@ -16,16 +17,22 @@ vi.mock('@nuxt/kit', () => ({ defineNuxtModule: (definition: { setup: (options: unknown, nuxt: unknown) => unknown }) => definition, })) -/** Runs the module's setup against a fake Nuxt and returns the handler it registered for `name`. */ -async function registeredHook(name: string, rootDir: string): Promise<(...args: unknown[]) => void> { +/** Runs the module's setup against a fake Nuxt and returns that Nuxt. */ +async function setupModule(rootDir: string) { const moduleDefinition = (await import('../../src/nuxt/module')).default as unknown as { setup: (options: Record, nuxt: unknown) => void } - const hook = vi.fn() - moduleDefinition.setup({}, { - hook, - options: { dev: false, rootDir, buildDir: join(rootDir, '.nuxt'), runtimeConfig: { public: {} } }, - }) + const nuxt = { + hook: vi.fn(), + options: { dev: false, rootDir, buildDir: join(rootDir, '.nuxt'), runtimeConfig: { public: {} as Record } }, + } + moduleDefinition.setup({}, nuxt) + return nuxt +} + +/** Runs the module's setup against a fake Nuxt and returns the handler it registered for `name`. */ +async function registeredHook(name: string, rootDir: string): Promise<(...args: unknown[]) => void> { + const { hook } = await setupModule(rootDir) const call = hook.mock.calls.find(([hookName]) => hookName === name) if (!call) throw new Error(`the module registers no ${name} hook`) return call[1] @@ -49,6 +56,18 @@ describe('nuxt module and evlog.config.ts', () => { expect(nitro.options.virtual[EVLOG_CONFIG_PLUGIN_ID]).toContain(join(rootDir, 'evlog.config.ts')) }) + it('registers the server plugin that hands the browser settings of the file to the client', async () => { + await registeredHook('nitro:init', appWithConfig()) + + expect(vi.mocked(addServerPlugin)).toHaveBeenCalledWith('../runtime/server/public-config') + }) + + it('leaves the browser settings nuxt.config.ts does not set for the file to fill', async () => { + const nuxt = await setupModule(appWithConfig()) + + expect(nuxt.options.runtimeConfig.public.evlog).toMatchObject({ enabled: undefined, pretty: undefined, minLevel: undefined }) + }) + it('adds the file to the tsconfig nuxt.config.ts is checked with', async () => { const prepareTypes = await registeredHook('prepare:types', appWithConfig()) diff --git a/packages/evlog/test/nuxt/public-config.test.ts b/packages/evlog/test/nuxt/public-config.test.ts new file mode 100644 index 000000000..c152573b1 --- /dev/null +++ b/packages/evlog/test/nuxt/public-config.test.ts @@ -0,0 +1,78 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { EvlogConfig } from '../../src/shared/define' +import { EVLOG_FILE_CONFIG_KEY } from '../../src/shared/fileConfig' + +interface RuntimeConfig { + public: { evlog?: Record } +} + +vi.mock('nitropack/runtime', () => ({ + defineNitroPlugin: (plugin: unknown) => plugin, + useRuntimeConfig: (event: { config: RuntimeConfig }) => event.config, +})) + +const { default: publicConfigPlugin } = await import('../../src/runtime/server/public-config') as unknown as { + default: (nitroApp: { hooks: { hook: (name: string, handler: (event: unknown) => void) => void } }) => void +} + +function loadFile(config: EvlogConfig | undefined): void { + (globalThis as Record)[Symbol.for(EVLOG_FILE_CONFIG_KEY)] = config +} + +/** The runtime config Nitro serves for these `nuxt.config.ts` options, where a key left unset reads as `''`. */ +function runtimeConfigFor(options: EvlogConfig): RuntimeConfig { + return { + public: { evlog: { enabled: options.enabled ?? '', pretty: options.pretty ?? '', minLevel: options.minLevel ?? '' } }, + } +} + +/** Starts the plugin and returns its `request` hook, if it registered one. */ +function requestHook(): ((event: { config: RuntimeConfig }) => void) | undefined { + const hook = vi.fn() + publicConfigPlugin({ hooks: { hook } }) + return hook.mock.calls.find(([name]) => name === 'request')?.[1] +} + +describe('nuxt browser settings from evlog.config.ts', () => { + afterEach(() => { + loadFile(undefined) + }) + + it('copies the browser settings of the file into the public runtime config', () => { + loadFile({ enabled: false, pretty: false, minLevel: 'warn' }) + const config = runtimeConfigFor({}) + + requestHook()?.({ config }) + + expect(config.public.evlog).toEqual({ enabled: false, pretty: false, minLevel: 'warn' }) + }) + + it('keeps what nuxt.config.ts sets', () => { + loadFile({ enabled: false, minLevel: 'warn' }) + const config = runtimeConfigFor({ enabled: true, minLevel: 'error' }) + + requestHook()?.({ config }) + + expect(config.public.evlog).toEqual({ enabled: true, pretty: '', minLevel: 'error' }) + }) + + it('keeps what a NUXT_PUBLIC_EVLOG variable sets', () => { + loadFile({ enabled: false, minLevel: 'warn' }) + const config = runtimeConfigFor({}) + config.public.evlog = { enabled: true, pretty: '', minLevel: 'debug' } + + requestHook()?.({ config }) + + expect(config.public.evlog).toEqual({ enabled: true, pretty: '', minLevel: 'debug' }) + }) + + it('registers nothing when the file sets no browser setting', () => { + loadFile({ service: 'shop' }) + + expect(requestHook()).toBeUndefined() + }) + + it('registers nothing when the app has no config file', () => { + expect(requestHook()).toBeUndefined() + }) +}) diff --git a/packages/evlog/tsdown.config.ts b/packages/evlog/tsdown.config.ts index 169da05a1..d2a24923b 100644 --- a/packages/evlog/tsdown.config.ts +++ b/packages/evlog/tsdown.config.ts @@ -16,6 +16,7 @@ export default defineConfig({ 'runtime/client/log': 'src/runtime/client/log.ts', 'runtime/client/plugin': 'src/runtime/client/plugin.ts', 'runtime/server/useLogger': 'src/runtime/server/useLogger.ts', + 'runtime/server/public-config': 'src/runtime/server/public-config.ts', 'runtime/server/routes/_evlog/ingest.post': 'src/runtime/server/routes/_evlog/ingest.post.ts', 'runtime/server/routes/_evlog/stream-info.get': 'src/runtime/server/routes/_evlog/stream-info.get.ts', 'runtime/utils/parseError': 'src/runtime/utils/parseError.ts', From c49990eb4d45ae5bb2b66d5793c5c0e9a216079d Mon Sep 17 00:00:00 2001 From: Hugo Richard Date: Sun, 11 Oct 2026 08:24:26 +0100 Subject: [PATCH 3/6] fix(cli): show the module options that override evlog.config.ts in evlog config --- .changeset/cli-config-module-options.md | 5 ++ apps/docs/content/3.cli/11.config.md | 20 ++++- packages/cli/src/commands/config.ts | 97 +++++++++++++++++++++---- packages/cli/src/lib/config/index.ts | 29 +++++++- packages/cli/src/lib/config/read.ts | 64 ++++++++++++++++ packages/cli/test/config.test.ts | 88 ++++++++++++++++++++++ 6 files changed, 286 insertions(+), 17 deletions(-) create mode 100644 .changeset/cli-config-module-options.md diff --git a/.changeset/cli-config-module-options.md b/.changeset/cli-config-module-options.md new file mode 100644 index 000000000..c8170e5e8 --- /dev/null +++ b/.changeset/cli-config-module-options.md @@ -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. diff --git a/apps/docs/content/3.cli/11.config.md b/apps/docs/content/3.cli/11.config.md index 896ea6008..6fa0fa9f2 100644 --- a/apps/docs/content/3.cli/11.config.md +++ b/apps/docs/content/3.cli/11.config.md @@ -91,7 +91,7 @@ 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 evlog finds the file @@ -248,7 +248,23 @@ export default defineNuxtConfig({ }) ``` -This app keeps every info event whatever the file says, and the other rates in the file still apply. Keep a setting in one of the two places: `evlog config` lists only the file, so a value overridden in `nuxt.config.ts` is not the one it shows. +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. diff --git a/packages/cli/src/commands/config.ts b/packages/cli/src/commands/config.ts index f4ec6d65e..720df2019 100644 --- a/packages/cli/src/commands/config.ts +++ b/packages/cli/src/commands/config.ts @@ -1,7 +1,7 @@ import type { CliContext } from '../core/context' import { createStyle } from '../core/output' import { defineEvlogCommand, failWith } from '../lib/command' -import { formatPath, isAppendedList, loadCliConfig, RuntimeValue } from '../lib/config' +import { formatPath, isAppendedList, loadCliConfig, loadModuleOptions, RuntimeValue } from '../lib/config' import { isPlainObject } from '../lib/config/read' import type { CliDebug } from '../lib/debug' import { createNoopCliDebug } from '../lib/debug' @@ -17,6 +17,8 @@ export interface ConfigEntry { value: unknown /** Where the setting is written, as `file:line`, or `null` when it comes from a file that only contributes values. */ source: string | null + /** What `evlog.config` sets at the same path, when the module options replace it. */ + overrides?: { value: unknown, source: string | null } } /** Typed result of `evlog config`, rendered by {@link formatConfigReport}. */ @@ -26,9 +28,15 @@ export interface ConfigResult { /** Relative to the working directory, or `null` when no config applies. */ file: string | null extends: { specifier: string | null, file: string } | null + /** + * The framework config that passes options to the evlog module, which win + * over the file key by key. `readable` is false when they are computed at + * runtime, so `app` shows the file alone. + */ + moduleOptions: { file: string, readable: boolean } | null /** Settings the CLI applies itself (`map`, `logs`). */ cli: ConfigEntry[] - /** Settings the app applies at runtime. */ + /** Settings the app applies at runtime, with the module options laid over the file. */ app: ConfigEntry[] /** Where each key is written, by {@link formatPath}, for rows that stand for a whole object such as `redact`. */ sources: ReadonlyMap @@ -55,6 +63,45 @@ function entriesOf(value: unknown, path: ConfigPath, sources: ReadonlyMap, sources: ReadonlyMap): ConfigEntry[] { + return Object.entries(value).flatMap(([key, child]) => entriesOf(child, [key], sources)) +} + +/** The path a setting reaches the app under: the module folds `service` and `environment` into `env`. */ +function runtimePath(path: ConfigPath): ConfigPath { + return path.length === 1 && (path[0] === 'service' || path[0] === 'environment') ? ['env', path[0]] : path +} + +/** + * Lay the module options over the file's settings the way the module does: an + * option replaces what the file sets at its path and below, objects merge key + * by key, and the items of an appended list follow the file's. + */ +function layerEntries(file: ConfigEntry[], options: ConfigEntry[]): ConfigEntry[] { + let out = [...file] + for (const option of options) { + if (option.value === undefined) continue + const index = option.path.at(-1) + const list = option.path.slice(0, -1) + const path = typeof index === 'number' && isAppendedList(list) + ? [...list, index + file.filter(entry => entry.path.length === option.path.length && startsWith(entry.path, list)).length] + : option.path + const below = out.filter(entry => startsWith(runtimePath(entry.path), path)) + /* An empty object adds nothing, and `redact: true` keeps a redact object the file sets. */ + const keepsFile = isPlainObject(option.value) + || (option.value === true && path.length === 1 && path[0] === 'redact' && below.some(entry => entry.path.length > 1 || isPlainObject(entry.value))) + if (below.length > 0 && keepsFile) continue + const replaced = out.filter(entry => startsWith(runtimePath(entry.path), path) || startsWith(path, runtimePath(entry.path))) + const exactly = replaced.filter(entry => runtimePath(entry.path).length === path.length) + /* A top-level `service` wins over the file's own `env.service`, so it is the value the option replaces. */ + const previous = exactly.find(entry => entry.path.length === 1) ?? exactly[0] + const at = replaced.length > 0 ? out.indexOf(replaced[0]!) : out.length + out = out.filter(entry => !replaced.includes(entry)) + out.splice(at, 0, { ...option, path, ...(previous && { overrides: { value: previous.value, source: previous.source } }) }) + } + return out +} + /** * Read the `evlog.config` that applies to `ctx.cwd`, merged across `extends`, * with where each setting is written. Pure with respect to the context. @@ -65,16 +112,20 @@ export async function runConfig(ctx: CliContext, log: CliDebug = createNoopCliDe const project = await log.step('resolveProject', () => resolveProject(ctx.cwd), p => ({ project: { root: p.root, packageDir: p.packageDir } })) const searched = { from: prettyPath(ctx.cwd, project.packageDir), to: prettyPath(ctx.cwd, project.root) } const config = await log.step('loadConfig', () => loadCliConfig(project), c => ({ config: c && { file: c.file, extends: c.extends?.file ?? null } })) - if (!config) return { searched, file: null, extends: null, cli: [], app: [], sources: new Map() } + if (!config) return { searched, file: null, extends: null, moduleOptions: null, cli: [], app: [], sources: new Map() } + const options = await log.step('loadModuleOptions', () => loadModuleOptions(project), o => ({ moduleOptions: o && { file: o.file, readable: !(o.value instanceof RuntimeValue) } })) + const layered = options && !(options.value instanceof RuntimeValue) ? { value: options.value, sources: options.sources } : null - const entries = Object.entries(config.resolved).flatMap(([key, value]) => entriesOf(value, [key], config.sources)) + const entries = entriesFor(config.resolved, config.sources) + const app = entries.filter(entry => !CLI_SECTIONS.has(entry.path[0])) return { searched, file: prettyPath(ctx.cwd, config.file), extends: config.extends && { specifier: config.extends.specifier, file: prettyPath(ctx.cwd, config.extends.file) }, + moduleOptions: options && { file: prettyPath(ctx.cwd, options.file), readable: layered !== null }, cli: entries.filter(entry => CLI_SECTIONS.has(entry.path[0])), - app: entries.filter(entry => !CLI_SECTIONS.has(entry.path[0])), - sources: config.sources, + app: layered ? layerEntries(app, entriesFor(layered.value, layered.sources).filter(entry => !CLI_SECTIONS.has(entry.path[0]))) : app, + sources: layered ? new Map([...layered.sources, ...config.sources]) : config.sources, } } @@ -101,8 +152,13 @@ function jsonValue(value: unknown): unknown { /** The `--json` payload: entries keyed by path, values JSON-safe. */ export function configJson(result: ConfigResult): Record { - const settings = (entries: ConfigEntry[]) => entries.map(entry => ({ path: formatPath(entry.path), value: jsonValue(entry.value), source: entry.source })) - return { file: result.file, extends: result.extends, cli: settings(result.cli), app: settings(result.app) } + const settings = (entries: ConfigEntry[]) => entries.map(entry => ({ + path: formatPath(entry.path), + value: jsonValue(entry.value), + source: entry.source, + ...(entry.overrides && { overrides: { value: jsonValue(entry.overrides.value), source: entry.overrides.source } }), + })) + return { file: result.file, extends: result.extends, moduleOptions: result.moduleOptions, cli: settings(result.cli), app: settings(result.app) } } interface Row { @@ -116,6 +172,8 @@ interface Row { /** What the setting covers, under the row. */ note?: string warning?: string + /** The file's value the module options replace, under the row. */ + overrides?: string } interface Section { @@ -145,13 +203,17 @@ function exact(entries: ConfigEntry[], path: ConfigPath): ConfigEntry | undefine return entries.find(entry => entry.path.length === path.length && startsWith(entry.path, path)) } +function overridesNote(entry: ConfigEntry | undefined): string | undefined { + return entry?.overrides && `overrides ${formatValue(entry.overrides.value)} at ${entry.overrides.source ?? 'the config'}` +} + function settingRow(entry: ConfigEntry, label = formatPath(entry.path)): Row { - return { label, value: formatValue(entry.value), source: entry.source, runtime: entry.value instanceof RuntimeValue } + return { label, value: formatValue(entry.value), source: entry.source, runtime: entry.value instanceof RuntimeValue, overrides: overridesNote(entry) } } /** A setting that reads as text, a service name or a path, so a string goes unquoted. */ function textRow(entry: ConfigEntry, label: string): Row { - return typeof entry.value === 'string' ? { label, value: entry.value, source: entry.source } : settingRow(entry, label) + return typeof entry.value === 'string' ? { label, value: entry.value, source: entry.source, overrides: overridesNote(entry) } : settingRow(entry, label) } /** Rows for a list, the label on the first item only. */ @@ -211,7 +273,7 @@ function samplingSection(app: ConfigEntry[]): Section { } else if (!rate) { rows.push({ label: level, value: level === 'trace' ? '0%' : '100%', source: DEFAULT }) } else if (typeof rate.value === 'number') { - rows.push({ label: level, value: `${rate.value}%`, source: rate.source, warning: level === 'error' ? errorWarning(rate.value) : undefined }) + rows.push({ label: level, value: `${rate.value}%`, source: rate.source, warning: level === 'error' ? errorWarning(rate.value) : undefined, overrides: overridesNote(rate) }) } else { rows.push(settingRow(rate, level)) } @@ -236,8 +298,8 @@ function redactionSection(app: ConfigEntry[], sources: ReadonlyMap typeof value === 'string' ? value : formatValue(value)), ...listRows(patterns, 'patterns', formatValue), ...restRows(scope, new Set([root, builtins, ...paths, ...patterns])), @@ -277,6 +339,7 @@ function renderSection(section: Section, paint: Paint): string[] { const value = row.runtime ? paint('magenta', row.value) : row.muted ? paint('dim', row.value) : row.value const source = row.source ? `${' '.repeat(valueWidth - row.value.length)} ${paint('dim', row.source)}` : '' lines.push(` ${paint('cyan', row.label.padEnd(labelWidth))} ${value}${source}`) + if (row.overrides) lines.push(` ${' '.repeat(labelWidth)} ${paint('dim', row.overrides)}`) if (row.warning) lines.push(` ${' '.repeat(labelWidth)} ${paint('yellow', `⚠ ${row.warning}`)}`) if (row.note) lines.push(` ${' '.repeat(labelWidth)} ${paint('dim', row.note)}`) } @@ -301,6 +364,12 @@ export function formatConfigReport(ctx: CliContext, result: ConfigResult): strin const parent = result.extends && (result.extends.specifier ? `${result.extends.specifier} → ${result.extends.file}` : result.extends.file) const lines = [result.file + (parent ? paint('dim', ` · extends ${parent}`) : '')] + if (result.moduleOptions) { + const { file, readable } = result.moduleOptions + lines.push(paint('dim', readable + ? `${file} passes options to the evlog module, and they override this file` + : `${file} passes options to the evlog module that are computed at runtime, and they override this file`)) + } const sections: Section[] = [] if (result.app.length > 0) { sections.push(serviceSection(result.app), samplingSection(result.app), redactionSection(result.app, result.sources), pipelineSection(result.app)) diff --git a/packages/cli/src/lib/config/index.ts b/packages/cli/src/lib/config/index.ts index 9b3493e2e..b37880ab5 100644 --- a/packages/cli/src/lib/config/index.ts +++ b/packages/cli/src/lib/config/index.ts @@ -8,7 +8,7 @@ import type { CheckId } from '../map/types' import { prettyPath } from '../project' import type { ProjectInfo } from '../project' import type { ConfigDocument } from './read' -import { formatPath, isPlainObject, readConfig, RuntimeValue } from './read' +import { formatPath, isPlainObject, readConfig, readModuleOptions, RuntimeValue } from './read' export { formatPath, RuntimeValue } from './read' @@ -48,6 +48,18 @@ export interface CliConfig { logs: LogsSettings } +/** The options a framework config passes to the evlog module, which the module lays over `evlog.config`. */ +export interface ModuleOptions { + file: string + /** A {@link RuntimeValue} when the options are computed at runtime. */ + value: Record | RuntimeValue + /** Where each option is written, as `file:line`, by {@link formatPath}. */ + sources: ReadonlyMap +} + +/** Framework configs that can pass options to the evlog module, looked up in the package directory. */ +const MODULE_CONFIG_FILES = ['nuxt.config', 'nitro.config'].flatMap(base => ['ts', 'mts', 'js', 'mjs'].map(extension => `${base}.${extension}`)) + const MAP_KEYS = ['rules', 'ignore', 'minScore', 'baseline'] /** The map sorts entry points into instrumented, partial and dark by these, so neither can be turned off. */ const CORE_RULES: ReadonlySet = new Set(['wide-event', 'context']) @@ -84,6 +96,21 @@ export function findConfigFile(project: ProjectInfo): string | null { } } +/** + * Read the options `nuxt.config` or `nitro.config` passes to the evlog module + * in `project`, or `null` when it passes none. The module lays them over + * `evlog.config`, so they win key by key. + * + * @throws a `cli.CONFIG_PARSE_FAILED` error when the framework config does not parse. + */ +export function loadModuleOptions(project: ProjectInfo): ModuleOptions | null { + const file = MODULE_CONFIG_FILES.map(name => join(project.packageDir, name)).find(path => existsSync(path)) + const options = file ? readModuleOptions(file, path => prettyPath(project.cwd, path)) : null + if (!options) return null + const label = prettyPath(project.cwd, options.file) + return { file: options.file, value: options.value, sources: new Map([...options.lines].map(([path, line]) => [path, `${label}:${line}`])) } +} + type At = (path: string) => string function staticValue(value: unknown, key: string, at: At): unknown { diff --git a/packages/cli/src/lib/config/read.ts b/packages/cli/src/lib/config/read.ts index 3038c2115..cec5134a7 100644 --- a/packages/cli/src/lib/config/read.ts +++ b/packages/cli/src/lib/config/read.ts @@ -50,6 +50,15 @@ export function formatPath(path: readonly (string | number)[]): string { return out } +/** The options a framework config passes to the evlog module, as written. */ +export interface ModuleOptionsDocument { + file: string + /** A {@link RuntimeValue} when the options are computed at runtime. */ + value: Record | RuntimeValue + /** Line of each option, by {@link formatPath}. */ + lines: Map +} + export interface ReadConfigResult { config: ConfigDocument /** The config `config` extends, with the specifier it was imported from (`null` when it is defined in the same file). */ @@ -75,6 +84,7 @@ interface Located { /** Bounds how far a name is followed through bindings and re-exports, so a cycle ends. */ const MAX_HOPS = 8 const DEFINE_SOURCES = new Set(['evlog', 'evlog/toolkit']) +const NITRO_MODULE_SOURCES = new Set(['evlog/nitro', 'evlog/nitro/v3']) const WRAPPERS = new Set(['ParenthesizedExpression', 'TSAsExpression', 'TSSatisfiesExpression', 'TSNonNullExpression', 'TSTypeAssertion']) const SNIPPET_LENGTH = 60 @@ -241,6 +251,42 @@ class Reader { return null } + /** The object literal a framework config exports, through its `define*Config(...)` call and named bindings. */ + frameworkObject(module: Module, node: Expression, hops: number): Located | null { + if (hops > MAX_HOPS) return null + const expression = strip(node) + if (expression.type === 'ObjectExpression') return { module, node: expression } + if (expression.type === 'CallExpression') { + const [argument] = expression.arguments + return argument && argument.type !== 'SpreadElement' ? this.frameworkObject(module, argument, hops + 1) : null + } + if (expression.type === 'Identifier') { + const target = this.binding(module, expression.name, hops + 1) + return target ? this.frameworkObject(target.module, target.node, hops + 1) : null + } + return null + } + + /** Nuxt's `evlog` key, or the argument of the `evlog/nitro` module in `modules`. */ + moduleOptionsNode({ module, node }: Located): Expression | null { + for (const prop of node.properties) { + if (prop.type !== 'Property') continue + const key = propertyName(prop) + if (key === 'evlog') return prop.value + const list = key === 'modules' ? strip(prop.value) : null + if (list?.type !== 'ArrayExpression') continue + for (const element of list.elements) { + const call = element && element.type !== 'SpreadElement' ? strip(element) : null + if (call?.type !== 'CallExpression' || call.callee.type !== 'Identifier') continue + const imported = module.imports.get(call.callee.name) + if (!imported || !NITRO_MODULE_SOURCES.has(imported.source)) continue + const [argument] = call.arguments + return argument && argument.type !== 'SpreadElement' ? argument : null + } + } + return null + } + evaluate({ module, node }: Located, path: readonly string[], lines: Map | undefined, hops: number): unknown { const expression = strip(node) switch (expression.type) { @@ -380,3 +426,21 @@ export function readConfig(file: string, label: (file: string) => string): ReadC } return { config: document, parent: { specifier: parent.specifier, document: parent.document } } } + +/** + * Read the options `nuxt.config` or `nitro.config` passes to the evlog module + * without running the file: Nuxt's `evlog` key, or the argument of the + * `evlog/nitro` module in `modules`. `null` when it passes none. + * + * @throws a `cli.CONFIG_PARSE_FAILED` error when the file does not parse. + */ +export function readModuleOptions(file: string, label: (file: string) => string): ModuleOptionsDocument | null { + const reader = new Reader(label) + const exported = reader.exported(file, 'default', 0) + const object = exported && reader.frameworkObject(exported.module, exported.node, 0) + const node = object && reader.moduleOptionsNode(object) + if (!object || !node) return null + const lines = new Map() + const value = reader.evaluate({ module: object.module, node }, [], lines, 0) + return isPlainObject(value) || value instanceof RuntimeValue ? { file, value, lines } : null +} diff --git a/packages/cli/test/config.test.ts b/packages/cli/test/config.test.ts index 61b5d81d7..9a25c63a0 100644 --- a/packages/cli/test/config.test.ts +++ b/packages/cli/test/config.test.ts @@ -81,6 +81,26 @@ export default defineEvlog({ `, } +/** A Nuxt app whose nuxt.config passes evlog options over the file, as the module layers them. */ +const NUXT_OVERRIDES = { + 'package.json': PACKAGE, + 'evlog.config.ts': `export default { + service: 'review', + sampling: { rates: { warn: 50 } }, + redact: { paths: ['user.password'] }, +} +`, + 'nuxt.config.ts': `export default defineNuxtConfig({ + modules: ['evlog/nuxt'], + evlog: { + env: { service: 'nuxt-wins' }, + sampling: { rates: { warn: 10 } }, + redact: { paths: ['card.number'] }, + }, +}) +`, +} + /** A config extending a package whose package.json is cut off mid-object. */ const MALFORMED_PRESET = { 'package.json': PACKAGE, @@ -646,6 +666,74 @@ export default { expect(json.app).toContainEqual(expect.objectContaining({ path: 'redact.patterns[0]', value: { regexp: '/acct_\\w+/g' } })) }) + it('layers the options nuxt.config passes to the module over the file', async () => { + const cwd = await makeProject(NUXT_OVERRIDES) + const result = await runConfig(fakeContext(cwd)) + + expect(result.moduleOptions).toEqual({ file: 'nuxt.config.ts', readable: true }) + expect(result.app).toContainEqual({ path: ['env', 'service'], value: 'nuxt-wins', source: 'nuxt.config.ts:4', overrides: { value: 'review', source: 'evlog.config.ts:2' } }) + expect(result.app).toContainEqual({ path: ['sampling', 'rates', 'warn'], value: 10, source: 'nuxt.config.ts:5', overrides: { value: 50, source: 'evlog.config.ts:3' } }) + expect(result.app).toContainEqual({ path: ['redact', 'paths', 0], value: 'user.password', source: 'evlog.config.ts:4' }) + expect(result.app).toContainEqual({ path: ['redact', 'paths', 1], value: 'card.number', source: 'nuxt.config.ts:6' }) + expect(result.app.map(entry => entry.path)).not.toContainEqual(['service']) + }) + + it('shows what the app runs with and the file value each option replaces', async () => { + const cwd = await makeProject(NUXT_OVERRIDES) + const result = await runConfig(fakeContext(cwd)) + const report = formatConfigReport(fakeContext(cwd), result) + + expect(report).toMatch(/^evlog\.config\.ts\nnuxt\.config\.ts passes options to the evlog module, and they override this file$/m) + expect(report).toMatch(/^ {2}service +nuxt-wins +nuxt\.config\.ts:4\n +overrides 'review' at evlog\.config\.ts:2$/m) + expect(report).toMatch(/^ {2}warn +10% +nuxt\.config\.ts:5\n +overrides 50 at evlog\.config\.ts:3$/m) + expect(report).toMatch(/^ {12}card\.number +nuxt\.config\.ts:6$/m) + expect(configJson(result)).toMatchObject({ moduleOptions: { file: 'nuxt.config.ts', readable: true } }) + expect(configJson(result).app).toContainEqual({ path: 'env.service', value: 'nuxt-wins', source: 'nuxt.config.ts:4', overrides: { value: 'review', source: 'evlog.config.ts:2' } }) + }) + + it('reads the options passed to the Nitro module', async () => { + const cwd = await makeProject({ + 'package.json': PACKAGE, + 'evlog.config.ts': 'export default { service: \'review\' }\n', + 'nitro.config.ts': `import evlog from 'evlog/nitro/v3' +import { defineConfig } from 'nitro' + +export default defineConfig({ + modules: [evlog({ env: { service: 'nitro-wins' } })], +}) +`, + }) + const result = await runConfig(fakeContext(cwd)) + + expect(result.moduleOptions).toEqual({ file: 'nitro.config.ts', readable: true }) + expect(result.app).toContainEqual({ path: ['env', 'service'], value: 'nitro-wins', source: 'nitro.config.ts:5', overrides: { value: 'review', source: 'evlog.config.ts:1' } }) + }) + + it('says the module options override the file when they are computed', async () => { + const cwd = await makeProject({ + 'package.json': PACKAGE, + 'evlog.config.ts': 'export default { service: \'review\' }\n', + 'nuxt.config.ts': 'import { evlogOptions } from \'@acme/config\'\n\nexport default defineNuxtConfig({ evlog: evlogOptions() })\n', + }) + const result = await runConfig(fakeContext(cwd)) + + expect(result.moduleOptions).toEqual({ file: 'nuxt.config.ts', readable: false }) + expect(result.app).toContainEqual({ path: ['service'], value: 'review', source: 'evlog.config.ts:1' }) + expect(formatConfigReport(fakeContext(cwd), result)).toContain('nuxt.config.ts passes options to the evlog module that are computed at runtime, and they override this file') + }) + + it('says nothing about a module registered without options', async () => { + const cwd = await makeProject({ + 'package.json': PACKAGE, + 'evlog.config.ts': 'export default { service: \'review\' }\n', + 'nitro.config.ts': 'import evlog from \'evlog/nitro\'\n\nexport default defineNitroConfig({ modules: [evlog()] })\n', + }) + const result = await runConfig(fakeContext(cwd)) + + expect(result.moduleOptions).toBeNull() + expect(formatConfigReport(fakeContext(cwd), result)).not.toContain('evlog module') + }) + it('says where it looked when there is no config', async () => { const cwd = await makeProject({ 'package.json': PACKAGE }) const result = await runConfig(fakeContext(cwd)) From a348c5b8c6b23da3d6f8fc6f3a4e1d23a2372b08 Mon Sep 17 00:00:00 2001 From: Hugo Richard Date: Sun, 11 Oct 2026 09:21:23 +0100 Subject: [PATCH 4/6] fix(nitro): clear the evlog.config slot for apps without one --- packages/evlog/src/shared/configPlugin.ts | 32 ++++++++++--------- .../evlog/test/nitro/module-paths.test.ts | 11 +++---- .../evlog/test/shared/configPlugin.test.ts | 14 ++++++-- 3 files changed, 33 insertions(+), 24 deletions(-) diff --git a/packages/evlog/src/shared/configPlugin.ts b/packages/evlog/src/shared/configPlugin.ts index 49d8caa9b..f28840834 100644 --- a/packages/evlog/src/shared/configPlugin.ts +++ b/packages/evlog/src/shared/configPlugin.ts @@ -44,15 +44,18 @@ export function findEvlogConfigFile(rootDir: string): string | undefined { } } -/** Source of the server plugin that imports `file` and hands its default export to the evlog Nitro plugin. */ -export function evlogConfigPluginSource(file: string): string { - // Raw-interpolated into a JS string literal: POSIX separators keep Windows backslashes from reading as escapes. - return [ - `import config from ${JSON.stringify(file.replace(/\\/g, '/'))}`, - `globalThis[Symbol.for(${JSON.stringify(EVLOG_FILE_CONFIG_KEY)})] = config`, - 'export default function evlogConfig() {}', - '', - ].join('\n') +/** + * Source of the server plugin that imports `file` and hands its default export + * to the evlog Nitro plugin. Without a file it empties the slot, so a config + * loaded by another server in the same process does not apply. + */ +export function evlogConfigPluginSource(file: string | undefined): string { + const slot = `globalThis[Symbol.for(${JSON.stringify(EVLOG_FILE_CONFIG_KEY)})]` + const load = file + // Raw-interpolated into a JS string literal: POSIX separators keep Windows backslashes from reading as escapes. + ? [`import config from ${JSON.stringify(file.replace(/\\/g, '/'))}`, `${slot} = config`] + : [`${slot} = undefined`] + return [...load, 'export default function evlogConfig() {}', ''].join('\n') } /** The slice of Nitro options the config plugin is registered on, shared by nitropack v2 and Nitro v3. */ @@ -65,15 +68,14 @@ interface NitroOptionsTarget { } /** - * Load `evlog.config.ts` into the server bundle when the app has one, through - * a virtual plugin placed first so the file is evaluated before any plugin - * runs. Call it once options are resolved (from a Nitro module's `setup` or - * Nuxt's `nitro:init`): a plugin id added earlier is resolved as a path. - * Returns the file it registered. + * Load `evlog.config.ts` into the server bundle through a virtual plugin + * placed first, so the file is evaluated before any plugin runs. The plugin is + * registered without a file too, to empty the slot. Call it once options are + * resolved (from a Nitro module's `setup` or Nuxt's `nitro:init`): a plugin id + * added earlier is resolved as a path. Returns the file it loads. */ export function registerEvlogConfigPlugin(nitro: NitroOptionsTarget): string | undefined { const file = findEvlogConfigFile(nitro.options.rootDir) - if (!file) return undefined nitro.options.virtual[EVLOG_CONFIG_PLUGIN_ID] = evlogConfigPluginSource(file) nitro.options.plugins.unshift(EVLOG_CONFIG_PLUGIN_ID) return file diff --git a/packages/evlog/test/nitro/module-paths.test.ts b/packages/evlog/test/nitro/module-paths.test.ts index 98c6c1e0e..73df37e9c 100644 --- a/packages/evlog/test/nitro/module-paths.test.ts +++ b/packages/evlog/test/nitro/module-paths.test.ts @@ -4,6 +4,7 @@ import { join } from 'node:path' import { describe, expect, it } from 'vitest' import nitroV2Module from '../../src/nitro/module' import nitroV3Module from '../../src/nitro-v3/module' +import { EVLOG_CONFIG_PLUGIN_ID } from '../../src/shared/configPlugin' // Nitro builds its #nitro/virtual/plugins and #nitro/virtual/error-handler // virtual modules by raw-interpolating these paths into JS string literals: @@ -31,9 +32,8 @@ describe('nitro modules avoid backslash paths', () => { const nitro = makeNitroStub() nitroV2Module({ env: { service: 'test' }, silent: true }).setup(nitro as never) - expect(nitro.options.plugins).toHaveLength(1) - expect(nitro.options.plugins[0]).not.toMatch(/\\/) - expect(nitro.options.plugins[0]).toMatch(/\/nitro\/plugin$/) + expect(nitro.options.plugins).toEqual([EVLOG_CONFIG_PLUGIN_ID, expect.stringMatching(/\/nitro\/plugin$/)]) + expect(nitro.options.plugins[1]).not.toMatch(/\\/) expect(nitro.options.errorHandler).toBeTypeOf('string') expect(nitro.options.errorHandler).not.toMatch(/\\/) @@ -47,9 +47,8 @@ describe('nitro modules avoid backslash paths', () => { const nitro = makeNitroStub() nitroV3Module({ env: { service: 'test' } }).setup(nitro as never) - expect(nitro.options.plugins).toHaveLength(1) - expect(nitro.options.plugins[0]).not.toMatch(/\\/) - expect(nitro.options.plugins[0]).toMatch(/\/nitro-v3\/plugin$/) + expect(nitro.options.plugins).toEqual([EVLOG_CONFIG_PLUGIN_ID, expect.stringMatching(/\/nitro-v3\/plugin$/)]) + expect(nitro.options.plugins[1]).not.toMatch(/\\/) expect(Array.isArray(nitro.options.errorHandler)).toBe(true) const handlers = nitro.options.errorHandler as string[] diff --git a/packages/evlog/test/shared/configPlugin.test.ts b/packages/evlog/test/shared/configPlugin.test.ts index 3abb6c8bf..bd90a99ce 100644 --- a/packages/evlog/test/shared/configPlugin.test.ts +++ b/packages/evlog/test/shared/configPlugin.test.ts @@ -115,14 +115,22 @@ describe('evlogConfigPluginSource', () => { expect(source).toContain('"C:/app/evlog.config.ts"') expect(source).not.toMatch(/\\/) }) + + it('empties the global slot when there is no file', () => { + expect(evlogConfigPluginSource(undefined)).toBe([ + 'globalThis[Symbol.for("evlog.config.file")] = undefined', + 'export default function evlogConfig() {}', + '', + ].join('\n')) + }) }) describe('registerEvlogConfigPlugin', () => { - it('leaves Nitro untouched when the app has no config', () => { + it('registers a plugin that empties the slot when the app has no config', () => { const nitro = makeNitroStub(makeDir()) expect(registerEvlogConfigPlugin(nitro)).toBeUndefined() - expect(nitro.options.plugins).toEqual([]) - expect(nitro.options.virtual).toEqual({}) + expect(nitro.options.plugins).toEqual([EVLOG_CONFIG_PLUGIN_ID]) + expect(nitro.options.virtual[EVLOG_CONFIG_PLUGIN_ID]).toBe(evlogConfigPluginSource(undefined)) }) it('registers the virtual plugin first, before plugins already registered', () => { From 2e0c43dcc75894e99479dd89cf5ba658990fab38 Mon Sep 17 00:00:00 2001 From: Hugo Richard Date: Sun, 11 Oct 2026 09:36:13 +0100 Subject: [PATCH 5/6] fix(cli): read a named Nitro modules list and attribute replaced redaction in evlog config --- packages/cli/src/commands/config.ts | 11 ++++++++-- packages/cli/src/lib/config/read.ts | 23 ++++++++++++--------- packages/cli/test/config.test.ts | 32 +++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 12 deletions(-) diff --git a/packages/cli/src/commands/config.ts b/packages/cli/src/commands/config.ts index 720df2019..c51f836b2 100644 --- a/packages/cli/src/commands/config.ts +++ b/packages/cli/src/commands/config.ts @@ -102,6 +102,12 @@ function layerEntries(file: ConfigEntry[], options: ConfigEntry[]): ConfigEntry[ return out } +/** A key keeps the file's source while one of the file's settings at or below it is still in effect, and takes the option's once the options replace them. */ +function layerSources(file: ReadonlyMap, options: ReadonlyMap, kept: ConfigEntry[]): Map { + const live = new Set(kept.flatMap(entry => entry.path.map((_, end) => formatPath(entry.path.slice(0, end + 1))))) + return new Map([...options, ...[...file].filter(([key]) => live.has(key) || !options.has(key))]) +} + /** * Read the `evlog.config` that applies to `ctx.cwd`, merged across `extends`, * with where each setting is written. Pure with respect to the context. @@ -118,14 +124,15 @@ export async function runConfig(ctx: CliContext, log: CliDebug = createNoopCliDe const entries = entriesFor(config.resolved, config.sources) const app = entries.filter(entry => !CLI_SECTIONS.has(entry.path[0])) + const layeredApp = layered ? layerEntries(app, entriesFor(layered.value, layered.sources).filter(entry => !CLI_SECTIONS.has(entry.path[0]))) : app return { searched, file: prettyPath(ctx.cwd, config.file), extends: config.extends && { specifier: config.extends.specifier, file: prettyPath(ctx.cwd, config.extends.file) }, moduleOptions: options && { file: prettyPath(ctx.cwd, options.file), readable: layered !== null }, cli: entries.filter(entry => CLI_SECTIONS.has(entry.path[0])), - app: layered ? layerEntries(app, entriesFor(layered.value, layered.sources).filter(entry => !CLI_SECTIONS.has(entry.path[0]))) : app, - sources: layered ? new Map([...layered.sources, ...config.sources]) : config.sources, + app: layeredApp, + sources: layered ? layerSources(config.sources, layered.sources, entries.filter(entry => CLI_SECTIONS.has(entry.path[0]) || layeredApp.includes(entry))) : config.sources, } } diff --git a/packages/cli/src/lib/config/read.ts b/packages/cli/src/lib/config/read.ts index cec5134a7..e7ffd97b9 100644 --- a/packages/cli/src/lib/config/read.ts +++ b/packages/cli/src/lib/config/read.ts @@ -267,21 +267,24 @@ class Reader { return null } - /** Nuxt's `evlog` key, or the argument of the `evlog/nitro` module in `modules`. */ - moduleOptionsNode({ module, node }: Located): Expression | null { + /** Nuxt's `evlog` key, or the argument of the `evlog/nitro` module in `modules`, written inline or through a named list. */ + moduleOptionsNode({ module, node }: Located): Located | null { for (const prop of node.properties) { if (prop.type !== 'Property') continue const key = propertyName(prop) - if (key === 'evlog') return prop.value - const list = key === 'modules' ? strip(prop.value) : null - if (list?.type !== 'ArrayExpression') continue + if (key === 'evlog') return { module, node: prop.value } + if (key !== 'modules') continue + const value = strip(prop.value) + const target = value.type === 'Identifier' ? this.binding(module, value.name, 0) : { module, node: value } + const list = target && strip(target.node) + if (!target || list?.type !== 'ArrayExpression') continue for (const element of list.elements) { const call = element && element.type !== 'SpreadElement' ? strip(element) : null if (call?.type !== 'CallExpression' || call.callee.type !== 'Identifier') continue - const imported = module.imports.get(call.callee.name) + const imported = target.module.imports.get(call.callee.name) if (!imported || !NITRO_MODULE_SOURCES.has(imported.source)) continue const [argument] = call.arguments - return argument && argument.type !== 'SpreadElement' ? argument : null + return argument && argument.type !== 'SpreadElement' ? { module: target.module, node: argument } : null } } return null @@ -438,9 +441,9 @@ export function readModuleOptions(file: string, label: (file: string) => string) const reader = new Reader(label) const exported = reader.exported(file, 'default', 0) const object = exported && reader.frameworkObject(exported.module, exported.node, 0) - const node = object && reader.moduleOptionsNode(object) - if (!object || !node) return null + const options = object && reader.moduleOptionsNode(object) + if (!object || !options) return null const lines = new Map() - const value = reader.evaluate({ module: object.module, node }, [], lines, 0) + const value = reader.evaluate(options, [], options.module === object.module ? lines : undefined, 0) return isPlainObject(value) || value instanceof RuntimeValue ? { file, value, lines } : null } diff --git a/packages/cli/test/config.test.ts b/packages/cli/test/config.test.ts index 9a25c63a0..e2bc7d1dc 100644 --- a/packages/cli/test/config.test.ts +++ b/packages/cli/test/config.test.ts @@ -709,6 +709,38 @@ export default defineConfig({ expect(result.app).toContainEqual({ path: ['env', 'service'], value: 'nitro-wins', source: 'nitro.config.ts:5', overrides: { value: 'review', source: 'evlog.config.ts:1' } }) }) + it('reads the Nitro modules from a variable', async () => { + const cwd = await makeProject({ + 'package.json': PACKAGE, + 'evlog.config.ts': 'export default { service: \'review\' }\n', + 'nitro.config.ts': `import evlog from 'evlog/nitro' +const modules = [evlog({ env: { service: 'nitro-wins' } })] +export default defineNitroConfig({ modules }) +`, + }) + const result = await runConfig(fakeContext(cwd)) + + expect(result.moduleOptions).toEqual({ file: 'nitro.config.ts', readable: true }) + expect(result.app).toContainEqual({ path: ['env', 'service'], value: 'nitro-wins', source: 'nitro.config.ts:2', overrides: { value: 'review', source: 'evlog.config.ts:1' } }) + }) + + it('attributes redaction to the module options when they turn it back on', async () => { + const cwd = await makeProject({ + 'package.json': PACKAGE, + 'evlog.config.ts': 'export default { redact: false }\n', + 'nuxt.config.ts': `export default defineNuxtConfig({ + modules: ['evlog/nuxt'], + evlog: { + redact: { paths: ['card.number'] }, + }, +}) +`, + }) + const report = formatConfigReport(fakeContext(cwd), await runConfig(fakeContext(cwd))) + + expect(report).toMatch(/^ {2}redact +on +nuxt\.config\.ts:4$/m) + }) + it('says the module options override the file when they are computed', async () => { const cwd = await makeProject({ 'package.json': PACKAGE, From dc9b4abc612104292df2fbf87fa042a2744e152b Mon Sep 17 00:00:00 2001 From: Hugo Richard Date: Sun, 11 Oct 2026 11:15:15 +0100 Subject: [PATCH 6/6] fix(cli): report module options in evlog config --json without an evlog.config --- packages/cli/src/commands/config.ts | 5 +++-- packages/cli/test/config.test.ts | 8 ++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/commands/config.ts b/packages/cli/src/commands/config.ts index c51f836b2..8ae2ce1f2 100644 --- a/packages/cli/src/commands/config.ts +++ b/packages/cli/src/commands/config.ts @@ -118,9 +118,10 @@ export async function runConfig(ctx: CliContext, log: CliDebug = createNoopCliDe const project = await log.step('resolveProject', () => resolveProject(ctx.cwd), p => ({ project: { root: p.root, packageDir: p.packageDir } })) const searched = { from: prettyPath(ctx.cwd, project.packageDir), to: prettyPath(ctx.cwd, project.root) } const config = await log.step('loadConfig', () => loadCliConfig(project), c => ({ config: c && { file: c.file, extends: c.extends?.file ?? null } })) - if (!config) return { searched, file: null, extends: null, moduleOptions: null, cli: [], app: [], sources: new Map() } const options = await log.step('loadModuleOptions', () => loadModuleOptions(project), o => ({ moduleOptions: o && { file: o.file, readable: !(o.value instanceof RuntimeValue) } })) const layered = options && !(options.value instanceof RuntimeValue) ? { value: options.value, sources: options.sources } : null + const moduleOptions = options && { file: prettyPath(ctx.cwd, options.file), readable: layered !== null } + if (!config) return { searched, file: null, extends: null, moduleOptions, cli: [], app: [], sources: new Map() } const entries = entriesFor(config.resolved, config.sources) const app = entries.filter(entry => !CLI_SECTIONS.has(entry.path[0])) @@ -129,7 +130,7 @@ export async function runConfig(ctx: CliContext, log: CliDebug = createNoopCliDe searched, file: prettyPath(ctx.cwd, config.file), extends: config.extends && { specifier: config.extends.specifier, file: prettyPath(ctx.cwd, config.extends.file) }, - moduleOptions: options && { file: prettyPath(ctx.cwd, options.file), readable: layered !== null }, + moduleOptions, cli: entries.filter(entry => CLI_SECTIONS.has(entry.path[0])), app: layeredApp, sources: layered ? layerSources(config.sources, layered.sources, entries.filter(entry => CLI_SECTIONS.has(entry.path[0]) || layeredApp.includes(entry))) : config.sources, diff --git a/packages/cli/test/config.test.ts b/packages/cli/test/config.test.ts index e2bc7d1dc..2a48432ce 100644 --- a/packages/cli/test/config.test.ts +++ b/packages/cli/test/config.test.ts @@ -774,6 +774,14 @@ export default defineNitroConfig({ modules }) expect(formatConfigReport(fakeContext(cwd), result)).toContain('No evlog.config from .') }) + it('still reports the module options in the JSON when there is no config', async () => { + const cwd = await makeProject({ 'package.json': PACKAGE, 'nuxt.config.ts': NUXT_OVERRIDES['nuxt.config.ts'] }) + const result = await runConfig(fakeContext(cwd)) + + expect(configJson(result)).toMatchObject({ file: null, moduleOptions: { file: 'nuxt.config.ts', readable: true }, cli: [], app: [] }) + expect(formatConfigReport(fakeContext(cwd), result)).toContain('No evlog.config from .') + }) + it('exits 1 with the catalog error on a config it cannot read', async () => { const cwd = await makeProject({ 'package.json': PACKAGE, 'evlog.config.ts': 'export default { map: { minScore: 101 } }\n' }) const out = silence()