diff --git a/.changeset/pretty-terminal-errors.md b/.changeset/pretty-terminal-errors.md new file mode 100644 index 00000000..386635e0 --- /dev/null +++ b/.changeset/pretty-terminal-errors.md @@ -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. diff --git a/apps/docs/content/2.learn/2.wide-events.md b/apps/docs/content/2.learn/2.wide-events.md index f96e0a2e..5e6db6d6 100644 --- a/apps/docs/content/2.learn/2.wide-events.md +++ b/apps/docs/content/2.learn/2.wide-events.md @@ -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 ``` :: diff --git a/apps/docs/content/2.learn/3.structured-errors.md b/apps/docs/content/2.learn/3.structured-errors.md index 84d62b38..8d6e229d 100644 --- a/apps/docs/content/2.learn/3.structured-errors.md +++ b/apps/docs/content/2.learn/3.structured-errors.md @@ -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. diff --git a/apps/docs/content/3.integrate/frameworks/01.nuxt.md b/apps/docs/content/3.integrate/frameworks/01.nuxt.md index eb245731..0455a149 100644 --- a/apps/docs/content/3.integrate/frameworks/01.nuxt.md +++ b/apps/docs/content/3.integrate/frameworks/01.nuxt.md @@ -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` | `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 | diff --git a/apps/docs/content/3.integrate/frameworks/02.nextjs.md b/apps/docs/content/3.integrate/frameworks/02.nextjs.md index 4c4ddd1a..26adc686 100644 --- a/apps/docs/content/3.integrate/frameworks/02.nextjs.md +++ b/apps/docs/content/3.integrate/frameworks/02.nextjs.md @@ -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 diff --git a/apps/docs/content/6.reference/1.configuration.md b/apps/docs/content/6.reference/1.configuration.md index 9c07696e..62b50b37 100644 --- a/apps/docs/content/6.reference/1.configuration.md +++ b/apps/docs/content/6.reference/1.configuration.md @@ -43,6 +43,7 @@ initLogger({ | `enabled` | `boolean` | `true` | Enable/disable all logging globally. When `false`, all operations become no-ops | | `env` | `Partial` | 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 | @@ -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`. diff --git a/packages/evlog/src/logger.ts b/packages/evlog/src/logger.ts index 00584a9b..7d99f9f8 100644 --- a/packages/evlog/src/logger.ts +++ b/packages/evlog/src/logger.ts @@ -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 { @@ -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) | undefined @@ -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 @@ -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).') @@ -521,11 +540,35 @@ function buildAIEntries(ai: Record): 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): 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[] = [] @@ -536,7 +579,11 @@ function prettyPrintWideEvent(event: Record): 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) { @@ -569,23 +616,35 @@ function prettyPrintWideEvent(event: Record): 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 @@ -593,10 +652,10 @@ function prettyPrintWideEvent(event: Record): void { 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) { @@ -605,16 +664,30 @@ function prettyPrintWideEvent(event: Record): 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) { @@ -778,6 +851,14 @@ export function createLogger>(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, errorObj) } else { diff --git a/packages/evlog/src/nitro-v3/errorHandler.ts b/packages/evlog/src/nitro-v3/errorHandler.ts index 8c504a23..de48efa0 100644 --- a/packages/evlog/src/nitro-v3/errorHandler.ts +++ b/packages/evlog/src/nitro-v3/errorHandler.ts @@ -1,33 +1,49 @@ import { parseURL } from 'ufo' import { defineErrorHandler } from 'nitro' -import { resolveEvlogError, extractErrorStatus, serializeEvlogErrorResponse } from '../nitro' +import { + resolveEvlogError, + extractErrorStatus, + buildPlainNitroErrorBody, + serializeEvlogErrorResponse, + shouldSuppressNitroDevOverlay, + suppressNitroDevOverlay, + markH3ErrorHandled, +} from '../nitro' +import type { NitroErrorHandlerContext } from '../shared/nitro-types' /** * Custom Nitro v3 error handler that properly serializes EvlogError. * This ensures that 'data' (containing 'why', 'fix', 'link') is preserved * in the JSON response regardless of the underlying HTTP framework. * - * For non-EvlogError, returns undefined to let Nitro's default handler take over. - * * Usage in nitro.config.ts: * ```ts - * // errorHandler.ts * export { default } from 'evlog/nitro/v3/errorHandler' - * // nitro.config.ts - * export default defineConfig({ - * errorHandler: './errorHandler', - * }) * ``` */ -export default defineErrorHandler((error, event) => { - const evlogError = resolveEvlogError(error) +export default defineErrorHandler(async (error, event, ctx: NitroErrorHandlerContext) => { + const suppressOverlay = shouldSuppressNitroDevOverlay() + + if (!suppressOverlay) { + await ctx.defaultHandler(error, event, { silent: false }) + } - if (!evlogError) return + markH3ErrorHandled(event) + + if (suppressOverlay) { + suppressNitroDevOverlay(error) + } const url = parseURL(event.req.url).pathname - const status = extractErrorStatus(evlogError) + const isDev = process.env.NODE_ENV === 'development' + const evlogError = resolveEvlogError(error) + + const body = evlogError + ? serializeEvlogErrorResponse(evlogError, url) + : buildPlainNitroErrorBody(error, url, isDev) + const status = extractErrorStatus(evlogError ?? error) - return new Response(JSON.stringify(serializeEvlogErrorResponse(evlogError, url)), { + return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' }, }) diff --git a/packages/evlog/src/nitro-v3/module.ts b/packages/evlog/src/nitro-v3/module.ts index 864ceb7b..0d8c62c0 100644 --- a/packages/evlog/src/nitro-v3/module.ts +++ b/packages/evlog/src/nitro-v3/module.ts @@ -2,6 +2,7 @@ import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import type { Nitro } from 'nitro/types' import type { NitroModuleOptions } from '../nitro' +import { prependNitroErrorHandler } from '../nitro' export type { NitroModuleOptions } @@ -31,14 +32,11 @@ export default function evlog(options?: NitroModuleOptions) { } - // Set error handler only if not already configured by user - if (!nitro.options.errorHandler) { - nitro.options.errorHandler = [resolveModulePath('errorHandler')] - } else if (Array.isArray(nitro.options.errorHandler)) { - nitro.options.errorHandler.unshift(resolveModulePath('errorHandler')) - } else if (typeof nitro.options.errorHandler === 'string') { - nitro.options.errorHandler = [resolveModulePath('errorHandler'), nitro.options.errorHandler] - } + const handlers = prependNitroErrorHandler( + nitro.options.errorHandler, + resolveModulePath('errorHandler'), + ) + nitro.options.errorHandler = Array.isArray(handlers) ? handlers : [handlers] // Inject config into runtimeConfig — works in production where the // plugin is bundled through Nitro's builder and the virtual diff --git a/packages/evlog/src/nitro-v3/plugin.ts b/packages/evlog/src/nitro-v3/plugin.ts index c1558f70..11148f35 100644 --- a/packages/evlog/src/nitro-v3/plugin.ts +++ b/packages/evlog/src/nitro-v3/plugin.ts @@ -3,7 +3,11 @@ import type { CaptureError } from 'nitro/types' import type { HTTPEvent } from 'nitro/h3' import { parseURL } from 'ufo' import { createRequestLogger, getGlobalPluginRunner, initLogger, isEnabled } from '../logger' +import { registerPrettyErrorSnippetReader } from '../shared/pretty-error' +import { readCodeSnippetFromDisk } from '../shared/pretty-error-snippet.node' +import { enrichErrorStackForDev } from '../shared/enrich-error-stack.node' import { shouldLog, getServiceForPath, extractErrorStatus } from '../nitro' +import { extendDeferredDrain } from '../nitro/enrich-drain' import { normalizeRedactConfig } from '../redact' import { resolveEvlogConfigForNitroPlugin, setActiveNitroRuntime } from '../shared/nitroConfigBridge' import type { EnrichContext, RequestLogger, TailSamplingContext, WideEvent } from '../types' @@ -71,6 +75,7 @@ async function callDrainHook( emittedEvent: WideEvent | null, event: HTTPEvent, hookContext: Omit, + options?: { deferDrain?: boolean }, ): Promise { if (!emittedEvent) return @@ -102,15 +107,18 @@ async function callDrainHook( if (drainTasks.length === 0) return const drainPromise = Promise.all(drainTasks) - // Use waitUntil if available (srvx native — Cloudflare Workers, Vercel Edge, etc.) - // This keeps the runtime alive for background work without blocking the response + // deferDrain: never block Nitro Node responses; extend lifetime on Cloudflare only. + if (options?.deferDrain) { + const waitUntil = globalThis.navigator?.userAgent === 'Cloudflare-Workers' && typeof event.req.waitUntil === 'function' + ? event.req.waitUntil.bind(event.req) + : undefined + extendDeferredDrain(drainPromise, waitUntil) + return + } + if (typeof event.req.waitUntil === 'function') { event.req.waitUntil(drainPromise) } else { - // Fallback: await drain to prevent lost logs in serverless environments - // (e.g. Vercel Fluid Compute). On the normal path this runs from the - // response hook (response already sent); on the error path it may run - // before the error response is finalized. await drainPromise } } @@ -120,6 +128,7 @@ async function callEnrichAndDrain( emittedEvent: WideEvent | null, event: HTTPEvent, res?: Response, + options?: { deferDrain?: boolean }, ): Promise { if (!emittedEvent) return @@ -137,7 +146,7 @@ async function callEnrichAndDrain( await runner.runEnrich(enrichCtx) } - await callDrainHook(hooks, emittedEvent, event, hookContext) + await callDrainHook(hooks, emittedEvent, event, hookContext, options) } /** @@ -155,10 +164,13 @@ export default definePlugin(async (nitroApp) => { const redact = normalizeRedactConfig(evlogConfig?.redact as boolean | Record | undefined) + registerPrettyErrorSnippetReader(readCodeSnippetFromDisk) + initLogger({ enabled: evlogConfig?.enabled, env: evlogConfig?.env, pretty: evlogConfig?.pretty, + dev: evlogConfig?.dev, silent: evlogConfig?.silent, sampling: evlogConfig?.sampling, minLevel: evlogConfig?.minLevel, @@ -224,7 +236,7 @@ export default definePlugin(async (nitroApp) => { hooks.hook('response', async (res, event) => { const ctx = event.req.context // Skip if already emitted by error hook or route was filtered out - if (ctx?._evlogEmitted || !ctx?._evlogShouldEmit) return + if (ctx?._evlogEmitted || ctx?._evlogEmitting || !ctx?._evlogShouldEmit) return const log = ctx?.log as RequestLogger | undefined if (!log || !ctx) return @@ -263,36 +275,44 @@ export default definePlugin(async (nitroApp) => { const log = ctx.log as RequestLogger | undefined if (!log) return - // Check if error.cause is an EvlogError (thrown errors get wrapped in HTTPError by nitro) - const actualError = (error.cause as Error)?.name === 'EvlogError' - ? error.cause as Error - : error as Error + ctx._evlogEmitting = true + try { + const actualError = (error.cause as Error)?.name === 'EvlogError' + ? error.cause as Error + : error as Error - log.error(actualError) + void enrichErrorStackForDev(actualError, { pretty: evlogConfig?.pretty }) + log.error(actualError) - const errorStatus = extractErrorStatus(actualError) - log.set({ status: errorStatus }) + const errorStatus = extractErrorStatus(actualError) + log.set({ status: errorStatus }) - const { pathname } = parseURL(e.req.url) - const startTime = ctx._evlogStartTime as number | undefined - const durationMs = startTime ? Date.now() - startTime : undefined + const { pathname } = parseURL(e.req.url) + const startTime = ctx._evlogStartTime as number | undefined + const durationMs = startTime ? Date.now() - startTime : undefined - const tailCtx: TailSamplingContext = { - status: errorStatus, - duration: durationMs, - path: pathname, - method: e.req.method, - context: log.getContext(), - shouldKeep: false, - } - - await hooks.callHook('evlog:emit:keep', tailCtx) - const runner = getGlobalPluginRunner() - if (runner.hasKeep) await runner.runKeep(tailCtx) + const tailCtx: TailSamplingContext = { + status: errorStatus, + duration: durationMs, + path: pathname, + method: e.req.method, + context: log.getContext(), + shouldKeep: false, + } - ctx._evlogEmitted = true + await hooks.callHook('evlog:emit:keep', tailCtx) + const runner = getGlobalPluginRunner() + if (runner.hasKeep) await runner.runKeep(tailCtx) - const emittedEvent = log.emit({ _forceKeep: tailCtx.shouldKeep }) - await callEnrichAndDrain(hooks, emittedEvent, e) + const emittedEvent = log.emit({ _forceKeep: tailCtx.shouldKeep }) + if (emittedEvent) { + ctx._evlogEmitted = true + void callEnrichAndDrain(hooks, emittedEvent, e, undefined, { deferDrain: true }).catch((err) => { + console.error('[evlog] background enrich/drain failed:', err) + }) + } + } finally { + delete ctx._evlogEmitting + } }) }) diff --git a/packages/evlog/src/nitro.ts b/packages/evlog/src/nitro.ts index 7e4cd2a1..1676ba8c 100644 --- a/packages/evlog/src/nitro.ts +++ b/packages/evlog/src/nitro.ts @@ -1,5 +1,11 @@ import type { EnvironmentContext, LogLevel, RedactConfig, RouteConfig, SamplingConfig } from './types' +import type { DevTerminalInput, DevTerminalResolveInput } from './shared/dev-terminal' import { extractErrorStatus } from './shared/errors' +import { resolveDevTerminal, shouldShowFrameworkOverlay } from './shared/dev-terminal' +import { readEvlogConfigSync } from './shared/nitroConfigBridge' + +export type { DevTerminalInput, DevTerminalPreset, DevPrettyErrorConfig, DevTerminalConfigObject, ResolvedPrettyError } from './shared/dev-terminal' +export { resolveDevTerminal, shouldShowFrameworkOverlay } from './shared/dev-terminal' export { shouldLog, getServiceForPath } from './shared/routes' @@ -21,6 +27,12 @@ export interface NitroModuleOptions { */ pretty?: boolean + /** + * Dev terminal output: preset or explicit overlay + pretty-error settings. + * @default 'evlog' when pretty in development + */ + dev?: DevTerminalInput + /** * Suppress built-in console output. * When true, events are still built, sampled, and passed to drains, @@ -76,10 +88,9 @@ export interface NitroModuleOptions { * {@link import('./shared/define').EvlogConfig} for the canonical user-facing * config shape. */ -export interface NitroPluginEvlogConfig { +export interface NitroPluginEvlogConfig extends DevTerminalResolveInput { enabled?: boolean env?: Record - pretty?: boolean silent?: boolean include?: string[] exclude?: string[] @@ -104,6 +115,96 @@ export function resolveEvlogError(error: Error): Error | null { export { extractErrorStatus } from './shared/errors' +/** + * Mark an h3 event handled synchronously. + * Nitro chains a built-in dev handler after custom handlers; `send()` defers + * `res.end`, so without this the Youch overlay still runs. + * @internal + */ +export function markH3ErrorHandled(event: { _handled?: boolean }): void { + event._handled = true +} + +/** + * Prepend evlog's Nitro error handler so it runs before framework handlers (e.g. Nuxt). + * @internal + */ +export function prependNitroErrorHandler( + errorHandler: string | string[] | undefined, + handlerPath: string, +): string | string[] { + if (!errorHandler) return handlerPath + if (Array.isArray(errorHandler)) { + const rest = errorHandler.filter(h => h !== handlerPath) + return [handlerPath, ...rest] + } + if (errorHandler === handlerPath) return handlerPath + return [handlerPath, errorHandler] +} + +/** + * Whether the Nitro dev Youch overlay should be suppressed for this process. + * @internal + */ +let cachedConfigKey: string | undefined +let cachedSuppressOverlay: boolean | undefined + +export function shouldSuppressNitroDevOverlay(): boolean { + const config = readEvlogConfigSync() + const key = config ? JSON.stringify(config) : '' + if (cachedSuppressOverlay !== undefined && cachedConfigKey === key) { + return cachedSuppressOverlay + } + + cachedConfigKey = key + cachedSuppressOverlay = !resolveDevTerminal(config ?? {}).frameworkOverlay + return cachedSuppressOverlay +} + +/** @internal Reset overlay decision cache — tests only. */ +export function resetNitroDevOverlayCache(): void { + cachedConfigKey = undefined + cachedSuppressOverlay = undefined +} + +/** + * Clear Nitro/h3 unhandled flags so the dev Youch logger skips this error. + * @internal + */ +export function suppressNitroDevOverlay(error: Error): void { + const err = error as Error & { unhandled?: boolean; fatal?: boolean } + err.unhandled = false + err.fatal = false +} + +/** + * Build Nitro-compatible JSON for non-EvlogError throws. + * Sanitizes 5xx messages in production. + */ +export function buildPlainNitroErrorBody( + error: Error, + url: string, + isDev = process.env.NODE_ENV === 'development', +): Record { + const status = extractErrorStatus(error) + const rawMessage = ((error as { statusText?: string }).statusText + ?? (error as { statusMessage?: string }).statusMessage + ?? error.message) || 'Internal Server Error' + const message = isDev + ? rawMessage + : (status >= 500 ? 'Internal Server Error' : rawMessage) + + return { + url, + status, + statusCode: status, + statusText: message, + statusMessage: message, + message, + error: true, + } +} + /** * Build a standard evlog error JSON response body. * Used by both v2 and v3 error handlers to ensure consistent shape. diff --git a/packages/evlog/src/nitro/enrich-drain.ts b/packages/evlog/src/nitro/enrich-drain.ts new file mode 100644 index 00000000..f75ff995 --- /dev/null +++ b/packages/evlog/src/nitro/enrich-drain.ts @@ -0,0 +1,141 @@ +import type { NitroApp } from 'nitropack/types' +import { getHeaders } from 'h3' +import { getGlobalPluginRunner } from '../logger' +import type { EnrichContext, ServerEvent, WideEvent } from '../types' +import { filterSafeHeaders } from '../utils' + +function getSafeHeaders(event: ServerEvent): Record { + const allHeaders = getHeaders(event as Parameters[0]) + return filterSafeHeaders(allHeaders) +} + +function getSafeResponseHeaders(event: ServerEvent): Record | undefined { + const headers: Record = {} + const nodeRes = event.node?.res as { getHeaders?: () => Record } | undefined + + if (nodeRes?.getHeaders) { + for (const [key, value] of Object.entries(nodeRes.getHeaders())) { + if (value === undefined) continue + headers[key] = Array.isArray(value) ? value.join(', ') : String(value) + } + } + + if (event.response?.headers) { + event.response.headers.forEach((value, key) => { + headers[key] = value + }) + } + + if (Object.keys(headers).length === 0) return undefined + return filterSafeHeaders(headers) +} + +function getResponseStatus(event: ServerEvent): number { + if (event.node?.res?.statusCode) { + return event.node.res.statusCode + } + if (event.response?.status) { + return event.response.status + } + if (typeof event.context.status === 'number') { + return event.context.status + } + return 200 +} + +function buildHookContext(event: ServerEvent): Omit { + const responseHeaders = getSafeResponseHeaders(event) + return { + request: { method: event.method, path: event.path }, + headers: getSafeHeaders(event), + response: { + status: getResponseStatus(event), + headers: responseHeaders, + }, + } +} + +/** @internal Extend drain lifetime on Cloudflare without blocking Nitro Node responses. */ +export function extendDeferredDrain( + drainPromise: Promise, + waitUntil?: (promise: Promise) => void, +): void { + void drainPromise.catch((err) => { + console.error('[evlog] background drain failed:', err) + }) + if (typeof waitUntil === 'function') { + waitUntil(drainPromise) + } +} + +function resolveDeferredWaitUntil(event: ServerEvent): ((promise: Promise) => void) | undefined { + if (globalThis.navigator?.userAgent !== 'Cloudflare-Workers') return undefined + const waitUntilCtx = event.context.cloudflare?.context ?? event.context + if (typeof waitUntilCtx?.waitUntil === 'function') { + return waitUntilCtx.waitUntil.bind(waitUntilCtx) + } + return undefined +} + +/** + * Run evlog enrich + drain hooks for an emitted wide event. + * @internal Exported for Nitro plugin tests. + */ +export async function callEnrichAndDrain( + nitroApp: NitroApp, + emittedEvent: WideEvent | null, + event: ServerEvent, + options?: { deferDrain?: boolean }, +): Promise { + if (!emittedEvent) return + + const hookContext = buildHookContext(event) + const enrichCtx: EnrichContext = { event: emittedEvent, ...hookContext } + const runner = getGlobalPluginRunner() + + try { + await nitroApp.hooks.callHook('evlog:enrich', enrichCtx) + } catch (err) { + console.error('[evlog] enrich failed:', err) + } + if (runner.hasEnrich) { + try { + await runner.runEnrich(enrichCtx) + } catch (err) { + console.error('[evlog] enrich failed:', err) + } + } + + const drainCtx = { + event: emittedEvent, + request: hookContext.request, + headers: hookContext.headers, + } + const drainTasks: Array> = [ + nitroApp.hooks.callHook('evlog:drain', drainCtx).catch((err) => { + console.error('[evlog] drain failed:', err) + }), + ] + if (runner.hasDrain) { + drainTasks.push( + runner.runDrain(drainCtx).catch((err) => { + console.error('[evlog] drain failed:', err) + }), + ) + } + const drainPromise = Promise.all(drainTasks) + + // deferDrain: never block the HTTP error response on Nitro Node (h3 2.13+ waitUntil + // queues work before send). On Cloudflare, register waitUntil so drains survive. + if (options?.deferDrain) { + extendDeferredDrain(drainPromise, resolveDeferredWaitUntil(event)) + return + } + + const waitUntilCtx = event.context.cloudflare?.context ?? event.context + if (typeof waitUntilCtx?.waitUntil === 'function') { + waitUntilCtx.waitUntil(drainPromise) + } else { + await drainPromise + } +} diff --git a/packages/evlog/src/nitro/errorHandler.ts b/packages/evlog/src/nitro/errorHandler.ts index 91321244..e128f582 100644 --- a/packages/evlog/src/nitro/errorHandler.ts +++ b/packages/evlog/src/nitro/errorHandler.ts @@ -2,7 +2,16 @@ // internal/app.mjs which imports virtual modules that crash outside rollup builds. import { defineNitroErrorHandler } from 'nitropack/runtime/internal/error/utils' import { getRequestURL, setResponseHeader, setResponseStatus, send } from 'h3' -import { resolveEvlogError, extractErrorStatus, serializeEvlogErrorResponse } from '../nitro' +import { + resolveEvlogError, + extractErrorStatus, + buildPlainNitroErrorBody, + serializeEvlogErrorResponse, + markH3ErrorHandled, + shouldSuppressNitroDevOverlay, + suppressNitroDevOverlay, +} from '../nitro' +import type { NitroErrorHandlerContext } from '../shared/nitro-types' /** * Custom Nitro error handler that properly serializes EvlogError. @@ -12,38 +21,28 @@ import { resolveEvlogError, extractErrorStatus, serializeEvlogErrorResponse } fr * For non-EvlogError, it preserves Nitro's default response shape while * sanitizing internal error details in production for 5xx errors. */ -export default defineNitroErrorHandler((error, event) => { +export default defineNitroErrorHandler(async (error, event, ctx: NitroErrorHandlerContext) => { + const suppressOverlay = shouldSuppressNitroDevOverlay() + + if (!suppressOverlay) { + await ctx.defaultHandler(error, event, { silent: false }) + } + + markH3ErrorHandled(event) + if (suppressOverlay) { + suppressNitroDevOverlay(error) + } + const evlogError = resolveEvlogError(error) const isDev = process.env.NODE_ENV === 'development' const url = getRequestURL(event, { xForwardedHost: true }).pathname - // For non-EvlogError, preserve Nitro's default response shape if (!evlogError) { - const status = extractErrorStatus(error) - - // Derive message from statusText/statusMessage/message for cross-version compatibility - const rawMessage = ((error as { statusText?: string }).statusText - ?? (error as { statusMessage?: string }).statusMessage - ?? error.message) || 'Internal Server Error' - - // Sanitize internal error details in production for 5xx errors - const message = isDev - ? rawMessage - : (status >= 500 ? 'Internal Server Error' : rawMessage) - - setResponseStatus(event, status) + const body = buildPlainNitroErrorBody(error, url, isDev) + setResponseStatus(event, body.status as number) setResponseHeader(event, 'Content-Type', 'application/json') - - return send(event, JSON.stringify({ - url, - status, - statusCode: status, - statusText: message, - statusMessage: message, - message, - error: true, - })) + return send(event, JSON.stringify(body)) } const status = extractErrorStatus(evlogError) diff --git a/packages/evlog/src/nitro/module.ts b/packages/evlog/src/nitro/module.ts index c3e77a64..957bc90b 100644 --- a/packages/evlog/src/nitro/module.ts +++ b/packages/evlog/src/nitro/module.ts @@ -2,6 +2,7 @@ import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import type { Nitro } from 'nitropack' import type { NitroModuleOptions } from '../nitro' +import { prependNitroErrorHandler } from '../nitro' export type { NitroModuleOptions } @@ -23,10 +24,11 @@ export default function evlog(options?: NitroModuleOptions) { nitro.options.plugins = nitro.options.plugins || [] nitro.options.plugins.push(resolveModulePath('plugin')) - // Set error handler only if not already configured by user - if (!nitro.options.errorHandler) { - nitro.options.errorHandler = resolveModulePath('errorHandler') - } + // Prepend so evlog runs before any framework handler (Nuxt registers its own). + nitro.options.errorHandler = prependNitroErrorHandler( + nitro.options.errorHandler, + resolveModulePath('errorHandler'), + ) // explicitly tell nitro to bundle evlog's files to correctly resolve nitro dependencies // in nitro v2 we can only disable externals globally diff --git a/packages/evlog/src/nitro/plugin.ts b/packages/evlog/src/nitro/plugin.ts index 260fcaf1..61fd6cc7 100644 --- a/packages/evlog/src/nitro/plugin.ts +++ b/packages/evlog/src/nitro/plugin.ts @@ -1,4 +1,3 @@ -import type { NitroApp } from 'nitropack/types' // Import from specific subpaths to avoid the barrel 'nitropack/runtime' which // re-exports from internal/app.mjs — that file imports #nitro-internal-virtual/* // modules that only exist inside rollup builds and crash when loaded externally @@ -6,39 +5,22 @@ import type { NitroApp } from 'nitropack/types' import { defineNitroPlugin } from 'nitropack/runtime/internal/plugin' import { getHeaders } from 'h3' import { createRequestLogger, getGlobalPluginRunner, initLogger, isEnabled } from '../logger' +import { registerPrettyErrorSnippetReader } from '../shared/pretty-error' +import { readCodeSnippetFromDisk } from '../shared/pretty-error-snippet.node' +import { enrichErrorStackForDev } from '../shared/enrich-error-stack.node' import { shouldLog, getServiceForPath, extractErrorStatus } from '../nitro' import { normalizeRedactConfig } from '../redact' import { resolveEvlogConfigForNitroPlugin, setActiveNitroRuntime } from '../shared/nitroConfigBridge' import { startStreamServer, type StreamServerOptions } from '../stream' -import type { EnrichContext, RequestLogger, ServerEvent, TailSamplingContext, WideEvent } from '../types' +import type { RequestLogger, ServerEvent, TailSamplingContext } from '../types' import { filterSafeHeaders } from '../utils' +import { callEnrichAndDrain } from './enrich-drain' function getSafeHeaders(event: ServerEvent): Record { const allHeaders = getHeaders(event as Parameters[0]) return filterSafeHeaders(allHeaders) } -function getSafeResponseHeaders(event: ServerEvent): Record | undefined { - const headers: Record = {} - const nodeRes = event.node?.res as { getHeaders?: () => Record } | undefined - - if (nodeRes?.getHeaders) { - for (const [key, value] of Object.entries(nodeRes.getHeaders())) { - if (value === undefined) continue - headers[key] = Array.isArray(value) ? value.join(', ') : String(value) - } - } - - if (event.response?.headers) { - event.response.headers.forEach((value, key) => { - headers[key] = value - }) - } - - if (Object.keys(headers).length === 0) return undefined - return filterSafeHeaders(headers) -} - function getResponseStatus(event: ServerEvent): number { // Node.js style if (event.node?.res?.statusCode) { @@ -58,77 +40,19 @@ function getResponseStatus(event: ServerEvent): number { return 200 } -function buildHookContext(event: ServerEvent): Omit { - const responseHeaders = getSafeResponseHeaders(event) - return { - request: { method: event.method, path: event.path }, - headers: getSafeHeaders(event), - response: { - status: getResponseStatus(event), - headers: responseHeaders, - }, - } -} - -async function callEnrichAndDrain( - nitroApp: NitroApp, - emittedEvent: WideEvent | null, - event: ServerEvent, -): Promise { - if (!emittedEvent) return - - const hookContext = buildHookContext(event) - const enrichCtx: EnrichContext = { event: emittedEvent, ...hookContext } - const runner = getGlobalPluginRunner() - - try { - await nitroApp.hooks.callHook('evlog:enrich', enrichCtx) - } catch (err) { - console.error('[evlog] enrich failed:', err) - } - if (runner.hasEnrich) { - await runner.runEnrich(enrichCtx) - } - - const drainCtx = { - event: emittedEvent, - request: hookContext.request, - headers: hookContext.headers, - } - const drainTasks: Array> = [ - nitroApp.hooks.callHook('evlog:drain', drainCtx).catch((err) => { - console.error('[evlog] drain failed:', err) - }), - ] - if (runner.hasDrain) { - drainTasks.push(runner.runDrain(drainCtx)) - } - const drainPromise = Promise.all(drainTasks) - - // Use waitUntil if available (Cloudflare Workers, Vercel Edge, etc.) - // This keeps the runtime alive for background work without blocking the response - const waitUntilCtx = event.context.cloudflare?.context ?? event.context - if (typeof waitUntilCtx?.waitUntil === 'function') { - waitUntilCtx.waitUntil(drainPromise) - } else { - // Fallback: await drain to prevent lost logs in serverless environments - // (e.g. Vercel Fluid Compute). On the normal path this runs from - // afterResponse (response already sent); on the error path it may run - // before the error response is finalized. - await drainPromise - } -} - export default defineNitroPlugin(async (nitroApp) => { setActiveNitroRuntime('v2') const evlogConfig = await resolveEvlogConfigForNitroPlugin() const redact = normalizeRedactConfig(evlogConfig?.redact as boolean | Record | undefined) + registerPrettyErrorSnippetReader(readCodeSnippetFromDisk) + initLogger({ enabled: evlogConfig?.enabled, env: evlogConfig?.env, pretty: evlogConfig?.pretty, + dev: evlogConfig?.dev, silent: evlogConfig?.silent, sampling: evlogConfig?.sampling, minLevel: evlogConfig?.minLevel, @@ -208,13 +132,17 @@ export default defineNitroPlugin(async (nitroApp) => { if (!e.context._evlogShouldEmit) return const requestLog = e.context.log as RequestLogger | undefined - if (requestLog) { - requestLog.error(error as Error) + if (!requestLog) return + + e.context._evlogEmitting = true + try { + const err = error as Error + void enrichErrorStackForDev(err, { pretty: evlogConfig?.pretty }) + requestLog.error(err) const errorStatus = extractErrorStatus(error) requestLog.set({ status: errorStatus }) - // Build tail sampling context const startTime = e.context._evlogStartTime as number | undefined const durationMs = startTime ? Date.now() - startTime : undefined @@ -227,22 +155,25 @@ export default defineNitroPlugin(async (nitroApp) => { shouldKeep: false, } - // Call evlog:emit:keep hook + plugin runner keep hook await nitroApp.hooks.callHook('evlog:emit:keep', tailCtx) const runner = getGlobalPluginRunner() if (runner.hasKeep) await runner.runKeep(tailCtx) - e.context._evlogEmitted = true - const emittedEvent = requestLog.emit({ _forceKeep: tailCtx.shouldKeep }) - await callEnrichAndDrain(nitroApp, emittedEvent, e) + if (emittedEvent) { + e.context._evlogEmitted = true + void callEnrichAndDrain(nitroApp, emittedEvent, e, { deferDrain: true }).catch((err) => { + console.error('[evlog] background enrich/drain failed:', err) + }) + } + } finally { + delete e.context._evlogEmitting } }) nitroApp.hooks.hook('afterResponse', async (event) => { const e = event as ServerEvent - // Skip if already emitted by error hook or route was filtered out - if (e.context._evlogEmitted || !e.context._evlogShouldEmit) return + if (e.context._evlogEmitted || e.context._evlogEmitting || !e.context._evlogShouldEmit) return const requestLog = e.context.log as RequestLogger | undefined if (requestLog) { diff --git a/packages/evlog/src/nuxt/module.ts b/packages/evlog/src/nuxt/module.ts index 894ba587..6d4db6ca 100644 --- a/packages/evlog/src/nuxt/module.ts +++ b/packages/evlog/src/nuxt/module.ts @@ -10,6 +10,8 @@ import { } from '@nuxt/kit' import type { NitroConfig } from 'nitropack' import type { EnvironmentContext, LogLevel, RedactConfig, RouteConfig, SamplingConfig, TransportConfig } from '../types' +import type { DevTerminalInput } from '../shared/dev-terminal' +import { prependNitroErrorHandler } from '../nitro' import { createStripPlugin } from '../vite/strip' import { createSourceLocationPlugin } from '../vite/source-location' import { name, version } from '../../package.json' @@ -76,6 +78,12 @@ export interface ModuleOptions { */ pretty?: boolean + /** + * Dev terminal output: preset or explicit overlay + pretty-error settings. + * @default 'evlog' when pretty in development + */ + dev?: DevTerminalInput + /** * Suppress built-in console output. * When true, events are still built, sampled, and passed to drains, @@ -357,7 +365,8 @@ export default defineNuxtModule({ // often cannot resolve useRuntimeConfig().evlog via dynamic import reliably). // @ts-expect-error nitro:config hook exists but is not in NuxtHooks type nuxt.hook('nitro:config', (nitroConfig: NitroConfig) => { - nitroConfig.errorHandler = nitroConfig.errorHandler || resolver.resolve('../nitro/errorHandler') + const evlogHandler = resolver.resolve('../nitro/errorHandler').replace(/\\/g, '/') + nitroConfig.errorHandler = prependNitroErrorHandler(nitroConfig.errorHandler, evlogHandler) const evlogForNitro = nuxt.options.runtimeConfig.evlog ?? options if (evlogForNitro !== undefined && typeof evlogForNitro === 'object') { diff --git a/packages/evlog/src/shared/define.ts b/packages/evlog/src/shared/define.ts index f5dd1b13..1567e576 100644 --- a/packages/evlog/src/shared/define.ts +++ b/packages/evlog/src/shared/define.ts @@ -1,4 +1,5 @@ import type { EnvironmentContext, LoggerConfig, SamplingConfig } from '../types' +import type { DevTerminalInput } from './dev-terminal' import type { BaseEvlogOptions } from './middleware' /** @@ -16,6 +17,11 @@ export interface EvlogConfig extends BaseEvlogOptions { enabled?: boolean /** Auto-detected from `NODE_ENV` when omitted. */ pretty?: boolean + /** + * Dev terminal output: preset or explicit overlay + pretty-error settings. + * @default 'evlog' when pretty in development + */ + dev?: DevTerminalInput sampling?: SamplingConfig /** Suppress built-in console output (useful when drains own the channel). */ silent?: boolean @@ -66,6 +72,7 @@ export function toLoggerConfig(config: EvlogConfig): LoggerConfig { if (env) out.env = env if (config.enabled !== undefined) out.enabled = config.enabled if (config.pretty !== undefined) out.pretty = config.pretty + if (config.dev !== undefined) out.dev = config.dev if (config.sampling !== undefined) out.sampling = config.sampling if (config.minLevel !== undefined) out.minLevel = config.minLevel if (config.stringify !== undefined) out.stringify = config.stringify diff --git a/packages/evlog/src/shared/dev-terminal.ts b/packages/evlog/src/shared/dev-terminal.ts new file mode 100644 index 00000000..87569720 --- /dev/null +++ b/packages/evlog/src/shared/dev-terminal.ts @@ -0,0 +1,102 @@ +import { isDev } from '../utils' + +/** Dev terminal preset — shorthand for common overlay + pretty-error combinations. */ +export type DevTerminalPreset = 'evlog' | 'nitro' | 'both' + +/** How much stack detail evlog prints inside the wide-event error block. */ +export type DevPrettyErrorDetail = 'full' | 'guidance' + +/** Pretty-print options for the `error:` block in dev wide events. */ +export interface DevPrettyErrorConfig { + snippet?: boolean + stackDepth?: number + compact?: boolean + detail?: DevPrettyErrorDetail +} + +/** Resolved pretty-error settings used at runtime. */ +export type ResolvedPrettyError = Required + +/** Resolved dev terminal object (alternative to preset strings). */ +export interface DevTerminalConfigObject { + /** Show Nitro `[request error]` + Youch in the terminal. @default false when pretty in dev. */ + frameworkOverlay?: boolean + prettyError?: DevPrettyErrorConfig +} + +/** User-facing dev terminal config: preset string or explicit object. */ +export type DevTerminalInput = DevTerminalPreset | DevTerminalConfigObject + +/** Resolved dev terminal settings used at runtime. */ +export interface ResolvedDevTerminal { + frameworkOverlay: boolean + prettyError: ResolvedPrettyError +} + +/** Config surface accepted by {@link resolveDevTerminal}. */ +export interface DevTerminalResolveInput { + pretty?: boolean + dev?: DevTerminalInput +} + +const DEV_PRESETS: Record = { + evlog: { frameworkOverlay: false, detail: 'full' }, + nitro: { frameworkOverlay: true, detail: 'guidance' }, + both: { frameworkOverlay: true, detail: 'full' }, +} + +function finalizePrettyError( + partial: DevPrettyErrorConfig, + frameworkOverlay: boolean, + pretty: boolean, + inDev: boolean, +): ResolvedPrettyError { + const compact = partial.compact ?? (inDev && pretty) + const detail = partial.detail ?? (frameworkOverlay ? 'guidance' : 'full') + const stackDepth = detail === 'guidance' + ? 0 + : (partial.stackDepth ?? (compact ? 2 : 3)) + const snippet = partial.snippet ?? (detail === 'full' && pretty && inDev) + + return { snippet, stackDepth, compact, detail } +} + +/** + * Resolve dev terminal settings from `dev` presets or explicit objects. + */ +export function resolveDevTerminal(input: DevTerminalResolveInput = {}): ResolvedDevTerminal { + const pretty = input.pretty ?? isDev() + const inDev = isDev() + + let frameworkOverlay: boolean | undefined + let prettyError: DevPrettyErrorConfig = {} + + if (typeof input.dev === 'string' && input.dev in DEV_PRESETS) { + const { frameworkOverlay: presetOverlay, detail } = DEV_PRESETS[input.dev] + frameworkOverlay = presetOverlay + prettyError = { detail } + } else if (input.dev && typeof input.dev === 'object') { + const { frameworkOverlay: devOverlay, prettyError: devPrettyError } = input.dev + frameworkOverlay = devOverlay + if (devPrettyError) { + prettyError = devPrettyError + } + } + + if (frameworkOverlay === undefined) { + frameworkOverlay = !(pretty && inDev) + } + + return { + frameworkOverlay, + prettyError: finalizePrettyError(prettyError, frameworkOverlay, pretty, inDev), + } +} + +/** + * Whether Nitro's dev Youch overlay should print to the terminal. + * @internal + */ +export function shouldShowFrameworkOverlay(input: DevTerminalResolveInput = {}): boolean { + return resolveDevTerminal(input).frameworkOverlay +} diff --git a/packages/evlog/src/shared/enrich-error-stack.node.ts b/packages/evlog/src/shared/enrich-error-stack.node.ts new file mode 100644 index 00000000..e52fb09f --- /dev/null +++ b/packages/evlog/src/shared/enrich-error-stack.node.ts @@ -0,0 +1,47 @@ +/** Options for {@link enrichErrorStackForDev}. */ +export interface EnrichErrorStackOptions { + /** When false, skip Nitro source-map stack enrichment. @default true in dev when pretty is enabled */ + pretty?: boolean +} + +function shouldEnrichStackFromConfig(): boolean { + try { + const raw = process.env.__EVLOG_CONFIG + if (raw) { + const config = JSON.parse(raw) as { pretty?: boolean } + return config.pretty ?? process.env.NODE_ENV !== 'production' + } + } catch { + // ignore malformed config + } + return process.env.NODE_ENV !== 'production' +} + +/** + * Rewrite `error.stack` with source-mapped frames when the Nitro dev runtime is available. + * Matches Nitro's Youch output (e.g. `server/api/foo.ts:100` instead of `.nuxt/dev/index.mjs`). + */ +export async function enrichErrorStackForDev( + error: Error, + options: EnrichErrorStackOptions = {}, +): Promise { + if (process.env.NODE_ENV === 'production') return + const pretty = options.pretty ?? shouldEnrichStackFromConfig() + if (!pretty) return + + const specifiers = [ + 'nitropack/runtime/internal/error/dev', + 'nitro/runtime/internal/error/dev', + ] + for (const specifier of specifiers) { + try { + const mod = await import(specifier) + if (typeof mod.loadStackTrace === 'function') { + await mod.loadStackTrace(error).catch(() => {}) + return + } + } catch { + // try next runtime + } + } +} diff --git a/packages/evlog/src/shared/nitro-types.ts b/packages/evlog/src/shared/nitro-types.ts new file mode 100644 index 00000000..16e0be65 --- /dev/null +++ b/packages/evlog/src/shared/nitro-types.ts @@ -0,0 +1,11 @@ +/** + * Context passed to Nitro custom error handlers (v2 and v3). + * @internal + */ +export interface NitroErrorHandlerContext { + defaultHandler: ( + error: Error, + event: unknown, + opts?: { silent?: boolean; json?: boolean }, + ) => Promise | unknown +} diff --git a/packages/evlog/src/shared/nitroConfigBridge.ts b/packages/evlog/src/shared/nitroConfigBridge.ts index 63e98e07..962f72ec 100644 --- a/packages/evlog/src/shared/nitroConfigBridge.ts +++ b/packages/evlog/src/shared/nitroConfigBridge.ts @@ -110,6 +110,14 @@ export function readEvlogConfigFromNitroEnv(): EvlogConfig | undefined { } } +/** + * Synchronous evlog config for hot paths (error handler overlay, etc.). + * Matches {@link resolveEvlogConfigForNitroPlugin} steps 1–2 only. + */ +export function readEvlogConfigSync(): EvlogConfig | undefined { + return readEvlogConfigFromInline() ?? readEvlogConfigFromNitroEnv() +} + let cachedNitropackRuntime: NitroRuntimeConfigModule | null | undefined let cachedNitroV3Runtime: NitroRuntimeConfigModule | null | undefined let cachedNitropackInternalConfig: NitroRuntimeConfigModule | null | undefined diff --git a/packages/evlog/src/shared/pretty-error-snippet.node.ts b/packages/evlog/src/shared/pretty-error-snippet.node.ts new file mode 100644 index 00000000..437451f3 --- /dev/null +++ b/packages/evlog/src/shared/pretty-error-snippet.node.ts @@ -0,0 +1,40 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import type { CodeSnippetLine } from './pretty-error' +import { decodeFileUrl } from './pretty-error' + +/** + * Read source lines around a stack frame from disk (Node.js only). + */ +export function readCodeSnippetFromDisk( + file: string, + line: number, + contextLines = 2, +): CodeSnippetLine[] | null { + const decoded = decodeFileUrl(file) + let content: string + try { + content = readFileSync(decoded, 'utf8') + } catch { + try { + content = readFileSync(resolve(process.cwd(), decoded), 'utf8') + } catch { + return null + } + } + + const lines = content.split('\n') + const start = Math.max(0, line - contextLines - 1) + const end = Math.min(lines.length, line + contextLines) + const snippet: CodeSnippetLine[] = [] + + for (let i = start; i < end; i++) { + snippet.push({ + line: i + 1, + content: lines[i] ?? '', + isErrorLine: i + 1 === line, + }) + } + + return snippet.length > 0 ? snippet : null +} diff --git a/packages/evlog/src/shared/pretty-error.ts b/packages/evlog/src/shared/pretty-error.ts new file mode 100644 index 00000000..6ebe0d86 --- /dev/null +++ b/packages/evlog/src/shared/pretty-error.ts @@ -0,0 +1,422 @@ +import { colors, isBrowser, isDev } from '../utils' +import type { ResolvedPrettyError } from './dev-terminal' + +/** @internal Server-only snippet reader registered by Nitro plugin or initLogger. */ +type SnippetReader = (file: string, line: number, contextLines?: number) => CodeSnippetLine[] | null + +let snippetReader: SnippetReader | null = null + +/** + * Register a disk-backed snippet reader (Node.js integrations only). + * @internal + */ +export function registerPrettyErrorSnippetReader(reader: SnippetReader | null): void { + snippetReader = reader +} + +/** Tree-only breathing line (connector without content). */ +export const PRETTY_ERROR_TREE_SPACER = '__EVLOG_TREE_SPACER__' + +function pushTreeSpacer(children: string[]) { + children.push(PRETTY_ERROR_TREE_SPACER) +} + +/** Pretty-print tree node for error sections. */ +export interface PrettyErrorTreeEntry { + key: string + value: string + /** Optional ANSI color for the value (server only). */ + valueColor?: string + children?: string[] +} + +/** Normalized error fields extracted from wide-event `error` context. */ +export interface NormalizedErrorContext { + message: string + name?: string + code?: string + why?: string + fix?: string + link?: string + status?: number + stack?: string + cause?: string +} + +/** Parsed V8 stack frame. */ +export interface StackFrame { + raw: string + file?: string + line?: number + column?: number + fn?: string + /** True for application source (not node_modules / build output). */ + isApp: boolean +} + +/** Options for {@link buildErrorEntries}. */ +export type PrettyErrorOptions = Partial & { + /** Project root for relative paths in snippets. @default process.cwd() */ + cwd?: string +} + +export interface CodeSnippetLine { + line: number + content: string + isErrorLine: boolean +} + +const SKIP_PATH_RE = /(?:^|[/\\])(?:node_modules|\.nuxt|\.output)(?:[/\\]|$)/ +const SKIP_FRAME_PATH_RE = /(?:^|[/\\])(?:packages[/\\]evlog|evlog[/\\](?:dist|src))(?:[/\\]|$)/ +const SKIP_FRAME_FN_RE = /^(?:createError|EvlogError|new EvlogError)$/ + +function isPlainObject(val: unknown): val is Record { + return val !== null && typeof val === 'object' && !Array.isArray(val) +} + +function pickString(obj: Record, key: string): string | undefined { + const val = obj[key] + return typeof val === 'string' && val.length > 0 ? val : undefined +} + +function pickNumber(obj: Record, key: string): number | undefined { + const val = obj[key] + return typeof val === 'number' ? val : undefined +} + +function extractGuidance(data: Record): Pick { + return { + code: pickString(data, 'code'), + why: pickString(data, 'why'), + fix: pickString(data, 'fix'), + link: pickString(data, 'link'), + } +} + +/** + * Extract structured error fields from a wide-event `error` value. + */ +export function normalizeErrorContext(error: unknown): NormalizedErrorContext | null { + if (error === null || error === undefined) return null + + if (typeof error === 'string') { + return { message: error } + } + + if (!isPlainObject(error)) { + return { message: String(error) } + } + + const message = pickString(error, 'message') + ?? pickString(error, 'statusText') + ?? pickString(error, 'statusMessage') + ?? 'Unknown error' + + const result: NormalizedErrorContext = { + message, + name: pickString(error, 'name'), + code: pickString(error, 'code'), + why: pickString(error, 'why'), + fix: pickString(error, 'fix'), + link: pickString(error, 'link'), + status: pickNumber(error, 'status') ?? pickNumber(error, 'statusCode'), + stack: pickString(error, 'stack'), + } + + const { data, cause } = error + if (isPlainObject(data)) { + const guidance = extractGuidance(data) + if (!result.code) result.code = guidance.code + if (!result.why) result.why = guidance.why + if (!result.fix) result.fix = guidance.fix + if (!result.link) result.link = guidance.link + } + + if (cause instanceof Error) { + result.cause = cause.message + } else if (isPlainObject(cause) && pickString(cause, 'message')) { + result.cause = pickString(cause, 'message') + } + + return result +} + +/** Decode a `file://` URL or path for display and snippet lookup. */ +export function decodeFileUrl(file: string): string { + if (file.startsWith('file://')) { + try { + return decodeURIComponent(new URL(file).pathname) + } catch { + return file.slice('file://'.length) + } + } + return file +} + +function isAppPath(file: string): boolean { + const normalized = file.replace(/\\/g, '/') + if (SKIP_PATH_RE.test(normalized)) return false + if (normalized.includes('/node_modules/')) return false + return true +} + +function formatDisplayPath(file: string, cwd: string): string { + const decoded = decodeFileUrl(file).replace(/\\/g, '/') + const cwdNorm = cwd.replace(/\\/g, '/').replace(/\/$/, '') + if (cwdNorm && decoded.startsWith(`${cwdNorm}/`)) { + const rel = decoded.slice(cwdNorm.length + 1) + return rel.startsWith('./') ? rel.slice(2) : rel + } + const serverIdx = decoded.indexOf('/server/') + if (serverIdx >= 0) return decoded.slice(serverIdx + 1) + const srcIdx = decoded.indexOf('/src/') + if (srcIdx >= 0) return decoded.slice(srcIdx + 1) + return decoded +} + +/** + * Parse a V8 stack trace string into frames. + */ +export function parseStackFrames(stack: string | undefined): StackFrame[] { + if (!stack) return [] + + const lines = stack.split('\n') + const frames: StackFrame[] = [] + + for (const line of lines) { + const trimmed = line.trim() + if (!trimmed.startsWith('at ')) continue + + const withFn = trimmed.match(/^at (.+?) \((.+):(\d+):(\d+)\)$/) + if (withFn) { + const [, fn, file, lineStr, colStr] = withFn + frames.push({ + raw: trimmed, + fn, + file, + line: Number(lineStr), + column: Number(colStr), + isApp: isAppPath(file!), + }) + continue + } + + const withoutFn = trimmed.match(/^at (.+):(\d+):(\d+)$/) + if (withoutFn) { + const [, file, lineStr, colStr] = withoutFn + frames.push({ + raw: trimmed, + file, + line: Number(lineStr), + column: Number(colStr), + isApp: isAppPath(file!), + }) + continue + } + + const asyncFn = trimmed.match(/^at async (.+?) \((.+):(\d+):(\d+)\)$/) + if (asyncFn) { + const [, fn, file, lineStr, colStr] = asyncFn + frames.push({ + raw: trimmed, + fn: `async ${fn}`, + file, + line: Number(lineStr), + column: Number(colStr), + isApp: isAppPath(file!), + }) + } + } + + return frames +} + +function isInternalErrorFrame(frame: StackFrame): boolean { + if (frame.fn) { + const fn = frame.fn.replace(/^async /, '') + if (SKIP_FRAME_FN_RE.test(fn)) return true + } + if (!frame.file) return true + const path = decodeFileUrl(frame.file).replace(/\\/g, '/') + if (SKIP_FRAME_PATH_RE.test(path)) return true + if (path.includes('.nuxt/')) return true + return false +} + +/** + * Pick the most useful frame for code snippets (prefer app source over bundles). + */ +export function pickPrimaryFrame(frames: StackFrame[]): StackFrame | undefined { + const appFrames = frames.filter(f => f.isApp && f.file && f.line && !isInternalErrorFrame(f)) + if (appFrames.length === 0) return undefined + + const scored = appFrames.map((frame) => { + const path = decodeFileUrl(frame.file!).replace(/\\/g, '/') + let score = 0 + if (path.includes('/server/')) score += 8 + if (/\.(?:ts|tsx|vue)$/.test(path)) score += 6 + if (path.includes('/src/')) score += 3 + if (path.startsWith('./')) score += 2 + if (/\.(?:js|jsx|mjs)$/.test(path)) score += 1 + if (path.includes('.nuxt/')) score -= 20 + if (path.includes('/packages/evlog/')) score -= 20 + return { frame, score } + }) + + scored.sort((a, b) => b.score - a.score) + return scored[0]?.frame ?? appFrames[0] +} + +/** + * Read source lines around a stack frame when a server snippet reader is registered. + */ +export function readCodeSnippet( + file: string, + line: number, + contextLines = 2, +): CodeSnippetLine[] | null { + if (!isDev() || isBrowser() || !snippetReader) return null + return snippetReader(file, line, contextLines) +} + +function formatSnippetLines(snippet: CodeSnippetLine[]): string[] { + const width = String(snippet[snippet.length - 1]?.line ?? 0).length + return snippet.map(({ line, content, isErrorLine }) => { + const marker = isErrorLine ? `${colors.red}❯${colors.reset}` : `${colors.dim} ${colors.reset}` + const numColor = isErrorLine ? colors.red : colors.gray + const trimmed = content.length > 120 ? `${content.slice(0, 117)}…` : content + return `${marker} ${numColor}${String(line).padStart(width, ' ')}${colors.reset} ${colors.dim}┃${colors.reset} ${colors.dim}${trimmed}${colors.reset}` + }) +} + +function formatFrameLocation(frame: StackFrame, cwd: string): string { + const file = frame.file ? formatDisplayPath(frame.file, cwd) : 'unknown' + const loc = frame.line ? `${file}:${frame.line}` : file + return frame.fn ? `at ${frame.fn} (${loc})` : `at ${loc}` +} + +function formatCollapsedFrame(frame: StackFrame, cwd: string): string { + const file = frame.file ? formatDisplayPath(frame.file, cwd) : 'unknown' + const loc = frame.line ? `${file}:${frame.line}` : file + const prefix = frame.fn?.startsWith('async') ? 'at async ' : 'at ' + const fn = frame.fn?.replace(/^async /, '') ?? loc + if (frame.fn && frame.fn !== loc) { + return `${prefix}${fn} (${loc})` + } + return `${prefix}${loc}` +} + +const GUIDANCE_WRAP_WIDTH = 76 +const GUIDANCE_CONTINUATION = ' ' + +/** Wrap guidance text with hanging indent for long Why/Fix lines. */ +function formatGuidanceLine(label: string, text: string, labelColor: string): string[] { + const prefix = `${labelColor}${label}:${colors.reset} ` + const lines: string[] = [] + let remaining = text.trim() + let first = true + + while (remaining.length > 0) { + const budget = first + ? Math.max(24, GUIDANCE_WRAP_WIDTH - prefix.length) + : Math.max(24, GUIDANCE_WRAP_WIDTH - GUIDANCE_CONTINUATION.length) + if (remaining.length <= budget) { + lines.push(first ? `${prefix}${remaining}` : `${GUIDANCE_CONTINUATION}${remaining}`) + break + } + let split = remaining.lastIndexOf(' ', budget) + if (split <= 0) split = budget + const chunk = remaining.slice(0, split).trimEnd() + lines.push(first ? `${prefix}${chunk}` : `${GUIDANCE_CONTINUATION}${chunk}`) + remaining = remaining.slice(split).trimStart() + first = false + } + + return lines +} + +/** + * Build pretty-print tree entries for a wide-event `error` field. + */ +export function buildErrorEntries( + error: unknown, + options: PrettyErrorOptions = {}, +): PrettyErrorTreeEntry[] { + const normalized = normalizeErrorContext(error) + if (!normalized) return [] + + const cwd = options.cwd ?? (typeof process !== 'undefined' && typeof process.cwd === 'function' ? process.cwd() : '.') + const compact = options.compact ?? isDev() + const detail = options.detail ?? 'full' + const guidanceOnly = detail === 'guidance' + const showFrames = !guidanceOnly && !isBrowser() && (options.snippet ?? isDev()) + const stackDepth = guidanceOnly + ? 0 + : (options.stackDepth ?? (compact ? 2 : 3)) + const snippetContextLines = compact ? 1 : 2 + + const children: string[] = [] + const frames = guidanceOnly ? [] : parseStackFrames(normalized.stack) + const primary = guidanceOnly ? undefined : pickPrimaryFrame(frames) + + if (!guidanceOnly && primary?.file && primary.line) { + pushTreeSpacer(children) + if (showFrames) { + const snippet = readCodeSnippet(primary.file, primary.line, snippetContextLines) + children.push(`${colors.dim} ${formatFrameLocation(primary, cwd)}${colors.reset}`) + if (snippet) { + children.push(...formatSnippetLines(snippet)) + } + } else { + children.push(`${colors.dim} ${formatFrameLocation(primary, cwd)}${colors.reset}`) + } + } + + if (normalized.code) { + children.push(`${colors.dim}Code:${colors.reset} ${normalized.code}`) + } + + const hasGuidance = Boolean(normalized.why || normalized.fix || normalized.link) + if (hasGuidance) { + pushTreeSpacer(children) + } + + if (normalized.why) { + children.push(...formatGuidanceLine('Why', normalized.why, colors.yellow)) + } + if (normalized.fix) { + children.push(...formatGuidanceLine('Fix', normalized.fix, colors.cyan)) + } + if (normalized.link) { + children.push(`${colors.dim}More:${colors.reset} ${normalized.link}`) + } + + if (normalized.cause && normalized.cause !== normalized.message) { + children.push(`${colors.dim}Caused by:${colors.reset} ${normalized.cause}`) + } + + const hiddenCount = guidanceOnly ? 0 : frames.filter(f => !f.isApp || isInternalErrorFrame(f)).length + const tailFrames = stackDepth > 0 + ? frames.filter(f => f !== primary && !isInternalErrorFrame(f)).slice(0, stackDepth) + : [] + + if (!guidanceOnly && (hiddenCount > 0 || tailFrames.length > 0)) { + pushTreeSpacer(children) + if (hiddenCount > 0) { + children.push(`${colors.gray}stack (${hiddenCount} frame${hiddenCount === 1 ? '' : 's'} hidden in node_modules)${colors.reset}`) + } else { + children.push(`${colors.gray}stack${colors.reset}`) + } + for (const frame of tailFrames) { + children.push(`${colors.gray} ${formatCollapsedFrame(frame, cwd)}${colors.reset}`) + } + } + + return [ + { + key: 'error', + value: `${colors.red}${colors.bold}${normalized.message}${colors.reset}`, + children: children.length > 0 ? children : undefined, + }, + ] +} diff --git a/packages/evlog/src/types.ts b/packages/evlog/src/types.ts index 2180a4f8..4251cd31 100644 --- a/packages/evlog/src/types.ts +++ b/packages/evlog/src/types.ts @@ -1,4 +1,5 @@ import type { NitroRuntimeHooks } from 'nitropack/types' +import type { DevTerminalInput } from './shared/dev-terminal' declare module 'nitropack/types' { interface NitroRuntimeHooks { @@ -291,6 +292,11 @@ export interface LoggerConfig { env?: Partial /** Enable pretty printing (auto-detected: true in dev, false in prod) */ pretty?: boolean + /** + * Dev terminal output: preset or explicit overlay + pretty-error settings. + * @default 'evlog' when pretty in development + */ + dev?: DevTerminalInput /** Sampling configuration for filtering logs */ sampling?: SamplingConfig /** @@ -909,6 +915,8 @@ export interface H3EventContext { _evlogStartTime?: number /** Internal: flag to prevent double emission on errors */ _evlogEmitted?: boolean + /** Internal: error hook is mid-emit; blocks concurrent afterResponse/response emission */ + _evlogEmitting?: boolean /** Internal: whether the route matched shouldLog filtering (emit-time guard) */ _evlogShouldEmit?: boolean [key: string]: unknown diff --git a/packages/evlog/test/core/dev-terminal.test.ts b/packages/evlog/test/core/dev-terminal.test.ts new file mode 100644 index 00000000..b2418d5d --- /dev/null +++ b/packages/evlog/test/core/dev-terminal.test.ts @@ -0,0 +1,121 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + resolveDevTerminal, + shouldShowFrameworkOverlay, +} from '../../src/shared/dev-terminal' +import { buildErrorEntries } from '../../src/shared/pretty-error' + +const SAMPLE_STACK = `Error: Payment processing failed + at Object.handler (file:///Users/dev/project/server/api/test.get.ts:100:0) + at async file:///Users/dev/project/node_modules/h3/dist/index.mjs:2017:19` + +describe('resolveDevTerminal', () => { + const originalEnv = process.env.NODE_ENV + + afterEach(() => { + process.env.NODE_ENV = originalEnv + }) + + it('applies evlog preset — no overlay, full pretty error', () => { + process.env.NODE_ENV = 'development' + expect(resolveDevTerminal({ pretty: true, dev: 'evlog' })).toEqual({ + frameworkOverlay: false, + prettyError: { + snippet: true, + stackDepth: 2, + compact: true, + detail: 'full', + }, + }) + }) + + it('applies nitro preset — overlay on, guidance-only pretty error', () => { + process.env.NODE_ENV = 'development' + expect(resolveDevTerminal({ pretty: true, dev: 'nitro' })).toEqual({ + frameworkOverlay: true, + prettyError: { + snippet: false, + stackDepth: 0, + compact: true, + detail: 'guidance', + }, + }) + }) + + it('applies both preset — overlay on, full pretty error', () => { + process.env.NODE_ENV = 'development' + expect(resolveDevTerminal({ pretty: true, dev: 'both' })).toEqual({ + frameworkOverlay: true, + prettyError: { + snippet: true, + stackDepth: 2, + compact: true, + detail: 'full', + }, + }) + }) + + it('ignores unknown preset strings and falls back to defaults', () => { + process.env.NODE_ENV = 'development' + expect(() => resolveDevTerminal({ pretty: true, dev: 'unknown' as 'evlog' })).not.toThrow() + expect(resolveDevTerminal({ pretty: true, dev: 'unknown' as 'evlog' })).toEqual( + resolveDevTerminal({ pretty: true }), + ) + }) + + it('accepts explicit dev object overrides', () => { + process.env.NODE_ENV = 'development' + expect(resolveDevTerminal({ + pretty: true, + dev: { + frameworkOverlay: true, + prettyError: { + snippet: false, + stackDepth: 1, + compact: false, + detail: 'guidance', + }, + }, + })).toEqual({ + frameworkOverlay: true, + prettyError: { + snippet: false, + stackDepth: 0, + compact: false, + detail: 'guidance', + }, + }) + }) +}) + +describe('shouldShowFrameworkOverlay', () => { + const originalEnv = process.env.NODE_ENV + + afterEach(() => { + process.env.NODE_ENV = originalEnv + }) + + it('defaults to no overlay in pretty dev and allows opt-in', () => { + process.env.NODE_ENV = 'development' + expect(shouldShowFrameworkOverlay({ pretty: true })).toBe(false) + expect(shouldShowFrameworkOverlay({ pretty: true, dev: 'nitro' })).toBe(true) + }) +}) + +describe('buildErrorEntries guidance detail', () => { + it('omits location and stack when detail is guidance', () => { + const entries = buildErrorEntries({ + message: 'Payment failed', + stack: SAMPLE_STACK, + why: 'Card declined', + fix: 'Try another card', + link: 'https://docs.example.com/payments', + }, { detail: 'guidance' }) + + const children = entries[0]?.children ?? [] + expect(children.some(line => line.includes('test.get.ts'))).toBe(false) + expect(children.some(line => line.includes('hidden in node_modules'))).toBe(false) + expect(children.some(line => line.includes('Why:'))).toBe(true) + expect(children.some(line => line.includes('Fix:'))).toBe(true) + }) +}) diff --git a/packages/evlog/test/core/pretty-error.test.ts b/packages/evlog/test/core/pretty-error.test.ts new file mode 100644 index 00000000..4746ce29 --- /dev/null +++ b/packages/evlog/test/core/pretty-error.test.ts @@ -0,0 +1,259 @@ +import { mkdtempSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createError } from '../../src/error' +import { createRequestLogger, initLogger } from '../../src/logger' +import { + buildErrorEntries, + normalizeErrorContext, + parseStackFrames, + pickPrimaryFrame, + readCodeSnippet, + registerPrettyErrorSnippetReader, +} from '../../src/shared/pretty-error' +import { readCodeSnippetFromDisk } from '../../src/shared/pretty-error-snippet.node' +import { enrichErrorStackForDev } from '../../src/shared/enrich-error-stack.node' +import { prependNitroErrorHandler } from '../../src/nitro' + +const SAMPLE_STACK = `Error: Payment processing failed + at Object.handler (file:///Users/dev/project/server/api/test.get.ts:100:0) + at async file:///Users/dev/project/node_modules/h3/dist/index.mjs:2017:19 + at async Object.callAsync (file:///Users/dev/project/node_modules/unctx/dist/index.mjs:72:16)` + +describe('normalizeErrorContext', () => { + it('extracts guidance fields from EvlogError-shaped objects', () => { + const err = createError({ + code: 'PAYMENT_DECLINED', + message: 'Payment failed', + status: 402, + why: 'Card declined', + fix: 'Try another card', + link: 'https://docs.example.com/payments', + }) + + expect(normalizeErrorContext({ + name: err.name, + message: err.message, + stack: err.stack, + code: err.code, + why: err.why, + fix: err.fix, + link: err.link, + status: err.status, + })).toMatchObject({ + message: 'Payment failed', + code: 'PAYMENT_DECLINED', + why: 'Card declined', + fix: 'Try another card', + link: 'https://docs.example.com/payments', + status: 402, + }) + }) + + it('reads guidance from nested data', () => { + expect(normalizeErrorContext({ + message: 'Failed', + data: { + code: 'X', + why: 'because', + fix: 'do Y', + }, + })).toMatchObject({ + code: 'X', + why: 'because', + fix: 'do Y', + }) + }) +}) + +describe('parseStackFrames', () => { + it('marks node_modules frames as non-app and picks the app frame for snippets', () => { + const frames = parseStackFrames(SAMPLE_STACK) + expect(frames).toHaveLength(3) + expect(frames[0]?.isApp).toBe(true) + expect(frames[0]?.file).toContain('server/api/test.get.ts') + expect(frames[1]?.isApp).toBe(false) + expect(frames[2]?.isApp).toBe(false) + + const primary = pickPrimaryFrame(frames) + expect(primary?.file).toContain('server/api/test.get.ts') + expect(primary?.line).toBe(100) + }) + + it('skips evlog internal frames from createError throws', () => { + const stack = `Error: Payment processing failed + at new EvlogError (file:///Users/dev/project/packages/evlog/src/error.ts:166:10) + at createError (file:///Users/dev/project/packages/evlog/src/error.ts:166:10) + at Object.handler (file:///Users/dev/project/server/api/test/structured-error.get.ts:100:9) + at async file:///Users/dev/project/node_modules/h3/dist/index.mjs:2017:19` + const primary = pickPrimaryFrame(parseStackFrames(stack)) + expect(primary?.file).toContain('structured-error.get.ts') + expect(primary?.line).toBe(100) + }) + + it('does not skip user handler files named error.ts', () => { + const stack = `Error: boom + at Object.handler (file:///Users/dev/project/server/api/error.ts:42:5) + at async file:///Users/dev/project/node_modules/h3/dist/index.mjs:2017:19` + const primary = pickPrimaryFrame(parseStackFrames(stack)) + expect(primary?.file).toContain('server/api/error.ts') + expect(primary?.line).toBe(42) + }) + + it('skips bundled .nuxt/dev frames', () => { + const stack = `Payment processing failed +at createError (.nuxt/dev/index.mjs:3007:10) +at Object.handler (.nuxt/dev/index.mjs:8108:9) +at async file:///Users/dev/project/node_modules/h3/dist/index.mjs:2017:19` + const primary = pickPrimaryFrame(parseStackFrames(stack)) + expect(primary).toBeUndefined() + }) +}) + +describe('readCodeSnippet', () => { + beforeEach(() => { + registerPrettyErrorSnippetReader(readCodeSnippetFromDisk) + }) + + afterEach(() => { + registerPrettyErrorSnippetReader(null) + }) + + it('returns lines around the error line', () => { + const dir = mkdtempSync(join(tmpdir(), 'evlog-pretty-error-')) + const file = join(dir, 'handler.ts') + writeFileSync(file, [ + 'line 1', + 'line 2', + 'throw new Error(\'boom\')', + 'line 4', + ].join('\n'), 'utf8') + + const snippet = readCodeSnippet(file, 3, 1) + expect(snippet).toEqual([ + { line: 2, content: 'line 2', isErrorLine: false }, + { line: 3, content: 'throw new Error(\'boom\')', isErrorLine: true }, + { line: 4, content: 'line 4', isErrorLine: false }, + ]) + }) +}) + +describe('buildErrorEntries', () => { + it('renders message, guidance, location, and hidden stack summary', () => { + const entries = buildErrorEntries({ + message: 'Payment failed', + stack: SAMPLE_STACK, + why: 'Card declined', + fix: 'Try another card', + link: 'https://docs.example.com/payments', + }, { snippet: false, stackDepth: 2, compact: false }) + + expect(entries).toHaveLength(1) + const entry = entries[0]! + expect(entry.key).toBe('error') + expect(entry.value).toContain('Payment failed') + expect(entry.value).not.toContain('stack=') + const children = entry.children ?? [] + expect(children.some(line => line.includes('Why:'))).toBe(true) + expect(children.some(line => line.includes('Fix:'))).toBe(true) + expect(children.some(line => line.includes('More:'))).toBe(true) + expect(children.some(line => line.includes('hidden in node_modules'))).toBe(true) + }) + + it('omits redundant caused-by when it matches the message', () => { + const entries = buildErrorEntries({ + message: 'Payment failed', + stack: SAMPLE_STACK, + cause: 'Payment failed', + }, { snippet: false }) + + const children = entries[0]?.children ?? [] + expect(children.some(line => line.includes('Caused by:'))).toBe(false) + }) + + it('filters createError frames from the collapsed stack', () => { + const stack = `Payment processing failed +at createError (/Users/dev/project/packages/evlog/dist/error.mjs:128:8) +at Object.handler (server/api/test/structured-error.get.ts:100:0) +at async file:///Users/dev/project/node_modules/h3/dist/index.mjs:2017:19` + const entries = buildErrorEntries({ + message: 'Payment failed', + stack, + why: 'Card declined', + }, { snippet: false, stackDepth: 5, compact: false }) + + const children = entries[0]?.children ?? [] + expect(children.some(line => line.includes('createError'))).toBe(false) + expect(children.some(line => line.includes('structured-error.get.ts'))).toBe(true) + }) + + it('wraps long Fix guidance with hanging indent', () => { + const longFix = `${'Please use a different payment method. '.repeat(6)}Contact your bank.` + const entries = buildErrorEntries({ + message: 'Payment failed', + stack: SAMPLE_STACK, + fix: longFix, + }, { snippet: false, stackDepth: 0, compact: false }) + + const children = entries[0]?.children ?? [] + const fixIndex = children.findIndex(line => line.includes('Fix:')) + expect(fixIndex).toBeGreaterThanOrEqual(0) + expect(children[fixIndex + 1]).toMatch(/^\s{5}\S/) + }) +}) + +describe('prependNitroErrorHandler', () => { + const handler = '/evlog/nitro/errorHandler' + + it('prepends without duplicating an existing handler list', () => { + expect(prependNitroErrorHandler(undefined, handler)).toBe(handler) + expect(prependNitroErrorHandler('/nuxt/error', handler)).toEqual([handler, '/nuxt/error']) + + const existing = ['/nuxt/error', '/nitro/dev'] + expect(prependNitroErrorHandler(existing, handler)).toEqual([handler, ...existing]) + expect(prependNitroErrorHandler([handler, ...existing], handler)).toEqual([handler, ...existing]) + expect(prependNitroErrorHandler(['framework', handler], handler)).toEqual([handler, 'framework']) + }) +}) + +describe('enrichErrorStackForDev', () => { + const originalEnv = process.env.NODE_ENV + const originalConfig = process.env.__EVLOG_CONFIG + + afterEach(() => { + process.env.NODE_ENV = originalEnv + if (originalConfig === undefined) delete process.env.__EVLOG_CONFIG + else process.env.__EVLOG_CONFIG = originalConfig + }) + + it('skips Nitro stack enrichment outside pretty dev', async () => { + process.env.NODE_ENV = 'production' + await expect(enrichErrorStackForDev(new Error('boom'))).resolves.toBeUndefined() + + process.env.NODE_ENV = 'development' + process.env.__EVLOG_CONFIG = JSON.stringify({ pretty: false }) + await expect(enrichErrorStackForDev(new Error('boom'), { pretty: false })).resolves.toBeUndefined() + }) +}) + +describe('pretty error logger integration', () => { + beforeEach(() => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.spyOn(console, 'log').mockImplementation(() => {}) + initLogger({ pretty: true, dev: { prettyError: { snippet: false } } }) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('uses console.log for error-level wide events (avoids per-line ERROR badges)', () => { + const logger = createRequestLogger({ method: 'GET', path: '/fail', requestId: 'r1' }) + logger.error(new Error('boom')) + logger.emit({ status: 500 }) + + expect(console.log).toHaveBeenCalled() + expect(console.error).not.toHaveBeenCalled() + }) +}) diff --git a/packages/evlog/test/nitro-v3/errorHandler.test.ts b/packages/evlog/test/nitro-v3/errorHandler.test.ts new file mode 100644 index 00000000..3c153c1b --- /dev/null +++ b/packages/evlog/test/nitro-v3/errorHandler.test.ts @@ -0,0 +1,211 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +declare global { + // eslint-disable-next-line @typescript-eslint/naming-convention + var __EVLOG_CONFIG__: unknown +} + +vi.mock('nitro', () => ({ + defineErrorHandler: (handler: T) => handler, +})) + +import { createError } from '../../src/error' +import errorHandler from '../../src/nitro-v3/errorHandler' +import { resetNitroDevOverlayCache, shouldSuppressNitroDevOverlay } from '../../src/nitro' + +const defaultHandlerMock = vi.fn().mockResolvedValue(undefined) + +const mockEvent = { + req: { url: 'http://localhost/api/test' }, + _handled: false, +} as { req: { url: string }; _handled: boolean } + +function invokeErrorHandler(error: Error, event = mockEvent) { + return errorHandler(error, event, { defaultHandler: defaultHandlerMock }) +} + +async function readJson(response: Response) { + return JSON.parse(await response.text()) +} + +describe('nitro-v3 errorHandler', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.stubEnv('NODE_ENV', 'development') + vi.stubEnv('__EVLOG_CONFIG', JSON.stringify({ pretty: true })) + delete globalThis.__EVLOG_CONFIG__ + resetNitroDevOverlayCache() + mockEvent._handled = false + }) + + it('marks the h3 event handled so Nitro dev handler does not run', async () => { + await invokeErrorHandler(new Error('boom')) + expect(mockEvent._handled).toBe(true) + }) + + it('clears unhandled flag in dev pretty mode', async () => { + vi.stubEnv('__EVLOG_CONFIG', JSON.stringify({ pretty: true })) + const error = Object.assign(new Error('boom'), { unhandled: true, fatal: true }) + await invokeErrorHandler(error) + expect(error.unhandled).toBe(false) + expect(error.fatal).toBe(false) + }) + + it('calls defaultHandler when dev preset is nitro', async () => { + vi.stubEnv('__EVLOG_CONFIG', JSON.stringify({ pretty: true, dev: 'nitro' })) + resetNitroDevOverlayCache() + const defaultHandler = vi.fn().mockResolvedValue(undefined) + const error = new Error('boom') + + await errorHandler(error, mockEvent, { defaultHandler }) + + expect(defaultHandler).toHaveBeenCalledWith(error, mockEvent, { silent: false }) + }) + + it('reads inlined __EVLOG_CONFIG__ for overlay suppression', () => { + vi.stubEnv('__EVLOG_CONFIG', undefined) + globalThis.__EVLOG_CONFIG__ = { pretty: true, dev: 'nitro' } + resetNitroDevOverlayCache() + expect(shouldSuppressNitroDevOverlay()).toBe(false) + }) + + describe('EvlogError handling', () => { + it('serializes EvlogError with all data fields', async () => { + const evlogError = Object.assign(new Error('Payment failed'), { + name: 'EvlogError', + status: 402, + statusText: 'Payment failed', + statusCode: 402, + statusMessage: 'Payment failed', + data: { + why: 'Card declined', + fix: 'Try another card', + link: 'https://docs.example.com', + }, + }) + + const response = await invokeErrorHandler(evlogError) + + expect(response.status).toBe(402) + expect(response.headers.get('Content-Type')).toBe('application/json') + + const body = await readJson(response) + expect(body.statusCode).toBe(402) + expect(body.message).toBe('Payment failed') + expect(body.url).toBe('/api/test') + expect(body.error).toBe(true) + expect(body.data).toEqual({ + why: 'Card declined', + fix: 'Try another card', + link: 'https://docs.example.com', + }) + }) + + it('derives HTTP status from evlogError when in error.cause', async () => { + const evlogError = Object.assign(new Error('Not found'), { + name: 'EvlogError', + status: 404, + statusText: 'Not found', + statusCode: 404, + statusMessage: 'Not found', + data: { why: 'Resource does not exist' }, + }) + + const wrapperError = Object.assign(new Error('Wrapper error'), { cause: evlogError }) + + const response = await invokeErrorHandler(wrapperError) + + expect(response.status).toBe(404) + + const body = await readJson(response) + expect(body.statusCode).toBe(404) + expect(body.data).toEqual({ why: 'Resource does not exist' }) + }) + + it('defaults to 500 when no status on evlogError', async () => { + const evlogError = Object.assign(new Error('Unknown error'), { name: 'EvlogError' }) + + const response = await invokeErrorHandler(evlogError) + + expect(response.status).toBe(500) + }) + + it('does not expose internal context on EvlogError responses', async () => { + const err = createError({ + message: 'Not allowed', + status: 403, + why: 'Insufficient role', + internal: { userId: 'u-internal', rawPolicy: 'deny:admin' }, + }) + + const response = await invokeErrorHandler(err) + + const body = await readJson(response) + expect(body.internal).toBeUndefined() + expect(JSON.stringify(body)).not.toContain('u-internal') + expect(JSON.stringify(body)).not.toContain('rawPolicy') + expect(body.data).toEqual({ + why: 'Insufficient role', + fix: undefined, + link: undefined, + }) + }) + }) + + describe('non-EvlogError handling', () => { + it('uses Nitro-compatible format for standard errors', async () => { + const error = Object.assign(new Error('Something went wrong'), { + statusCode: 400, + }) + + const response = await invokeErrorHandler(error) + + expect(response.status).toBe(400) + + const body = await readJson(response) + expect(body.statusCode).toBe(400) + expect(body.statusMessage).toBe('Something went wrong') + expect(body.message).toBe('Something went wrong') + expect(body.url).toBe('/api/test') + expect(body.error).toBe(true) + expect(body.data).toBeUndefined() + }) + + it('defaults to 500 for errors without status', async () => { + const response = await invokeErrorHandler(new Error('Generic error')) + expect(response.status).toBe(500) + }) + + it('uses "Internal Server Error" when no message', async () => { + const response = await invokeErrorHandler(new Error('')) + + const body = await readJson(response) + expect(body.message).toBe('Internal Server Error') + expect(body.statusMessage).toBe('Internal Server Error') + }) + + it('sanitizes 5xx error messages in production', async () => { + vi.stubEnv('NODE_ENV', 'production') + + const error = Object.assign(new Error('Database connection failed: password invalid'), { + statusCode: 500, + }) + + const body = await readJson(await invokeErrorHandler(error)) + expect(body.message).toBe('Internal Server Error') + expect(body.statusMessage).toBe('Internal Server Error') + }) + + it('preserves 4xx error messages in production', async () => { + vi.stubEnv('NODE_ENV', 'production') + + const error = Object.assign(new Error('Invalid email format'), { + statusCode: 400, + }) + + const body = await readJson(await invokeErrorHandler(error)) + expect(body.message).toBe('Invalid email format') + expect(body.statusMessage).toBe('Invalid email format') + }) + }) +}) diff --git a/packages/evlog/test/nitro/errorHandler.test.ts b/packages/evlog/test/nitro/errorHandler.test.ts index 5e659397..af0eb433 100644 --- a/packages/evlog/test/nitro/errorHandler.test.ts +++ b/packages/evlog/test/nitro/errorHandler.test.ts @@ -2,6 +2,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { H3Event } from 'h3' import { defined } from '../helpers/defined' +declare global { + // eslint-disable-next-line @typescript-eslint/naming-convention + var __EVLOG_CONFIG__: unknown +} + const mockSetResponseStatus = vi.fn() const mockSetResponseHeader = vi.fn() const mockSend = vi.fn() @@ -20,21 +25,57 @@ vi.mock('nitropack/runtime', () => ({ import { createError } from '../../src/error' import errorHandler from '../../src/nitro/errorHandler' +import { resetNitroDevOverlayCache, shouldSuppressNitroDevOverlay } from '../../src/nitro' + +const mockEvent = { node: { req: {}, res: {} }, _handled: false } as H3Event & { _handled: boolean } -const mockEvent = { node: { req: {}, res: {} } } as H3Event +const defaultHandlerMock = vi.fn().mockResolvedValue(undefined) function invokeErrorHandler(error: Error) { - errorHandler(error, mockEvent, { defaultHandler: vi.fn() }) + return errorHandler(error, mockEvent, { defaultHandler: defaultHandlerMock }) } describe('errorHandler', () => { beforeEach(() => { vi.clearAllMocks() vi.stubEnv('NODE_ENV', 'development') + vi.stubEnv('__EVLOG_CONFIG', JSON.stringify({ pretty: true })) + delete globalThis.__EVLOG_CONFIG__ + resetNitroDevOverlayCache() + mockEvent._handled = false + }) + + it('marks the h3 event handled so Nitro dev handler does not run', async () => { + await invokeErrorHandler(new Error('boom')) + expect(mockEvent._handled).toBe(true) + }) + + it('clears unhandled flag in dev pretty mode', async () => { + vi.stubEnv('__EVLOG_CONFIG', JSON.stringify({ pretty: true })) + const error = Object.assign(new Error('boom'), { unhandled: true, fatal: true }) + await invokeErrorHandler(error) + expect(error.unhandled).toBe(false) + expect(error.fatal).toBe(false) + }) + + it('calls defaultHandler when dev preset is nitro', async () => { + vi.stubEnv('__EVLOG_CONFIG', JSON.stringify({ pretty: true, dev: 'nitro' })) + resetNitroDevOverlayCache() + const defaultHandler = vi.fn().mockResolvedValue(undefined) + const error = new Error('boom') + await errorHandler(error, mockEvent, { defaultHandler }) + expect(defaultHandler).toHaveBeenCalledWith(error, mockEvent, { silent: false }) + }) + + it('reads inlined __EVLOG_CONFIG__ for overlay suppression', () => { + vi.stubEnv('__EVLOG_CONFIG', undefined) + globalThis.__EVLOG_CONFIG__ = { pretty: true, dev: 'nitro' } + resetNitroDevOverlayCache() + expect(shouldSuppressNitroDevOverlay()).toBe(false) }) describe('EvlogError handling', () => { - it('serializes EvlogError with all data fields', () => { + it('serializes EvlogError with all data fields', async () => { const evlogError = Object.assign(new Error('Payment failed'), { name: 'EvlogError', status: 402, @@ -48,7 +89,7 @@ describe('errorHandler', () => { }, }) - invokeErrorHandler(evlogError) + await invokeErrorHandler(evlogError) expect(mockSetResponseStatus).toHaveBeenCalledWith(mockEvent, 402) expect(mockSetResponseHeader).toHaveBeenCalledWith(mockEvent, 'Content-Type', 'application/json') @@ -65,7 +106,7 @@ describe('errorHandler', () => { }) }) - it('derives HTTP status from evlogError when in error.cause', () => { + it('derives HTTP status from evlogError when in error.cause', async () => { const evlogError = Object.assign(new Error('Not found'), { name: 'EvlogError', status: 404, @@ -77,7 +118,7 @@ describe('errorHandler', () => { const wrapperError = Object.assign(new Error('Wrapper error'), { cause: evlogError }) - invokeErrorHandler(wrapperError) + await invokeErrorHandler(wrapperError) expect(mockSetResponseStatus).toHaveBeenCalledWith(mockEvent, 404) @@ -86,15 +127,15 @@ describe('errorHandler', () => { expect(sentBody.data).toEqual({ why: 'Resource does not exist' }) }) - it('defaults to 500 when no status on evlogError', () => { + it('defaults to 500 when no status on evlogError', async () => { const evlogError = Object.assign(new Error('Unknown error'), { name: 'EvlogError' }) - invokeErrorHandler(evlogError) + await invokeErrorHandler(evlogError) expect(mockSetResponseStatus).toHaveBeenCalledWith(mockEvent, 500) }) - it('does not expose internal context on EvlogError responses', () => { + it('does not expose internal context on EvlogError responses', async () => { const err = createError({ message: 'Not allowed', status: 403, @@ -102,7 +143,7 @@ describe('errorHandler', () => { internal: { userId: 'u-internal', rawPolicy: 'deny:admin' }, }) - invokeErrorHandler(err) + await invokeErrorHandler(err) const sentBody = JSON.parse(defined(mockSend.mock.calls[0]?.[1], 'response body')) expect(sentBody.internal).toBeUndefined() @@ -117,12 +158,12 @@ describe('errorHandler', () => { }) describe('non-EvlogError handling', () => { - it('uses Nitro-compatible format for standard errors', () => { + it('uses Nitro-compatible format for standard errors', async () => { const error = Object.assign(new Error('Something went wrong'), { statusCode: 400, }) - invokeErrorHandler(error) + await invokeErrorHandler(error) expect(mockSetResponseStatus).toHaveBeenCalledWith(mockEvent, 400) @@ -135,46 +176,46 @@ describe('errorHandler', () => { expect(sentBody.data).toBeUndefined() }) - it('defaults to 500 for errors without status', () => { + it('defaults to 500 for errors without status', async () => { const error = new Error('Generic error') - invokeErrorHandler(error) + await invokeErrorHandler(error) expect(mockSetResponseStatus).toHaveBeenCalledWith(mockEvent, 500) }) - it('uses "Internal Server Error" when no message', () => { + it('uses "Internal Server Error" when no message', async () => { const error = new Error('') - invokeErrorHandler(error) + await invokeErrorHandler(error) const sentBody = JSON.parse(defined(mockSend.mock.calls[0]?.[1], 'response body')) expect(sentBody.message).toBe('Internal Server Error') expect(sentBody.statusMessage).toBe('Internal Server Error') }) - it('sanitizes 5xx error messages in production', () => { + it('sanitizes 5xx error messages in production', async () => { vi.stubEnv('NODE_ENV', 'production') const error = Object.assign(new Error('Database connection failed: password invalid'), { statusCode: 500, }) - invokeErrorHandler(error) + await invokeErrorHandler(error) const sentBody = JSON.parse(defined(mockSend.mock.calls[0]?.[1], 'response body')) expect(sentBody.message).toBe('Internal Server Error') expect(sentBody.statusMessage).toBe('Internal Server Error') }) - it('preserves 4xx error messages in production', () => { + it('preserves 4xx error messages in production', async () => { vi.stubEnv('NODE_ENV', 'production') const error = Object.assign(new Error('Invalid email format'), { statusCode: 400, }) - invokeErrorHandler(error) + await invokeErrorHandler(error) const sentBody = JSON.parse(defined(mockSend.mock.calls[0]?.[1], 'response body')) expect(sentBody.message).toBe('Invalid email format') diff --git a/packages/evlog/test/nitro/plugin-enrichment.test.ts b/packages/evlog/test/nitro/plugin-enrichment.test.ts index 91d148e9..2915dffc 100644 --- a/packages/evlog/test/nitro/plugin-enrichment.test.ts +++ b/packages/evlog/test/nitro/plugin-enrichment.test.ts @@ -1,52 +1,27 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { NitroApp } from 'nitropack/types' import { getHeaders } from 'h3' import type { DrainContext, EnrichContext, ServerEvent, WideEvent } from '../../src/types' import { defined } from '../helpers/defined' -import { filterSafeHeaders } from '../../src/utils' -import { createRequestLogger, initLogger } from '../../src/logger' +import { callEnrichAndDrain } from '../../src/nitro/enrich-drain' +import { initLogger } from '../../src/logger' vi.mock('h3', () => ({ getHeaders: vi.fn(), })) -function getSafeHeaders(allHeaders: Partial>): Record { - return filterSafeHeaders(allHeaders) +function asNitroApp(hooks: { callHook: NitroApp['hooks']['callHook'] }): NitroApp { + return { hooks } as NitroApp } - describe('nitro plugin - enrichment pipeline (T7)', () => { - async function callEnrichAndDrain( - nitroApp: { - hooks: { - callHook: (name: string, ctx: EnrichContext | DrainContext) => Promise - } - }, - emittedEvent: WideEvent | null, - event: ServerEvent, - ): Promise { - if (!emittedEvent) return - - const allHeaders = getHeaders(event as Parameters[0]) - const hookContext = { - request: { method: event.method, path: event.path, requestId: event.context.requestId as string | undefined }, - headers: getSafeHeaders(allHeaders), - response: { status: 200 }, - } - - try { - await nitroApp.hooks.callHook('evlog:enrich', { event: emittedEvent, ...hookContext }) - } catch (err) { - console.error('[evlog] enrich failed:', err) - } - - nitroApp.hooks.callHook('evlog:drain', { - event: emittedEvent, - request: hookContext.request, - headers: hookContext.headers, - }).catch((err) => { - console.error('[evlog] drain failed:', err) - }) - } + beforeEach(() => { + initLogger({ pretty: false }) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) it('calls enrich then drain in sequence', async () => { const callOrder: string[] = [] @@ -73,7 +48,7 @@ describe('nitro plugin - enrichment pipeline (T7)', () => { environment: 'test', } - await callEnrichAndDrain({ hooks: mockHooks }, emittedEvent, mockEvent) + await callEnrichAndDrain(asNitroApp(mockHooks), emittedEvent, mockEvent) expect(callOrder).toEqual(['evlog:enrich', 'evlog:drain']) }) @@ -89,7 +64,7 @@ describe('nitro plugin - enrichment pipeline (T7)', () => { context: {}, } - await callEnrichAndDrain({ hooks: mockHooks }, null, mockEvent) + await callEnrichAndDrain(asNitroApp(mockHooks), null, mockEvent) expect(mockHooks.callHook).not.toHaveBeenCalled() }) @@ -125,7 +100,7 @@ describe('nitro plugin - enrichment pipeline (T7)', () => { environment: 'test', } - await callEnrichAndDrain({ hooks: mockHooks }, emittedEvent, mockEvent) + await callEnrichAndDrain(asNitroApp(mockHooks), emittedEvent, mockEvent) expect(drainCalled).toBe(true) expect(consoleSpy).toHaveBeenCalledWith('[evlog] enrich failed:', expect.any(Error)) @@ -160,7 +135,7 @@ describe('nitro plugin - enrichment pipeline (T7)', () => { } // Should not throw - await callEnrichAndDrain({ hooks: mockHooks }, emittedEvent, mockEvent) + await callEnrichAndDrain(asNitroApp(mockHooks), emittedEvent, mockEvent) await vi.waitFor(() => expect(consoleSpy).toHaveBeenCalledWith('[evlog] drain failed:', expect.any(Error))) consoleSpy.mockRestore() @@ -197,7 +172,7 @@ describe('nitro plugin - enrichment pipeline (T7)', () => { environment: 'test', } - await callEnrichAndDrain({ hooks: mockHooks }, emittedEvent, mockEvent) + await callEnrichAndDrain(asNitroApp(mockHooks), emittedEvent, mockEvent) const drained = defined(drainEvent, 'drainEvent') expect(drained.enriched).toBe(true) @@ -238,9 +213,84 @@ describe('nitro plugin - enrichment pipeline (T7)', () => { environment: 'test', } - await callEnrichAndDrain({ hooks: mockHooks }, emittedEvent, mockEvent) + await callEnrichAndDrain(asNitroApp(mockHooks), emittedEvent, mockEvent) expect(enrichHeaders).toEqual(mockHeaders) expect(drainHeaders).toEqual(mockHeaders) }) + + it('deferDrain does not register drain on event.waitUntil', async () => { + const mockWaitUntil = vi.fn() + let resolveDrain!: () => void + const slowDrain = new Promise((resolve) => { + resolveDrain = resolve + }) + + vi.mocked(getHeaders).mockReturnValue({}) + + const mockHooks = { + callHook: vi.fn().mockImplementation((hookName: string) => { + if (hookName === 'evlog:drain') return slowDrain + return Promise.resolve() + }), + } + + const mockEvent: ServerEvent = { + method: 'GET', + path: '/api/fail', + context: { + requestId: 'req-wu', + waitUntil: mockWaitUntil, + }, + } + + const emittedEvent: WideEvent = { + timestamp: new Date().toISOString(), + level: 'error', + service: 'test', + environment: 'test', + } + + await callEnrichAndDrain(asNitroApp(mockHooks), emittedEvent, mockEvent, { deferDrain: true }) + + expect(mockWaitUntil).not.toHaveBeenCalled() + resolveDrain() + await slowDrain + }) + + it('deferDrain returns before a slow drain hook completes', async () => { + let resolveDrain!: () => void + const slowDrain = new Promise((resolve) => { + resolveDrain = resolve + }) + + vi.mocked(getHeaders).mockReturnValue({}) + + const mockHooks = { + callHook: vi.fn().mockImplementation((hookName: string) => { + if (hookName === 'evlog:drain') return slowDrain + return Promise.resolve() + }), + } + + const mockEvent: ServerEvent = { + method: 'GET', + path: '/api/fail', + context: { requestId: 'req-slow' }, + } + + const emittedEvent: WideEvent = { + timestamp: new Date().toISOString(), + level: 'error', + service: 'test', + environment: 'test', + } + + const started = Date.now() + await callEnrichAndDrain(asNitroApp(mockHooks), emittedEvent, mockEvent, { deferDrain: true }) + expect(Date.now() - started).toBeLessThan(100) + + resolveDrain() + await slowDrain + }) }) diff --git a/packages/evlog/test/nitro/plugin.test.ts b/packages/evlog/test/nitro/plugin.test.ts index 74c5a6b1..28eb07e0 100644 --- a/packages/evlog/test/nitro/plugin.test.ts +++ b/packages/evlog/test/nitro/plugin.test.ts @@ -846,7 +846,7 @@ describe('nitro plugin - middleware compatibility (#210)', () => { * Replicates the plugin's `afterResponse` hook logic. */ function simulateAfterResponseHook(event: ServerEvent): { emitted: boolean } { - if (event.context._evlogEmitted || !event.context._evlogShouldEmit) { + if (event.context._evlogEmitted || event.context._evlogEmitting || !event.context._evlogShouldEmit) { return { emitted: false } } const { log } = event.context @@ -863,11 +863,16 @@ describe('nitro plugin - middleware compatibility (#210)', () => { if (!event.context._evlogShouldEmit) return { emitted: false } const { log } = event.context if (!log) return { emitted: false } - log.error(error) - log.set({ status: 500 }) - event.context._evlogEmitted = true - const result = log.emit() - return { emitted: result !== null } + event.context._evlogEmitting = true + try { + log.error(error) + log.set({ status: 500 }) + const result = log.emit() + if (result) event.context._evlogEmitted = true + return { emitted: result !== null } + } finally { + delete event.context._evlogEmitting + } } beforeEach(() => { diff --git a/packages/evlog/test/shared/nitroConfigBridge.test.ts b/packages/evlog/test/shared/nitroConfigBridge.test.ts index a2e1e02f..4175ccd3 100644 --- a/packages/evlog/test/shared/nitroConfigBridge.test.ts +++ b/packages/evlog/test/shared/nitroConfigBridge.test.ts @@ -127,6 +127,12 @@ describe('nitroConfigBridge — active runtime', () => { expect(importSpy).not.toHaveBeenCalled() }) + it('reads inlined config via readEvlogConfigSync', async () => { + globalThis.__EVLOG_CONFIG__ = { env: { service: 'svc-inline' } } + const { readEvlogConfigSync } = await import('../../src/shared/nitroConfigBridge') + expect(readEvlogConfigSync()).toEqual({ env: { service: 'svc-inline' } }) + }) + it('ignores __EVLOG_CONFIG__ when it is not an object literal', async () => { globalThis.__EVLOG_CONFIG__ = 'not-an-object' const { bridge, importSpy } = await loadBridgeWithMocks()