From 3a884d2ad0dd77dc2b52b3254ea0648873942994 Mon Sep 17 00:00:00 2001 From: Hugo Richard Date: Sat, 15 Aug 2026 14:03:38 +0100 Subject: [PATCH 1/2] docs: turn the last one-mould pages into headings that answer a question --- .../references/corrections.md | 6 +++ .../content/4.integrate/frameworks/01.nuxt.md | 2 +- .../4.integrate/frameworks/16.aws-lambda.md | 2 +- apps/docs/content/5.use-cases/5.enrichers.md | 10 ++-- .../content/7.reference/1.configuration.md | 12 ++--- .../content/7.reference/5.vs-other-loggers.md | 10 ++-- packages/nuxthub/README.md | 6 +-- scripts/content-lint/lib/corpus.mjs | 49 ++++++++++++++++++- scripts/content-lint/lib/metrics.mjs | 5 +- scripts/content-lint/lib/metrics.test.mjs | 32 +++++++++--- 10 files changed, 104 insertions(+), 30 deletions(-) diff --git a/.agents/skills/write-evlog-content/references/corrections.md b/.agents/skills/write-evlog-content/references/corrections.md index c14074a5..01d670b7 100644 --- a/.agents/skills/write-evlog-content/references/corrections.md +++ b/.agents/skills/write-evlog-content/references/corrections.md @@ -86,3 +86,9 @@ Applies to: list items on every surface. Table cells stay out: a cell is a fragm Flagged: `~30–80 lines of glue`. Actual: an en dash between two numbers is the mark that reads as a range, and no comma, colon or period replaces it. The rule was never about that dash. Applies to: every surface. `metrics.mjs` ignores a dash with a digit on each side. + +## 2026-08-15 · T-06 · A question does not need its mark, and the verb list was too short + +Flagged: `Where the byte counts come from`, `Which number moves your bill`, `Try it against your numbers`, `Ask it from your editor`, and 8 pages of the same kind. +Actual: the classifier only saw a question when the heading ended on `?`, and its verb list held 42 words while the corpus writes with far more. Both made a page of answers look like a page of nouns. +Applies to: every surface. `classifyHeading` reads an interrogative opener as a question, and the verb list grew to 89. Words that are evlog's own nouns first (`log`, `route`, `stream`, `trace`, `filter`, `drain`) are kept out of it, since counting `## Route filtering` as an imperative would weaken the rule rather than correct it. diff --git a/apps/docs/content/4.integrate/frameworks/01.nuxt.md b/apps/docs/content/4.integrate/frameworks/01.nuxt.md index a207bef6..1246d7e1 100644 --- a/apps/docs/content/4.integrate/frameworks/01.nuxt.md +++ b/apps/docs/content/4.integrate/frameworks/01.nuxt.md @@ -145,7 +145,7 @@ All options are set in `nuxt.config.ts` under the `evlog` key: | `exclude` | `string[]` | `undefined` | Route patterns to exclude. Exclusions take precedence | | `routes` | `Record` | `undefined` | Route-specific service configuration | | `pretty` | `boolean` | `true` in dev | Pretty print with tree formatting | -| `dev` | `'evlog' \| 'nitro' \| 'both' \| object` | `'evlog'` in pretty dev | Dev terminal presets or `{ frameworkOverlay, prettyError }` — see [Configuration — Dev terminal output](/reference/configuration#dev-terminal-output) | +| `dev` | `'evlog' \| 'nitro' \| 'both' \| object` | `'evlog'` in pretty dev | Dev terminal presets or `{ frameworkOverlay, prettyError }` — see [Configuration — Dev terminal output](/reference/configuration#tune-the-dev-terminal-output) | ::callout{icon="i-lucide-terminal" color="info"} **Dev terminal presets:** `'evlog'` (default) gives one clean signal, evlog-only stack. `'nitro'` gives the wide event context + Nitro Youch stack (evlog prints Why/Fix only). `'both'` gives the full evlog block and the Nitro overlay. With `pretty: false`, set `dev: { frameworkOverlay: false }` to suppress Nitro while logging JSON. diff --git a/apps/docs/content/4.integrate/frameworks/16.aws-lambda.md b/apps/docs/content/4.integrate/frameworks/16.aws-lambda.md index 30776441..4ca36214 100644 --- a/apps/docs/content/4.integrate/frameworks/16.aws-lambda.md +++ b/apps/docs/content/4.integrate/frameworks/16.aws-lambda.md @@ -94,7 +94,7 @@ If you process the whole batch as one logical unit, use a **single** `createLogg ## Stdout and `silent` -Many teams ingest Lambda logs from **CloudWatch** via stdout. If you use a **drain adapter** (OTLP, Datadog, Axiom, etc.) and want JSON or platform-specific formatting without duplicate console noise, set `silent: true` in production. See [Configuration](/reference/configuration#silent-mode). +Many teams ingest Lambda logs from **CloudWatch** via stdout. If you use a **drain adapter** (OTLP, Datadog, Axiom, etc.) and want JSON or platform-specific formatting without duplicate console noise, set `silent: true` in production. See [Configuration](/reference/configuration#silence-the-terminal). ```typescript [src/handler.ts] import { createAxiomDrain } from 'evlog/axiom' diff --git a/apps/docs/content/5.use-cases/5.enrichers.md b/apps/docs/content/5.use-cases/5.enrichers.md index d68f8202..c9dad79f 100644 --- a/apps/docs/content/5.use-cases/5.enrichers.md +++ b/apps/docs/content/5.use-cases/5.enrichers.md @@ -64,7 +64,7 @@ export default defineNitroPlugin((nitroApp) => { Each enricher accepts `{ overwrite?: boolean }` (default `false`) so user-set fields are preserved. -## User Agent +## `userAgentEnricher()` Parse browser, OS, and device type from the `User-Agent` header. @@ -102,7 +102,7 @@ interface UserAgentInfo { **Detected devices:** Bot (crawlers, spiders), Tablet (iPad), Mobile (iPhone, Android phones), Desktop (fallback). -## Geo +## `geoEnricher()` Extract geographic data from platform-injected headers. @@ -136,7 +136,7 @@ interface GeoInfo { **Cloudflare note:** Only `cf-ipcountry` is a standard Cloudflare HTTP header. Other geo fields (`city`, `region`, `latitude`, etc.) are properties of `request.cf`, which is not exposed as headers. For full Cloudflare geo data, write a [custom enricher](/extend/custom-enrichers) that reads `request.cf`, or use a Workers middleware to copy `cf` properties into custom headers. :: -## Request Size +## `requestSizeEnricher()` Capture request and response payload sizes from `Content-Length` headers. @@ -170,7 +170,7 @@ interface RequestSizeInfo { This enricher reads the `Content-Length` header from both the request and response. If the header is missing (e.g., for chunked transfer encoding), the corresponding field will be `undefined`. :: -## Trace Context +## `traceContextEnricher()` Extract W3C trace context from the `traceparent` and `tracestate` headers. @@ -211,7 +211,7 @@ interface TraceContextInfo { The traceparent format follows the [W3C Trace Context](https://www.w3.org/TR/trace-context/) specification: `{version}-{traceId}-{spanId}-{flags}`. :: -## Full Setup Example +## Wire all four at once Use all built-in enrichers together. The list of enrichers is identical across frameworks. Only the wiring changes. diff --git a/apps/docs/content/7.reference/1.configuration.md b/apps/docs/content/7.reference/1.configuration.md index ea87eae2..08643110 100644 --- a/apps/docs/content/7.reference/1.configuration.md +++ b/apps/docs/content/7.reference/1.configuration.md @@ -18,7 +18,7 @@ links: evlog has two configuration surfaces: **global options** set once at startup, and **middleware options** set per-framework integration. This page documents both. -## Global Options (`initLogger`) +## Global options (`initLogger`) These options apply to all frameworks. Call `initLogger()` once at application startup for standalone frameworks (Hono, Express, Fastify, Elysia, NestJS, SvelteKit, Cloudflare Workers). For Nuxt and Nitro, these are set via module config and passed through automatically. @@ -60,7 +60,7 @@ initLogger({ Evaluation order for `log.info` / `log.debug` / etc.: `enabled` → `minLevel` → head sampling → output. -### Dev terminal output +### Tune the dev terminal output Pretty error blocks run only when `pretty: true` (default in development). Production always emits JSON wide events, with no stack snippets and no disk reads. @@ -102,7 +102,7 @@ evlog: { See [Development terminal output](/learn/structured-errors#development-terminal-output) in Structured Errors for an example of the pretty error tree. -### Environment Context +### Stamp the environment on every event The `env` option controls the fields included in every log event. Most values are auto-detected from environment variables. @@ -114,7 +114,7 @@ The `env` option controls the fields included in every log event. Most values ar | `commitHash` | `string` | `undefined` | `COMMIT_SHA`, `GITHUB_SHA`, `VERCEL_GIT_COMMIT_SHA`, `CF_PAGES_COMMIT_SHA` | | `region` | `string` | `undefined` | `VERCEL_REGION`, `AWS_REGION`, `FLY_REGION`, `CF_REGION` | -### Silent Mode +### Silence the terminal Use `silent` when your deployment platform captures stdout as its primary log ingestion (GCP Cloud Run, AWS Lambda, Fly.io, Railway, etc.) and you want a drain adapter to control the output format. @@ -132,7 +132,7 @@ initLogger({ If `silent` is enabled without a drain, events are built and sampled but never output anywhere. evlog will warn you about this at startup. :: -## Middleware Options +## Middleware options These options are passed to the framework middleware/plugin. They control per-request behavior: which routes to log, how to drain and enrich events, and custom tail sampling logic. @@ -208,7 +208,7 @@ app.use(evlog({ })) ``` -## Framework-Specific Options +## Framework-specific options Some frameworks have additional options beyond the shared config: diff --git a/apps/docs/content/7.reference/5.vs-other-loggers.md b/apps/docs/content/7.reference/5.vs-other-loggers.md index ad73e589..cd0d1bf6 100644 --- a/apps/docs/content/7.reference/5.vs-other-loggers.md +++ b/apps/docs/content/7.reference/5.vs-other-loggers.md @@ -26,7 +26,7 @@ evlog is a fully-featured general-purpose logger first, with wide events as a na - **Pick evlog over consola** when logs stop being something you read and start being something you query. consola renders for a person: three reporters ship (`fancy`, `basic`, `browser`), and structured output is a custom reporter you write. There is no pipeline, no batching, no sampling, and no request lifecycle. - **Stay on pino** only if you emit hundreds of thousands of fire-and-forget lines per second with no context accumulation, and you have a custom transport you don't want to port. That is the one shape where [the benchmarks](/reference/performance) put pino ahead. evlog still wins the wide event lifecycle by 7.7x, but pino can edge it on raw `info('hello world')` throughput. -## Feature comparison +## Compare the feature sets Three tables instead of one wall. The **Winner** column on the right tells you who wins each row at a glance; cells use semantic words ("Built-in", "Manual", "via X") instead of generic "Yes" so you can read the level of effort without reading the spec. @@ -34,7 +34,7 @@ Three tables instead of one wall. The **Winner** column on the right tells you w Hover (or tap on mobile) the **info icon** next to any feature name for a one-line explanation of what the row actually compares. :: -### Core API +### What you call in a handler | Feature | evlog | pino | consola | winston | Winner | |---------|:-----:|:----:|:-------:|:-------:|:-------| @@ -51,7 +51,7 @@ Hover (or tap on mobile) the **info icon** next to any feature name for a one-li | :feature-label[Wide events (one per operation)]{tip="Accumulate context throughout a request and emit one typed event at the end — the wide-event observability pattern from Honeycomb / Charity Majors."} | Yes | No | No | No | **evlog** | | :feature-label[Structured errors (why / fix / link)]{tip="Errors carry why (root cause), fix (actionable next step), and link (docs URL) fields that travel from server to client."} | Yes | No | No | No | **evlog** | -### Production features +### What runs in production | Feature | evlog | pino | consola | winston | Winner | |---------|:-----:|:----:|:-------:|:-------:|:-------| @@ -69,7 +69,7 @@ Hover (or tap on mobile) the **info icon** next to any feature name for a one-li | :feature-label[Self-hosted storage (NuxtHub adapter)]{tip="Store logs in your own NuxtHub-hosted database with automatic retention — no external observability vendor required."} | Built-in | No | No | No | **evlog** | | :feature-label[Edge / Workers runtime]{tip="First-class support for Cloudflare Workers and Vercel Edge with waitUntil() integration so drains finish before the runtime terminates."} | Built-in | Partial | No | No | **evlog** | -### Footprint and ecosystem +### What it costs to install | Feature | evlog | pino | consola | winston | Winner | |---------|:-----:|:----:|:-------:|:-------:|:-------| @@ -110,7 +110,7 @@ You configure `minLevel` in `initLogger({ minLevel: 'info' })` and that's it for On **request-scoped wide-event loggers**, `log.setLevel('error' | 'warn' | 'info' | 'debug')` promotes the event level explicitly without touching the `error` context, which is what you want when you control the error shape yourself. Client-side `setMinLevel()` works the same way for the browser `log` API. -### No custom levels +### Custom levels are not supported evlog ships `debug` / `info` / `warn` / `error` and that's it. pino, consola, and winston all let you define `trace`, `notice`, `fatal`, etc. We chose four levels on purpose (most teams never use more than four), but if your existing pipeline depends on `fatal` or `trace` you'll need to map them onto the closest evlog level. diff --git a/packages/nuxthub/README.md b/packages/nuxthub/README.md index 945aba55..429acc0d 100644 --- a/packages/nuxthub/README.md +++ b/packages/nuxthub/README.md @@ -10,7 +10,7 @@ Self-hosted log retention for [evlog](https://evlog.dev) using [NuxtHub](https://hub.nuxt.com) database storage. Store, query, and automatically clean up your structured logs with zero external dependencies. -## Setup +## Install and wire it Install the packages: @@ -40,7 +40,7 @@ export default defineNuxtConfig({ The cleanup cron schedule is automatically derived from the retention value. -## Database Support +## Which databases work NuxtHub supports multiple database dialects. The `evlog_events` table schema is automatically registered for: @@ -48,7 +48,7 @@ NuxtHub supports multiple database dialects. The `evlog_events` table schema is - **MySQL** - **PostgreSQL** -## Deployment +## Deploy it For Vercel deployments, the module can create a `vercel.json` with the appropriate cron schedule during `nuxi module add`. For Cloudflare and other platforms, the Nitro scheduled task handles cleanup automatically. diff --git a/scripts/content-lint/lib/corpus.mjs b/scripts/content-lint/lib/corpus.mjs index c287d1e2..423cd293 100644 --- a/scripts/content-lint/lib/corpus.mjs +++ b/scripts/content-lint/lib/corpus.mjs @@ -301,43 +301,90 @@ export function offNameTerms(text, context = text) { /** Openers treated as imperative when classifying headings (T-06). */ export const IMPERATIVE_VERBS = [ 'add', + 'apply', + 'ask', + 'attach', 'build', + 'call', 'catch', 'choose', + 'close', + 'collect', + 'compare', 'configure', 'connect', + 'consume', 'create', 'debug', + 'declare', 'define', + 'delete', 'deploy', 'disable', 'drain', 'emit', 'enable', + 'export', + 'expose', 'extend', + 'fail', 'find', 'fix', + 'follow', + 'forward', 'get', 'handle', + 'import', + 'inspect', 'install', + 'instrument', + 'join', 'keep', - 'log', 'make', 'measure', + 'merge', 'move', + 'narrow', + 'open', 'pick', + 'prove', + 'publish', + 'reach', 'read', 'redact', + 'register', + 'rename', + 'render', + 'replace', + 'reset', + 'resolve', + 'restore', + 'retry', + 'reuse', + 'rotate', 'run', 'sample', 'send', 'set', 'ship', + 'silence', + 'skip', + 'spawn', + 'stamp', 'start', 'stop', + 'strip', 'test', 'track', + 'trim', + 'try', + 'tune', + 'turn', + 'tweak', + 'update', + 'upgrade', 'use', + 'validate', 'verify', 'wire', 'wrap', diff --git a/scripts/content-lint/lib/metrics.mjs b/scripts/content-lint/lib/metrics.mjs index a4018d1a..bf3391b6 100644 --- a/scripts/content-lint/lib/metrics.mjs +++ b/scripts/content-lint/lib/metrics.mjs @@ -290,7 +290,10 @@ function classifyHeading(text) { // procedure share a shape because they are one procedure, which is the twin // `T-06` is meant to spare. if (/^(?:step\s+)?\d+\s*[.):—–]?\s/i.test(trimmed)) return 'sequence' - if (trimmed.endsWith('?')) return 'question' + // A question does not need its mark. `Where the byte counts come from` and + // `Which number moves your bill` are the same shape as the ones that carry it, + // and a page built on them is a set of answers rather than one mould. + if (trimmed.endsWith('?') || /^(what|where|which|when|why|how|who|whether)\b/i.test(trimmed)) return 'question' const first = trimmed.toLowerCase().split(/\s+/)[0] if (IMPERATIVE_VERBS.includes(first)) return 'imperative' if (/\b(is|are|was|were|has|have|does|do|can|will|becomes|makes|gives|takes)\b/i.test(trimmed)) { diff --git a/scripts/content-lint/lib/metrics.test.mjs b/scripts/content-lint/lib/metrics.test.mjs index d092aa76..02320cba 100644 --- a/scripts/content-lint/lib/metrics.test.mjs +++ b/scripts/content-lint/lib/metrics.test.mjs @@ -68,6 +68,26 @@ describe('headings', () => { expect(metrics.headings.dominant).toBe('imperative') expect(metrics.headings.share).toBe(1) }) + + it('reads a heading opening on an interrogative as a question', () => { + const titles = ['Where the byte counts come from', 'Which number moves your bill', 'What a retained event costs'] + const source = titles.map(title => `## ${title}\n\nProse under it.`).join('\n\n') + + expect(measureSource(source).headings.dominant).toBe('question') + }) + + it('leaves evlog\'s own nouns out of the verb list', () => { + const titles = ['Route filtering', 'Trace context', 'Drain pipeline'] + const source = titles.map(title => `## ${title}\n\nProse under it.`).join('\n\n') + + expect(measureSource(source).headings.dominant).toBe('noun') + }) + + it('reads a heading opening on a symbol as an API entry', () => { + const source = ['## `getMetadata()`: final snapshot', '## `getEstimatedCost()`: quick check', '## `onUpdate()`: incremental', '## `shape`: the record'].map(h => `${h}\n\nProse about it.`).join('\n\n') + + expect(measureSource(source).headings.dominant).toBe('symbol') + }) }) describe('contraction seam', () => { @@ -82,7 +102,7 @@ describe('contraction seam', () => { }) }) -describe('dashes', () => { +describe('dashes, twins', () => { it('reads a dash between two numbers as a range', () => { expect(measureSource('Manifest mode is ~30–80 lines of glue.').dashes.count).toBe(0) }) @@ -92,14 +112,12 @@ describe('dashes', () => { expect(measureSource('## Network bridge — stream server\n\nProse.').dashes.count).toBe(1) }) - it('reads a heading opening on a symbol as an API entry', () => { - const source = ['## `getMetadata()`: final snapshot', '## `getEstimatedCost()`: quick check', '## `onUpdate()`: incremental', '## `shape`: the record'].map(h => `${h}\n\nProse about it.`).join('\n\n') - - expect(measureSource(source).headings.dominant).toBe('symbol') + it('counts a dash hiding in a bullet', () => { + expect(measureSource('- Skip on serverless — the stream is in-process').dashes.count).toBe(1) }) }) -describe('bullet frames', () => { +describe('bullet frames, symbols', () => { it('reads a bolded symbol as the symbol it is', () => { const items = ['`message`', '`evlog`', '`dd`', '`service`', '`timestamp`'] const source = items.map(name => `- **${name}**: what the field carries and why`).join('\n') @@ -108,7 +126,7 @@ describe('bullet frames', () => { }) }) -describe('epigram closers', () => { +describe('epigrams, twins', () => { it('leaves a card body out of the rhythm', () => { const source = ['::card-group', ' :::card', ' ---', ' title: Nuxt', ' ---', ' Auto-imported helpers. Zero config.', ' :::', '::'].join('\n') From 1382c941538a9ee34de7ae26d89bcc7fec2a9eef Mon Sep 17 00:00:00 2001 From: Hugo Richard Date: Sat, 15 Aug 2026 14:03:38 +0100 Subject: [PATCH 2/2] docs: turn the last one-mould pages into headings that answer a question --- .../references/corrections.md | 6 +++ .../content/4.integrate/frameworks/01.nuxt.md | 2 +- .../4.integrate/frameworks/16.aws-lambda.md | 2 +- apps/docs/content/5.use-cases/5.enrichers.md | 10 ++-- .../content/7.reference/1.configuration.md | 12 ++--- .../content/7.reference/5.vs-other-loggers.md | 10 ++-- packages/nuxthub/README.md | 6 +-- scripts/content-lint/lib/corpus.mjs | 49 ++++++++++++++++++- scripts/content-lint/lib/metrics.mjs | 5 +- scripts/content-lint/lib/metrics.test.mjs | 32 +++++++++--- 10 files changed, 104 insertions(+), 30 deletions(-) diff --git a/.agents/skills/write-evlog-content/references/corrections.md b/.agents/skills/write-evlog-content/references/corrections.md index c14074a5..01d670b7 100644 --- a/.agents/skills/write-evlog-content/references/corrections.md +++ b/.agents/skills/write-evlog-content/references/corrections.md @@ -86,3 +86,9 @@ Applies to: list items on every surface. Table cells stay out: a cell is a fragm Flagged: `~30–80 lines of glue`. Actual: an en dash between two numbers is the mark that reads as a range, and no comma, colon or period replaces it. The rule was never about that dash. Applies to: every surface. `metrics.mjs` ignores a dash with a digit on each side. + +## 2026-08-15 · T-06 · A question does not need its mark, and the verb list was too short + +Flagged: `Where the byte counts come from`, `Which number moves your bill`, `Try it against your numbers`, `Ask it from your editor`, and 8 pages of the same kind. +Actual: the classifier only saw a question when the heading ended on `?`, and its verb list held 42 words while the corpus writes with far more. Both made a page of answers look like a page of nouns. +Applies to: every surface. `classifyHeading` reads an interrogative opener as a question, and the verb list grew to 89. Words that are evlog's own nouns first (`log`, `route`, `stream`, `trace`, `filter`, `drain`) are kept out of it, since counting `## Route filtering` as an imperative would weaken the rule rather than correct it. diff --git a/apps/docs/content/4.integrate/frameworks/01.nuxt.md b/apps/docs/content/4.integrate/frameworks/01.nuxt.md index a207bef6..1246d7e1 100644 --- a/apps/docs/content/4.integrate/frameworks/01.nuxt.md +++ b/apps/docs/content/4.integrate/frameworks/01.nuxt.md @@ -145,7 +145,7 @@ All options are set in `nuxt.config.ts` under the `evlog` key: | `exclude` | `string[]` | `undefined` | Route patterns to exclude. Exclusions take precedence | | `routes` | `Record` | `undefined` | Route-specific service configuration | | `pretty` | `boolean` | `true` in dev | Pretty print with tree formatting | -| `dev` | `'evlog' \| 'nitro' \| 'both' \| object` | `'evlog'` in pretty dev | Dev terminal presets or `{ frameworkOverlay, prettyError }` — see [Configuration — Dev terminal output](/reference/configuration#dev-terminal-output) | +| `dev` | `'evlog' \| 'nitro' \| 'both' \| object` | `'evlog'` in pretty dev | Dev terminal presets or `{ frameworkOverlay, prettyError }` — see [Configuration — Dev terminal output](/reference/configuration#tune-the-dev-terminal-output) | ::callout{icon="i-lucide-terminal" color="info"} **Dev terminal presets:** `'evlog'` (default) gives one clean signal, evlog-only stack. `'nitro'` gives the wide event context + Nitro Youch stack (evlog prints Why/Fix only). `'both'` gives the full evlog block and the Nitro overlay. With `pretty: false`, set `dev: { frameworkOverlay: false }` to suppress Nitro while logging JSON. diff --git a/apps/docs/content/4.integrate/frameworks/16.aws-lambda.md b/apps/docs/content/4.integrate/frameworks/16.aws-lambda.md index 30776441..4ca36214 100644 --- a/apps/docs/content/4.integrate/frameworks/16.aws-lambda.md +++ b/apps/docs/content/4.integrate/frameworks/16.aws-lambda.md @@ -94,7 +94,7 @@ If you process the whole batch as one logical unit, use a **single** `createLogg ## Stdout and `silent` -Many teams ingest Lambda logs from **CloudWatch** via stdout. If you use a **drain adapter** (OTLP, Datadog, Axiom, etc.) and want JSON or platform-specific formatting without duplicate console noise, set `silent: true` in production. See [Configuration](/reference/configuration#silent-mode). +Many teams ingest Lambda logs from **CloudWatch** via stdout. If you use a **drain adapter** (OTLP, Datadog, Axiom, etc.) and want JSON or platform-specific formatting without duplicate console noise, set `silent: true` in production. See [Configuration](/reference/configuration#silence-the-terminal). ```typescript [src/handler.ts] import { createAxiomDrain } from 'evlog/axiom' diff --git a/apps/docs/content/5.use-cases/5.enrichers.md b/apps/docs/content/5.use-cases/5.enrichers.md index d68f8202..c9dad79f 100644 --- a/apps/docs/content/5.use-cases/5.enrichers.md +++ b/apps/docs/content/5.use-cases/5.enrichers.md @@ -64,7 +64,7 @@ export default defineNitroPlugin((nitroApp) => { Each enricher accepts `{ overwrite?: boolean }` (default `false`) so user-set fields are preserved. -## User Agent +## `userAgentEnricher()` Parse browser, OS, and device type from the `User-Agent` header. @@ -102,7 +102,7 @@ interface UserAgentInfo { **Detected devices:** Bot (crawlers, spiders), Tablet (iPad), Mobile (iPhone, Android phones), Desktop (fallback). -## Geo +## `geoEnricher()` Extract geographic data from platform-injected headers. @@ -136,7 +136,7 @@ interface GeoInfo { **Cloudflare note:** Only `cf-ipcountry` is a standard Cloudflare HTTP header. Other geo fields (`city`, `region`, `latitude`, etc.) are properties of `request.cf`, which is not exposed as headers. For full Cloudflare geo data, write a [custom enricher](/extend/custom-enrichers) that reads `request.cf`, or use a Workers middleware to copy `cf` properties into custom headers. :: -## Request Size +## `requestSizeEnricher()` Capture request and response payload sizes from `Content-Length` headers. @@ -170,7 +170,7 @@ interface RequestSizeInfo { This enricher reads the `Content-Length` header from both the request and response. If the header is missing (e.g., for chunked transfer encoding), the corresponding field will be `undefined`. :: -## Trace Context +## `traceContextEnricher()` Extract W3C trace context from the `traceparent` and `tracestate` headers. @@ -211,7 +211,7 @@ interface TraceContextInfo { The traceparent format follows the [W3C Trace Context](https://www.w3.org/TR/trace-context/) specification: `{version}-{traceId}-{spanId}-{flags}`. :: -## Full Setup Example +## Wire all four at once Use all built-in enrichers together. The list of enrichers is identical across frameworks. Only the wiring changes. diff --git a/apps/docs/content/7.reference/1.configuration.md b/apps/docs/content/7.reference/1.configuration.md index ea87eae2..08643110 100644 --- a/apps/docs/content/7.reference/1.configuration.md +++ b/apps/docs/content/7.reference/1.configuration.md @@ -18,7 +18,7 @@ links: evlog has two configuration surfaces: **global options** set once at startup, and **middleware options** set per-framework integration. This page documents both. -## Global Options (`initLogger`) +## Global options (`initLogger`) These options apply to all frameworks. Call `initLogger()` once at application startup for standalone frameworks (Hono, Express, Fastify, Elysia, NestJS, SvelteKit, Cloudflare Workers). For Nuxt and Nitro, these are set via module config and passed through automatically. @@ -60,7 +60,7 @@ initLogger({ Evaluation order for `log.info` / `log.debug` / etc.: `enabled` → `minLevel` → head sampling → output. -### Dev terminal output +### Tune the dev terminal output Pretty error blocks run only when `pretty: true` (default in development). Production always emits JSON wide events, with no stack snippets and no disk reads. @@ -102,7 +102,7 @@ evlog: { See [Development terminal output](/learn/structured-errors#development-terminal-output) in Structured Errors for an example of the pretty error tree. -### Environment Context +### Stamp the environment on every event The `env` option controls the fields included in every log event. Most values are auto-detected from environment variables. @@ -114,7 +114,7 @@ The `env` option controls the fields included in every log event. Most values ar | `commitHash` | `string` | `undefined` | `COMMIT_SHA`, `GITHUB_SHA`, `VERCEL_GIT_COMMIT_SHA`, `CF_PAGES_COMMIT_SHA` | | `region` | `string` | `undefined` | `VERCEL_REGION`, `AWS_REGION`, `FLY_REGION`, `CF_REGION` | -### Silent Mode +### Silence the terminal Use `silent` when your deployment platform captures stdout as its primary log ingestion (GCP Cloud Run, AWS Lambda, Fly.io, Railway, etc.) and you want a drain adapter to control the output format. @@ -132,7 +132,7 @@ initLogger({ If `silent` is enabled without a drain, events are built and sampled but never output anywhere. evlog will warn you about this at startup. :: -## Middleware Options +## Middleware options These options are passed to the framework middleware/plugin. They control per-request behavior: which routes to log, how to drain and enrich events, and custom tail sampling logic. @@ -208,7 +208,7 @@ app.use(evlog({ })) ``` -## Framework-Specific Options +## Framework-specific options Some frameworks have additional options beyond the shared config: diff --git a/apps/docs/content/7.reference/5.vs-other-loggers.md b/apps/docs/content/7.reference/5.vs-other-loggers.md index ad73e589..cd0d1bf6 100644 --- a/apps/docs/content/7.reference/5.vs-other-loggers.md +++ b/apps/docs/content/7.reference/5.vs-other-loggers.md @@ -26,7 +26,7 @@ evlog is a fully-featured general-purpose logger first, with wide events as a na - **Pick evlog over consola** when logs stop being something you read and start being something you query. consola renders for a person: three reporters ship (`fancy`, `basic`, `browser`), and structured output is a custom reporter you write. There is no pipeline, no batching, no sampling, and no request lifecycle. - **Stay on pino** only if you emit hundreds of thousands of fire-and-forget lines per second with no context accumulation, and you have a custom transport you don't want to port. That is the one shape where [the benchmarks](/reference/performance) put pino ahead. evlog still wins the wide event lifecycle by 7.7x, but pino can edge it on raw `info('hello world')` throughput. -## Feature comparison +## Compare the feature sets Three tables instead of one wall. The **Winner** column on the right tells you who wins each row at a glance; cells use semantic words ("Built-in", "Manual", "via X") instead of generic "Yes" so you can read the level of effort without reading the spec. @@ -34,7 +34,7 @@ Three tables instead of one wall. The **Winner** column on the right tells you w Hover (or tap on mobile) the **info icon** next to any feature name for a one-line explanation of what the row actually compares. :: -### Core API +### What you call in a handler | Feature | evlog | pino | consola | winston | Winner | |---------|:-----:|:----:|:-------:|:-------:|:-------| @@ -51,7 +51,7 @@ Hover (or tap on mobile) the **info icon** next to any feature name for a one-li | :feature-label[Wide events (one per operation)]{tip="Accumulate context throughout a request and emit one typed event at the end — the wide-event observability pattern from Honeycomb / Charity Majors."} | Yes | No | No | No | **evlog** | | :feature-label[Structured errors (why / fix / link)]{tip="Errors carry why (root cause), fix (actionable next step), and link (docs URL) fields that travel from server to client."} | Yes | No | No | No | **evlog** | -### Production features +### What runs in production | Feature | evlog | pino | consola | winston | Winner | |---------|:-----:|:----:|:-------:|:-------:|:-------| @@ -69,7 +69,7 @@ Hover (or tap on mobile) the **info icon** next to any feature name for a one-li | :feature-label[Self-hosted storage (NuxtHub adapter)]{tip="Store logs in your own NuxtHub-hosted database with automatic retention — no external observability vendor required."} | Built-in | No | No | No | **evlog** | | :feature-label[Edge / Workers runtime]{tip="First-class support for Cloudflare Workers and Vercel Edge with waitUntil() integration so drains finish before the runtime terminates."} | Built-in | Partial | No | No | **evlog** | -### Footprint and ecosystem +### What it costs to install | Feature | evlog | pino | consola | winston | Winner | |---------|:-----:|:----:|:-------:|:-------:|:-------| @@ -110,7 +110,7 @@ You configure `minLevel` in `initLogger({ minLevel: 'info' })` and that's it for On **request-scoped wide-event loggers**, `log.setLevel('error' | 'warn' | 'info' | 'debug')` promotes the event level explicitly without touching the `error` context, which is what you want when you control the error shape yourself. Client-side `setMinLevel()` works the same way for the browser `log` API. -### No custom levels +### Custom levels are not supported evlog ships `debug` / `info` / `warn` / `error` and that's it. pino, consola, and winston all let you define `trace`, `notice`, `fatal`, etc. We chose four levels on purpose (most teams never use more than four), but if your existing pipeline depends on `fatal` or `trace` you'll need to map them onto the closest evlog level. diff --git a/packages/nuxthub/README.md b/packages/nuxthub/README.md index 945aba55..429acc0d 100644 --- a/packages/nuxthub/README.md +++ b/packages/nuxthub/README.md @@ -10,7 +10,7 @@ Self-hosted log retention for [evlog](https://evlog.dev) using [NuxtHub](https://hub.nuxt.com) database storage. Store, query, and automatically clean up your structured logs with zero external dependencies. -## Setup +## Install and wire it Install the packages: @@ -40,7 +40,7 @@ export default defineNuxtConfig({ The cleanup cron schedule is automatically derived from the retention value. -## Database Support +## Which databases work NuxtHub supports multiple database dialects. The `evlog_events` table schema is automatically registered for: @@ -48,7 +48,7 @@ NuxtHub supports multiple database dialects. The `evlog_events` table schema is - **MySQL** - **PostgreSQL** -## Deployment +## Deploy it For Vercel deployments, the module can create a `vercel.json` with the appropriate cron schedule during `nuxi module add`. For Cloudflare and other platforms, the Nitro scheduled task handles cleanup automatically. diff --git a/scripts/content-lint/lib/corpus.mjs b/scripts/content-lint/lib/corpus.mjs index c287d1e2..423cd293 100644 --- a/scripts/content-lint/lib/corpus.mjs +++ b/scripts/content-lint/lib/corpus.mjs @@ -301,43 +301,90 @@ export function offNameTerms(text, context = text) { /** Openers treated as imperative when classifying headings (T-06). */ export const IMPERATIVE_VERBS = [ 'add', + 'apply', + 'ask', + 'attach', 'build', + 'call', 'catch', 'choose', + 'close', + 'collect', + 'compare', 'configure', 'connect', + 'consume', 'create', 'debug', + 'declare', 'define', + 'delete', 'deploy', 'disable', 'drain', 'emit', 'enable', + 'export', + 'expose', 'extend', + 'fail', 'find', 'fix', + 'follow', + 'forward', 'get', 'handle', + 'import', + 'inspect', 'install', + 'instrument', + 'join', 'keep', - 'log', 'make', 'measure', + 'merge', 'move', + 'narrow', + 'open', 'pick', + 'prove', + 'publish', + 'reach', 'read', 'redact', + 'register', + 'rename', + 'render', + 'replace', + 'reset', + 'resolve', + 'restore', + 'retry', + 'reuse', + 'rotate', 'run', 'sample', 'send', 'set', 'ship', + 'silence', + 'skip', + 'spawn', + 'stamp', 'start', 'stop', + 'strip', 'test', 'track', + 'trim', + 'try', + 'tune', + 'turn', + 'tweak', + 'update', + 'upgrade', 'use', + 'validate', 'verify', 'wire', 'wrap', diff --git a/scripts/content-lint/lib/metrics.mjs b/scripts/content-lint/lib/metrics.mjs index a4018d1a..bf3391b6 100644 --- a/scripts/content-lint/lib/metrics.mjs +++ b/scripts/content-lint/lib/metrics.mjs @@ -290,7 +290,10 @@ function classifyHeading(text) { // procedure share a shape because they are one procedure, which is the twin // `T-06` is meant to spare. if (/^(?:step\s+)?\d+\s*[.):—–]?\s/i.test(trimmed)) return 'sequence' - if (trimmed.endsWith('?')) return 'question' + // A question does not need its mark. `Where the byte counts come from` and + // `Which number moves your bill` are the same shape as the ones that carry it, + // and a page built on them is a set of answers rather than one mould. + if (trimmed.endsWith('?') || /^(what|where|which|when|why|how|who|whether)\b/i.test(trimmed)) return 'question' const first = trimmed.toLowerCase().split(/\s+/)[0] if (IMPERATIVE_VERBS.includes(first)) return 'imperative' if (/\b(is|are|was|were|has|have|does|do|can|will|becomes|makes|gives|takes)\b/i.test(trimmed)) { diff --git a/scripts/content-lint/lib/metrics.test.mjs b/scripts/content-lint/lib/metrics.test.mjs index d092aa76..02320cba 100644 --- a/scripts/content-lint/lib/metrics.test.mjs +++ b/scripts/content-lint/lib/metrics.test.mjs @@ -68,6 +68,26 @@ describe('headings', () => { expect(metrics.headings.dominant).toBe('imperative') expect(metrics.headings.share).toBe(1) }) + + it('reads a heading opening on an interrogative as a question', () => { + const titles = ['Where the byte counts come from', 'Which number moves your bill', 'What a retained event costs'] + const source = titles.map(title => `## ${title}\n\nProse under it.`).join('\n\n') + + expect(measureSource(source).headings.dominant).toBe('question') + }) + + it('leaves evlog\'s own nouns out of the verb list', () => { + const titles = ['Route filtering', 'Trace context', 'Drain pipeline'] + const source = titles.map(title => `## ${title}\n\nProse under it.`).join('\n\n') + + expect(measureSource(source).headings.dominant).toBe('noun') + }) + + it('reads a heading opening on a symbol as an API entry', () => { + const source = ['## `getMetadata()`: final snapshot', '## `getEstimatedCost()`: quick check', '## `onUpdate()`: incremental', '## `shape`: the record'].map(h => `${h}\n\nProse about it.`).join('\n\n') + + expect(measureSource(source).headings.dominant).toBe('symbol') + }) }) describe('contraction seam', () => { @@ -82,7 +102,7 @@ describe('contraction seam', () => { }) }) -describe('dashes', () => { +describe('dashes, twins', () => { it('reads a dash between two numbers as a range', () => { expect(measureSource('Manifest mode is ~30–80 lines of glue.').dashes.count).toBe(0) }) @@ -92,14 +112,12 @@ describe('dashes', () => { expect(measureSource('## Network bridge — stream server\n\nProse.').dashes.count).toBe(1) }) - it('reads a heading opening on a symbol as an API entry', () => { - const source = ['## `getMetadata()`: final snapshot', '## `getEstimatedCost()`: quick check', '## `onUpdate()`: incremental', '## `shape`: the record'].map(h => `${h}\n\nProse about it.`).join('\n\n') - - expect(measureSource(source).headings.dominant).toBe('symbol') + it('counts a dash hiding in a bullet', () => { + expect(measureSource('- Skip on serverless — the stream is in-process').dashes.count).toBe(1) }) }) -describe('bullet frames', () => { +describe('bullet frames, symbols', () => { it('reads a bolded symbol as the symbol it is', () => { const items = ['`message`', '`evlog`', '`dd`', '`service`', '`timestamp`'] const source = items.map(name => `- **${name}**: what the field carries and why`).join('\n') @@ -108,7 +126,7 @@ describe('bullet frames', () => { }) }) -describe('epigram closers', () => { +describe('epigrams, twins', () => { it('leaves a card body out of the rhythm', () => { const source = ['::card-group', ' :::card', ' ---', ' title: Nuxt', ' ---', ' Auto-imported helpers. Zero config.', ' :::', '::'].join('\n')