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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/pretty-terminal-errors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'evlog': minor
---

Improve dev terminal error output and introduce a clearer `dev` config API.

**Presets:** `dev: 'evlog' | 'nitro' | 'both'` — controls Nitro's Youch overlay (`frameworkOverlay`) and how much stack detail evlog prints in the wide event (`prettyError.detail`). Default in pretty dev is `'evlog'` (no Nitro overlay, full evlog error block). `'nitro'` keeps Nitro's stack and prints only message + Why/Fix/link in the wide event. `'both'` shows both full outputs.

**Explicit object:** `dev: { frameworkOverlay, prettyError: { snippet, stackDepth, compact, detail: 'full' | 'guidance' } }`.

Other improvements: tighter error blocks by default (`prettyError.compact`), tree spacers, hanging-indent Why/Fix wrapping, `stdout` for error wide events in dev, source-mapped file:line via Nitro `loadStackTrace`, Nitro error hook enrich+drain no longer blocks HTTP responses.
18 changes: 9 additions & 9 deletions apps/docs/content/2.learn/2.wide-events.md
Original file line number Diff line number Diff line change
Expand Up @@ -381,15 +381,15 @@ export default defineEventHandler(async (event) => {
})
```
```bash [Output]
[ERROR] POST /api/checkout (123ms)
user: { id: 1, plan: 'pro' }
cart: { items: 3, total: 9999 }
error: {
message: 'Card declined',
code: 'CARD_DECLINED',
type: 'PaymentError'
}
status: 500
ERROR [checkout] POST /api/checkout 402 in 123ms
├─ error: Card declined
│ at server/api/checkout.post.ts:42
│ ❯ 42 ┃ throw createError({ code: 'CARD_DECLINED', ... })
│ Why: Issuer declined the charge
│ Fix: Ask the customer to use another card
│ stack (3 frames hidden in node_modules)
├─ user: id=1 plan=pro
└─ cart: items=3 total=9999
```
::

Expand Down
44 changes: 44 additions & 0 deletions apps/docs/content/2.learn/3.structured-errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,50 @@ try {
}
```

## Development terminal output

In development with `pretty: true` (the default), evlog prints failed requests as a wide event in the terminal. The **`error` block comes first**, then request context (`user`, `cart`, …). Structured fields (`why`, `fix`, `link`) appear under the error message with a source location and optional code snippet.

::code-group
```typescript [server/api/checkout.post.ts]
import { createError } from 'evlog'

throw createError({
code: 'PAYMENT_DECLINED',
message: 'Card declined',
status: 402,
why: 'Issuer declined the charge',
fix: 'Ask the customer to use another card',
link: 'https://docs.example.com/payments/declined',
})
```
```bash [Terminal (pretty dev)]
ERROR [checkout] POST /api/checkout 402 in 123ms
├─ error: Card declined
│ at server/api/checkout.post.ts:42
│ ❯ 42 ┃ throw createError({ code: 'PAYMENT_DECLINED', ... })
│ Why: Issuer declined the charge
│ Fix: Ask the customer to use another card
│ More: https://docs.example.com/payments/declined
│ stack (3 frames hidden in node_modules)
├─ user: id=1 plan=pro
└─ cart: items=3 total=9999
```
::

Colors and tree connectors render in the terminal; the example above omits ANSI for readability.

### Choosing evlog vs Nitro console output

| Goal | Config |
|------|--------|
| One clean signal — wide event only, no Nitro `[request error]` overlay | `dev: 'evlog'` (default in pretty dev) |
| Wide event context + Nitro's native Youch stack (evlog prints Why/Fix only) | `dev: 'nitro'` |
| Full evlog block **and** Nitro overlay (debug) | `dev: 'both'` |
| No pretty tree (JSON logs) but still suppress Nitro overlay | `pretty: false`, `dev: { frameworkOverlay: false }` |

Fine-grained control lives under `dev.prettyError` (`snippet`, `stackDepth`, `compact`, `detail: 'full' | 'guidance'`). See [Configuration](/reference/configuration) and [Nuxt integration](/integrate/frameworks/nuxt).

## Branching on `code`

`code` is a stable, machine-readable identifier you control. Pair it with `parseError()` so the client can branch on logic without parsing user-facing messages or coupling to HTTP status codes.
Expand Down
6 changes: 6 additions & 0 deletions apps/docs/content/3.integrate/frameworks/01.nuxt.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,12 @@ All options are set in `nuxt.config.ts` under the `evlog` key:
| `exclude` | `string[]` | `undefined` | Route patterns to exclude. Exclusions take precedence |
| `routes` | `Record<string, RouteConfig>` | `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) |

::callout{icon="i-lucide-terminal" color="info"}
**Dev terminal presets:** `'evlog'` (default) — one clean signal, evlog-only stack. `'nitro'` — wide event context + Nitro Youch stack (evlog prints Why/Fix only). `'both'` — full evlog block and Nitro overlay. With `pretty: false`, set `dev: { frameworkOverlay: false }` to suppress Nitro while logging JSON.
::

| `silent` | `boolean` | `false` | Suppress console output. Events are still built, sampled, and drained. Use for stdout-based platforms |
| `sampling.rates` | `object` | `undefined` | Head sampling rates per log level (0-100%) |
| `sampling.keep` | `array` | `undefined` | Tail sampling conditions to force-keep logs |
Expand Down
13 changes: 9 additions & 4 deletions apps/docs/content/3.integrate/frameworks/02.nextjs.md
Original file line number Diff line number Diff line change
Expand Up @@ -359,12 +359,17 @@ export const POST = withEvlog(async (request: Request) => {
}
```

In the terminal, the error renders with colored output:
In the terminal, the error renders inside the wide event — error block first, then request context. Colors and tree connectors render in the terminal; the example below omits ANSI for readability.

```bash [Terminal output]
Error: Payment declined
Why: Card declined by issuer: insufficient_funds
Fix: Try a different payment method or contact your bank
ERROR [app] POST /api/payment/process 402 in 12ms
├─ error: Payment declined
│ at app/api/payment/process/route.ts:336
│ ❯ 336 ┃ throw createError({ message: 'Payment declined', ... })
│ Why: Card declined by issuer: insufficient_funds
│ Fix: Try a different payment method or contact your bank
│ stack (3 frames hidden in node_modules)
└─ payment: amount=4999
```

### Parsing Errors on the Client
Expand Down
43 changes: 43 additions & 0 deletions apps/docs/content/6.reference/1.configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ initLogger({
| `enabled` | `boolean` | `true` | Enable/disable all logging globally. When `false`, all operations become no-ops |
| `env` | `Partial<EnvironmentContext>` | Auto-detected | Environment context overrides (see below) |
| `pretty` | `boolean` | `true` in dev | Pretty print with tree formatting. Auto-detected based on `NODE_ENV` |
| `dev` | `'evlog' \| 'nitro' \| 'both' \| object` | `'evlog'` in pretty dev | Dev terminal presets or `{ frameworkOverlay, prettyError }` — see [Dev terminal output](#dev-terminal-output) |
| `silent` | `boolean` | `false` | Suppress console output. Events are still built, sampled, and passed to drains |
| `stringify` | `boolean` | `true` | Emit JSON strings when `pretty` is disabled. Set to `false` for Cloudflare Workers |
| `minLevel` | `'debug' \| 'info' \| 'warn' \| 'error'` | `'debug'` | Minimum severity for the global `log` API only (not `createLogger` / request wide events). Order: debug < info < warn < error |
Expand All @@ -57,6 +58,48 @@ initLogger({

Evaluation order for `log.info` / `log.debug` / etc.: `enabled` → `minLevel` → head sampling → output.

### Dev terminal output

Pretty error blocks run only when `pretty: true` (default in development). Production always emits JSON wide events — no stack snippets or disk reads.

Use `dev` to control **two independent axes**: whether Nitro's Youch overlay runs, and how much stack detail evlog prints inside the wide event.

**Presets** (recommended):

| Preset | Nitro overlay | evlog error block |
|--------|---------------|-------------------|
| `'evlog'` (default in pretty dev) | Off | Full — location, snippet, stack tail, Why/Fix |
| `'nitro'` | On | Guidance only — message + Why/Fix/link (stack from Nitro) |
| `'both'` | On | Full — evlog block + Nitro overlay (debug) |

```typescript [nuxt.config.ts]
export default defineNuxtConfig({
modules: ['evlog/nuxt'],
evlog: {
pretty: true,
dev: 'evlog', // or 'nitro' | 'both'
},
})
```

**Explicit object** (fine-grained):

```typescript [nuxt.config.ts]
evlog: {
dev: {
frameworkOverlay: true,
prettyError: {
snippet: false,
stackDepth: 0,
compact: true,
detail: 'guidance', // 'full' | 'guidance'
},
},
}
```

See [Structured Errors — Development terminal output](/learn/structured-errors#development-terminal-output) for an example of the pretty error tree.

### Environment Context

The `env` option controls the fields included in every log event. Most values are auto-detected from environment variables and `package.json`.
Expand Down
95 changes: 88 additions & 7 deletions packages/evlog/src/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import { buildAuditFields, consumeAuditForceKeep, finalizeAudit } from './audit'
import { markGloballyRedacted, redactEvent, resolveRedactConfig } from './redact'
import type { PluginRunner } from './shared/plugin'
import { createPluginRunner, getEmptyPluginRunner } from './shared/plugin'
import { buildErrorEntries, PRETTY_ERROR_TREE_SPACER, registerPrettyErrorSnippetReader } from './shared/pretty-error'
import type { ResolvedPrettyError } from './shared/dev-terminal'
import { resolveDevTerminal } from './shared/dev-terminal'
import { EvlogError } from './error'
import { colors, cssColors, detectEnvironment, escapeFormatString, formatDuration, getConsoleMethod, getCssLevelColor, getLevelColor, isBrowser, isDev, isLevelEnabled, matchesPattern } from './utils'

function isPlainObject(val: unknown): val is Record<string, unknown> {
Expand Down Expand Up @@ -52,6 +56,12 @@ let globalEnv: EnvironmentContext = {
}

let globalPretty = isDev()
let globalPrettyError: ResolvedPrettyError = {
snippet: isDev(),
stackDepth: 2,
compact: isDev(),
detail: 'full',
}
let globalSampling: SamplingConfig = {}
let globalStringify = true
let globalDrain: ((ctx: DrainContext) => void | Promise<void>) | undefined
Expand Down Expand Up @@ -80,6 +90,7 @@ export function initLogger(config: LoggerConfig = {}): void {
}

globalPretty = config.pretty ?? isDev()
globalPrettyError = resolveDevTerminal(config).prettyError
globalSampling = config.sampling ?? {}
globalStringify = config.stringify ?? true
globalDrain = config.drain
Expand All @@ -94,6 +105,14 @@ export function initLogger(config: LoggerConfig = {}): void {
void globalPluginRunner.runSetup({ env: { ...globalEnv } })
}

if (!isBrowser() && typeof process !== 'undefined' && process.versions?.node) {
void import('./shared/pretty-error-snippet.node.js').then((mod) => {
registerPrettyErrorSnippetReader(mod.readCodeSnippetFromDisk)
}).catch(() => {
registerPrettyErrorSnippetReader(null)
})
}

const hasAnyDrain = !!globalDrain || globalPluginRunner.hasDrain
if (globalSilent && !hasAnyDrain && !config._suppressDrainWarning) {
console.warn('[evlog] silent mode is enabled but no drain is configured. Events will be built and sampled but not output anywhere. Set a drain via initLogger({ drain }) or a framework hook (evlog:drain).')
Expand Down Expand Up @@ -521,11 +540,35 @@ function buildAIEntries(ai: Record<string, unknown>): TreeEntry[] {
return entries
}

function flushPrettyLines(lines: string[]): void {
if (lines.length === 0) return
const text = `${lines.join('\n')}\n`
if (
typeof process !== 'undefined'
&& typeof process.stdout?.write === 'function'
&& !isBrowser()
&& process.env.VITEST !== 'true'
) {
process.stdout.write(text)
return
}
console.log(lines.join('\n'))
}

function prettyPrintWideEvent(event: Record<string, unknown>): void {
const { timestamp, level, service, environment, version, ...rest } = event
const ts = typeof timestamp === 'string' ? timestamp.slice(11, 23) : ''
const levelLabel = typeof level === 'string' ? level : 'info'
const browser = isBrowser()
const lines: string[] = []
const writeLine = (...args: unknown[]) => {
if (browser) {
console.log(...args)
return
}
const [line] = args
if (typeof line === 'string') lines.push(line)
}

const parts: string[] = []
const styles: string[] = []
Expand All @@ -536,7 +579,11 @@ function prettyPrintWideEvent(event: Record<string, unknown>): void {
styles.push(cssColors.dim, cssColors.reset, lc, cssColors.reset, cssColors.cyan, cssColors.reset)
} else {
const lc = getLevelColor(levelLabel)
parts.push(`${colors.dim}${ts}${colors.reset} ${lc}${levelLabel.toUpperCase()}${colors.reset} ${colors.cyan}[${service}]${colors.reset}`)
if (isDev()) {
parts.push(`${lc}${levelLabel.toUpperCase()}${colors.reset} ${colors.cyan}[${service}]${colors.reset}`)
} else {
parts.push(`${colors.dim}${ts}${colors.reset} ${lc}${levelLabel.toUpperCase()}${colors.reset} ${colors.cyan}[${service}]${colors.reset}`)
}
}

if (rest.method && rest.path) {
Expand Down Expand Up @@ -569,34 +616,46 @@ function prettyPrintWideEvent(event: Record<string, unknown>): void {
delete rest.duration
}

console.log(parts.join(''), ...styles)
writeLine(parts.join(''), ...styles)

const aiData = isPlainObject(rest.ai) ? rest.ai : undefined
if (aiData) {
delete rest.ai
}

const errorData = rest.error
if (errorData !== undefined) {
delete rest.error
}

const restEntries = Object.entries(rest).filter(([_, v]) => v !== undefined)
const aiEntries = aiData ? buildAIEntries(aiData) : []
const allEntries: TreeEntry[] = [
const errorEntries = errorData !== undefined
? buildErrorEntries(errorData, globalPrettyError)
: []
const contextEntries: TreeEntry[] = [
...restEntries.map(([key, value]) => ({ key, value: formatValue(value) })),
...aiEntries,
]
const allEntries: TreeEntry[] = errorEntries.length > 0
? [...errorEntries, ...contextEntries]
: contextEntries

for (let i = 0; i < allEntries.length; i++) {
const entry = allEntries[i]
if (!entry) continue

const { children } = entry
const hasChildren = children !== undefined && children.length > 0
const isLast = i === allEntries.length - 1 && !hasChildren
const prefix = isLast ? '└─' : '├─'

if (browser) {
const val = entry.value ? ` ${escapeFormatString(entry.value)}` : ''
console.log(` %c${prefix}%c %c${escapeFormatString(entry.key)}:%c${val}`, cssColors.dim, cssColors.reset, cssColors.cyan, cssColors.reset)
writeLine(` %c${prefix}%c %c${escapeFormatString(entry.key)}:%c${val}`, cssColors.dim, cssColors.reset, cssColors.cyan, cssColors.reset)
} else {
const val = entry.value ? ` ${entry.value}` : ''
console.log(` ${colors.dim}${prefix}${colors.reset} ${colors.cyan}${entry.key}:${colors.reset}${val}`)
writeLine(` ${colors.dim}${prefix}${colors.reset} ${colors.cyan}${entry.key}:${colors.reset}${val}`)
}

if (hasChildren && children) {
Expand All @@ -605,16 +664,30 @@ function prettyPrintWideEvent(event: Record<string, unknown>): void {
for (let j = 0; j < children.length; j++) {
const child = children[j]
if (child === undefined) continue
if (child === PRETTY_ERROR_TREE_SPACER) {
writeLine(` ${colors.dim}${connector}${colors.reset}`)
continue
}
const isLastChild = j === children.length - 1
const childPrefix = isLastChild ? '└─' : '├─'
if (child === '') {
writeLine('')
continue
}
if (browser) {
console.log(` %c${connector} ${childPrefix}%c ${escapeFormatString(child)}`, cssColors.dim, cssColors.reset)
writeLine(` %c${connector} ${childPrefix}%c ${escapeFormatString(child)}`, cssColors.dim, cssColors.reset)
} else if (child.startsWith(' ') || child.startsWith('\x1B')) {
writeLine(` ${colors.dim}${connector}${colors.reset}${child}`)
} else {
console.log(` ${colors.dim}${connector} ${childPrefix}${colors.reset} ${child}`)
writeLine(` ${colors.dim}${connector} ${childPrefix}${colors.reset} ${child}`)
}
}
}
}

if (!browser && lines.length > 0) {
flushPrettyLines(lines)
}
}

function createLogMethod(level: LogLevel) {
Expand Down Expand Up @@ -778,6 +851,14 @@ export function createLogger<T extends object = Record<string, unknown>>(initial
if (k in err) errorObj[k] = errRecord[k]
}

if (err instanceof EvlogError) {
if (err.code) errorObj.code = err.code
if (err.why) errorObj.why = err.why
if (err.fix) errorObj.fix = err.fix
if (err.link) errorObj.link = err.link
if (err.status) errorObj.status = err.status
}

if (isPlainObject(context.error)) {
mergeInto(context.error as Record<string, unknown>, errorObj)
} else {
Expand Down
Loading
Loading