From 647e003081f998a57f61d90a882e2e925f0606d9 Mon Sep 17 00:00:00 2001 From: Hugo Richard Date: Sat, 4 Apr 2026 16:38:10 +0100 Subject: [PATCH] feat(evlog): add internal-only context to createError --- .changeset/evl-140-create-error-internal.md | 5 ++ AGENTS.md | 5 +- .../1.getting-started/3.quick-start.md | 1 + apps/docs/content/2.logging/0.overview.md | 2 +- .../content/2.logging/3.structured-errors.md | 25 ++++++++ .../skills/review-logging-patterns/SKILL.md | 11 +++- .../references/code-review.md | 1 + .../references/structured-errors.md | 19 +++++- apps/playground/app/config/tests.config.ts | 60 ++++++++++++++++++- .../server/api/test/error-internal.get.ts | 23 +++++++ packages/evlog/README.md | 3 + packages/evlog/src/error.ts | 20 +++++++ packages/evlog/src/logger.ts | 2 +- packages/evlog/src/types.ts | 5 ++ packages/evlog/test/error.test.ts | 50 ++++++++++++++++ packages/evlog/test/errorHandler.test.ts | 25 +++++++- packages/evlog/test/logger.test.ts | 20 +++++++ 17 files changed, 268 insertions(+), 9 deletions(-) create mode 100644 .changeset/evl-140-create-error-internal.md create mode 100644 apps/playground/server/api/test/error-internal.get.ts diff --git a/.changeset/evl-140-create-error-internal.md b/.changeset/evl-140-create-error-internal.md new file mode 100644 index 00000000..520e1c5b --- /dev/null +++ b/.changeset/evl-140-create-error-internal.md @@ -0,0 +1,5 @@ +--- +"evlog": minor +--- + +Add `internal` to `createError` / `ErrorOptions`: backend-only context stored on `EvlogError`, included in wide events via `log.error()`, never serialized in HTTP responses or `toJSON()` ([EVL-140](https://linear.app/evlog/issue/EVL-140)). diff --git a/AGENTS.md b/AGENTS.md index 858d0a41..5d1107e7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -893,9 +893,10 @@ When creating errors with `createError()`: | `why` | No | Technical reason (for debugging) | | `fix` | No | Actionable solution (for developers/users) | | `link` | No | Documentation URL for more info | -| `cause` | No | Original error (if wrapping) +| `cause` | No | Original error (if wrapping) | +| `internal` | No | Backend-only `Record` — wide events / `log.error()` only; never HTTP, `toJSON()`, or client `parseError()` | -**Best practice**: At minimum, provide `message` and `status`. Add `why` and `fix` for errors that users can act on. Add `link` for documented error codes. +**Best practice**: At minimum, provide `message` and `status`. Add `why` and `fix` for errors that users can act on. Add `link` for documented error codes. Use `internal` for non-user-facing diagnostics (correlation IDs, processor codes) that must not reach browsers. ### Code Style diff --git a/apps/docs/content/1.getting-started/3.quick-start.md b/apps/docs/content/1.getting-started/3.quick-start.md index 45c92fb5..23f5bd68 100644 --- a/apps/docs/content/1.getting-started/3.quick-start.md +++ b/apps/docs/content/1.getting-started/3.quick-start.md @@ -220,6 +220,7 @@ throw createError({ | `fix` | No | Actionable solution | | `link` | No | Documentation URL for more info | | `cause` | No | Original error (if wrapping) | +| `internal` | No | Backend-only fields for logs and wide events — never included in HTTP JSON or `parseError()` | ### Frontend Integration diff --git a/apps/docs/content/2.logging/0.overview.md b/apps/docs/content/2.logging/0.overview.md index 2d9591c8..1ab94e50 100644 --- a/apps/docs/content/2.logging/0.overview.md +++ b/apps/docs/content/2.logging/0.overview.md @@ -128,7 +128,7 @@ All three modes share the same foundation: - **Pretty output** in development, **JSON** in production (default, no configuration needed) - **Drain pipeline** to send events to Axiom, Sentry, PostHog, and more -- **Structured errors** with `why`, `fix`, and `link` fields +- **Structured errors** with `why`, `fix`, and `link`, plus optional backend-only **`internal`** for logs - **Sampling** to control log volume in production - **Zero dependencies**, ~5 kB gzip diff --git a/apps/docs/content/2.logging/3.structured-errors.md b/apps/docs/content/2.logging/3.structured-errors.md index 83743de3..731a40bc 100644 --- a/apps/docs/content/2.logging/3.structured-errors.md +++ b/apps/docs/content/2.logging/3.structured-errors.md @@ -66,6 +66,31 @@ throw createError({ | `fix` | No | Actionable solution | | `link` | No | Documentation URL | | `cause` | No | Original error (for error chaining) | +| `internal` | No | Backend-only context (see below) | + +## Backend-only context (`internal`) + +Use `internal` when you need extra fields for logs, drains, or support tools, but **must not** expose them in API responses or to `parseError()` on the client. + +```typescript +throw createError({ + message: 'Payment could not be completed', + status: 402, + why: 'Your card was declined', + fix: 'Try another payment method', + internal: { + correlationId: 'pay_8x2k', + processorCode: 'insufficient_funds', + rawIssuerResponse: '…', // never sent to the client + }, +}) +``` + +- **HTTP responses** (Nuxt/Nitro error handler, Next.js, SvelteKit, etc.) and **`toJSON()`** omit `internal`. +- **`parseError()`** does not surface `internal` for UI; the thrown error may still carry it server-side on `raw` when debugging. +- **Wide events**: when the framework records the error (e.g. `log.error(err)` or automatic capture on thrown `EvlogError`), the emitted payload includes `error.internal`. + +In debuggers, the payload may appear under a symbol key; in code, always use **`error.internal`**. ## Basic Usage diff --git a/apps/docs/skills/review-logging-patterns/SKILL.md b/apps/docs/skills/review-logging-patterns/SKILL.md index 3133f960..16221af6 100644 --- a/apps/docs/skills/review-logging-patterns/SKILL.md +++ b/apps/docs/skills/review-logging-patterns/SKILL.md @@ -872,9 +872,17 @@ throw createError({ link: 'https://docs.example.com/payments/declined', cause: originalError, }) + +// Backend-only context (wide events / drains — never HTTP body or parseError()) +throw createError({ + message: 'Not allowed', + status: 403, + why: 'Insufficient permissions', + internal: { correlationId: 'req_abc', resourceId: 'proj_123' }, +}) ``` -Frontend — extract all fields with `parseError()`: +Frontend — extract user-facing fields with `parseError()` (`internal` is never returned to clients): ```typescript import { parseError } from 'evlog' @@ -897,6 +905,7 @@ See [references/structured-errors.md](references/structured-errors.md) for commo | No logging in request handlers | Add `useLogger(event)` / `useLogger()` / `createRequestLogger()` | | Flat log data `{ uid, n, t }` | Grouped objects: `{ user: {...}, cart: {...} }` | | Logging sensitive data `log.set({ user: body })` | Explicit fields: `{ user: { id: body.id, plan: body.plan } }` | +| Putting support-only IDs in `why` / `message` | Use `createError({ ..., internal: { ... } })` for non-user-facing diagnostics | See [references/code-review.md](references/code-review.md) for the full checklist. diff --git a/apps/docs/skills/review-logging-patterns/references/code-review.md b/apps/docs/skills/review-logging-patterns/references/code-review.md index f948f7c7..ce1f6c3a 100644 --- a/apps/docs/skills/review-logging-patterns/references/code-review.md +++ b/apps/docs/skills/review-logging-patterns/references/code-review.md @@ -262,6 +262,7 @@ export default defineEventHandler(async (event) => { - [ ] Fixable errors include `fix` with actionable steps - [ ] Documented errors include `link` to docs - [ ] Wrapped errors preserve `cause` +- [ ] Support-only or sensitive diagnostics use `internal`, not `message` / `why` / `fix` ### Frontend Error Handling diff --git a/apps/docs/skills/review-logging-patterns/references/structured-errors.md b/apps/docs/skills/review-logging-patterns/references/structured-errors.md index edac33d9..0cfb1a25 100644 --- a/apps/docs/skills/review-logging-patterns/references/structured-errors.md +++ b/apps/docs/skills/review-logging-patterns/references/structured-errors.md @@ -33,9 +33,19 @@ throw createError({ fix: 'Try a different payment method', // How to fix it link: 'https://docs.example.com/...', // More information cause: originalError, // Original error + internal: { // Optional: backend / logs only + correlationId: 'pay_abc', + processorCode: 'card_declined', + }, }) ``` +### `internal` (backend-only) + +- Use `internal` for IDs, gateway codes, or diagnostics that **must not** appear in HTTP error bodies or in client-side `parseError()` results. +- Access in server code via **`error.internal`**. Values are omitted from **`toJSON()`** and from framework serializers; they are included on wide events under **`error.internal`** when the error is captured with **`log.error()`** (or equivalent automatic capture). +- Stored with a non-enumerable symbol so `JSON.stringify(error)` does not leak `internal`; devtools may show it as `[Symbol(evlog.error.internal)]`. + ### Console Output (Development) ``` @@ -344,12 +354,17 @@ The wide event will include: "message": "Payment failed", "why": "Card declined by issuer", "fix": "Try a different payment method", - "link": "https://docs.stripe.com/declines/codes" + "link": "https://docs.stripe.com/declines/codes", + "internal": { + "stripeRequestId": "req_123" + } }, "step": "payment" } ``` +If you use `createError({ ..., internal: { ... } })` without calling `log.error(error)` yourself, framework integrations that attach thrown errors to the wide event still merge **`internal`** into **`error.internal`** on emit. + ## Best Practices ### Do @@ -359,11 +374,13 @@ The wide event will include: - Add `link` to documentation for complex errors - Preserve `cause` when wrapping errors - Be specific about what failed and why +- Put operator-only or sensitive diagnostics in `internal`, not in `why`/`fix`/`message` ### Don't - Use generic messages like "Error" or "Failed" - Leak sensitive data (passwords, tokens, PII) +- Expect `internal` in HTTP JSON or in `parseError()` — it is for server logs and drains only - Make `why` and `message` identical - Suggest fixes that aren't actually possible - Create errors without any context diff --git a/apps/playground/app/config/tests.config.ts b/apps/playground/app/config/tests.config.ts index 364eafb6..b39b0bb4 100644 --- a/apps/playground/app/config/tests.config.ts +++ b/apps/playground/app/config/tests.config.ts @@ -227,7 +227,7 @@ export const testConfig = { label: 'Structured Errors', icon: 'i-lucide-shield-alert', title: 'Structured Error → Toast', - description: 'This demonstrates how a structured createError() from the backend can be displayed as a toast in the frontend with all context (message, why, fix, link).', + description: 'This demonstrates how a structured createError() from the backend can be displayed as a toast in the frontend with all context (message, why, fix, link). Use “Error with internal” to verify internal-only fields: they appear in the terminal wide event under error.internal, never in the HTTP body.', layout: 'cards', tests: [ { @@ -246,7 +246,14 @@ export const testConfig = { description: error.why, color: 'error', actions: error.link - ? [{ label: 'Learn more', onClick: () => window.open(error.link, '_blank') }] + ? [ + { + label: 'Learn more', + onClick: () => { + window.open(error.link, '_blank') + }, + } + ] : undefined, }) if (error.fix) { @@ -259,6 +266,55 @@ export const testConfig = { color: 'red', }, }, + { + id: 'structured-error-internal', + label: 'Error with internal', + description: 'createError includes internal: { supportRef, gatewayCode, … } for logs only. Open the devtools console after click, then check the terminal wide event for error.internal.', + color: 'warning', + onClick: async () => { + try { + await $fetch('/api/test/error-internal') + } catch (err) { + const { data } = err as { data?: Record } + const serialized = JSON.stringify(data ?? {}) + const leaked + = serialized.includes('playground-support-ref-EVL140') + || serialized.includes('proc_declined_simulated') + if (leaked) { + console.error( + '[playground] internal context leaked into HTTP body — this should not happen', + data, + ) + } else { + console.info( + '[playground] HTTP body has no internal secrets — OK. In the terminal, find this request and check error.internal (supportRef, gatewayCode).', + data, + ) + } + const error = parseError(err) + const toast = useToast() + toast.add({ + title: error.message, + description: `${error.why ?? ''} See browser console for the HTTP body check.`, + color: 'error', + actions: error.link + ? [ + { + label: 'Learn more', + onClick: () => { + window.open(error.link, '_blank') + }, + } + ] + : undefined, + }) + } + }, + badge: { + label: 'GET /api/test/error-internal', + color: 'warning', + }, + }, ], } as TestSection, { diff --git a/apps/playground/server/api/test/error-internal.get.ts b/apps/playground/server/api/test/error-internal.get.ts new file mode 100644 index 00000000..ed2218df --- /dev/null +++ b/apps/playground/server/api/test/error-internal.get.ts @@ -0,0 +1,23 @@ +import { createError, useLogger } from 'evlog' + +/** + * Demo for createError({ internal }) — internal context is for drains / terminal logs only, + * never included in the JSON error response. + */ +export default defineEventHandler((event) => { + const log = useLogger(event) + log.set({ demo: 'error-internal-playground' }) + + throw createError({ + message: 'Demo: action not allowed', + status: 403, + why: 'This is a playground-only structured error (safe to show users).', + fix: 'Use another playground button or ignore this message.', + link: 'https://github.com/HugoRCD/evlog', + internal: { + supportRef: 'playground-support-ref-EVL140', + gatewayCode: 'proc_declined_simulated', + attemptedResource: '/api/test/error-internal', + }, + }) +}) diff --git a/packages/evlog/README.md b/packages/evlog/README.md index 288f07b7..16c3a001 100644 --- a/packages/evlog/README.md +++ b/packages/evlog/README.md @@ -1081,9 +1081,12 @@ createError({ fix?: string // How to fix it link?: string // Documentation URL cause?: Error // Original error + internal?: Record // Backend-only; never in HTTP body or toJSON() }) ``` +**`internal`** — Optional context for support, auditing, or debugging (IDs, gateway codes, raw diagnostics). It is stored on `EvlogError` and exposed as `error.internal` in server code. It is **not** included in JSON error responses, `toJSON()`, or `parseError()` results. When the error is passed to `log.error()` (or thrown in integrations that record errors on the wide event), `internal` is copied into the emitted event under `error.internal`. + ### `parseError(error)` Parse a caught error into a flat structure with all evlog fields. Auto-imported in Nuxt. diff --git a/packages/evlog/src/error.ts b/packages/evlog/src/error.ts index a8a69251..458550e7 100644 --- a/packages/evlog/src/error.ts +++ b/packages/evlog/src/error.ts @@ -1,6 +1,9 @@ import type { ErrorOptions } from './types' import { colors, isServer } from './utils' +/** Non-enumerable storage so `JSON.stringify(error)` never exposes internal context */ +const evlogErrorInternalKey = Symbol.for('evlog.error.internal') + /** * Structured error with context for better debugging * @@ -24,6 +27,14 @@ export class EvlogError extends Error { readonly fix?: string readonly link?: string + /** + * Backend-only context from `createError({ internal: … })`. + * Omitted from {@link EvlogError#toJSON} and all framework HTTP serializers. + */ + get internal(): Record | undefined { + return (this as EvlogError & { [evlogErrorInternalKey]?: Record })[evlogErrorInternalKey] + } + constructor(options: ErrorOptions | string) { const opts = typeof options === 'string' ? { message: options } : options @@ -35,6 +46,15 @@ export class EvlogError extends Error { this.fix = opts.fix this.link = opts.link + if (opts.internal !== undefined) { + Object.defineProperty(this, evlogErrorInternalKey, { + value: opts.internal, + enumerable: false, + writable: false, + configurable: true, + }) + } + // Maintain proper stack trace in V8 if (Error.captureStackTrace) { Error.captureStackTrace(this, EvlogError) diff --git a/packages/evlog/src/logger.ts b/packages/evlog/src/logger.ts index 8d0cc8e5..970e20df 100644 --- a/packages/evlog/src/logger.ts +++ b/packages/evlog/src/logger.ts @@ -402,7 +402,7 @@ export function createLogger>(initial stack: err.stack, } const errRecord = err as unknown as Record - for (const k of ['status', 'statusText', 'statusCode', 'statusMessage', 'data', 'cause'] as const) { + for (const k of ['status', 'statusText', 'statusCode', 'statusMessage', 'data', 'cause', 'internal'] as const) { if (k in err) errorObj[k] = errRecord[k] } diff --git a/packages/evlog/src/types.ts b/packages/evlog/src/types.ts index f7b2e153..bd3f1875 100644 --- a/packages/evlog/src/types.ts +++ b/packages/evlog/src/types.ts @@ -491,6 +491,11 @@ export interface ErrorOptions { link?: string /** The original error that caused this */ cause?: Error + /** + * Backend-only diagnostic context (auditing, support, debugging). + * Never included in HTTP responses or `EvlogError#toJSON`; included in wide events when the error is passed to `log.error()`. + */ + internal?: Record } /** diff --git a/packages/evlog/test/error.test.ts b/packages/evlog/test/error.test.ts index 9fd6c24c..c95e241d 100644 --- a/packages/evlog/test/error.test.ts +++ b/packages/evlog/test/error.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { createError, createEvlogError, EvlogError } from '../src/error' +import { serializeEvlogErrorResponse } from '../src/nitro' import { parseError } from '../src/runtime/utils/parseError' describe('EvlogError', () => { @@ -162,6 +163,55 @@ describe('EvlogError', () => { const json = error.toJSON() expect(json.cause).toBeUndefined() }) + + it('never includes internal (client-safe JSON)', () => { + const error = new EvlogError({ + message: 'Public message', + status: 403, + why: 'User facing why', + internal: { correlationId: 'corr-1', riskyDetail: 'issuer raw response' }, + }) + + const json = error.toJSON() + expect(json.internal).toBeUndefined() + expect((JSON.stringify(error) as string).includes('corr-1')).toBe(false) + expect((JSON.stringify(error) as string).includes('riskyDetail')).toBe(false) + }) + }) + + describe('internal (backend-only)', () => { + it('exposes internal via getter when provided', () => { + const error = createError({ + message: 'x', + internal: { orderId: 'o1', supportRef: 's2' }, + }) + expect(error.internal).toEqual({ orderId: 'o1', supportRef: 's2' }) + }) + + it('omits internal from toString()', () => { + const error = createError({ + message: 'Payment failed', + why: 'Card declined', + internal: { gatewayCode: '05', raw: 'do not print' }, + }) + const str = error.toString() + expect(str).toContain('Payment failed') + expect(str).toContain('Card declined') + expect(str).not.toContain('gatewayCode') + expect(str).not.toContain('do not print') + }) + + it('omits internal from serializeEvlogErrorResponse', () => { + const error = createError({ + message: 'Bad', + status: 400, + why: 'Reason', + internal: { secret: 'nope' }, + }) + const body = serializeEvlogErrorResponse(error, '/api/x') + expect(body.internal).toBeUndefined() + expect(body.data).toEqual({ why: 'Reason', fix: undefined, link: undefined }) + }) }) }) diff --git a/packages/evlog/test/errorHandler.test.ts b/packages/evlog/test/errorHandler.test.ts index fcb47eda..d05bd04f 100644 --- a/packages/evlog/test/errorHandler.test.ts +++ b/packages/evlog/test/errorHandler.test.ts @@ -18,8 +18,10 @@ vi.mock('nitropack/runtime', () => ({ defineNitroErrorHandler: (handler: T) => handler, })) -// eslint-disable-next-line import/first -- Must import after vi.mock +/* eslint-disable import/first -- Must import after vi.mock */ +import { createError } from '../src/error' import errorHandler from '../src/nitro/errorHandler' +/* eslint-enable import/first */ describe('errorHandler', () => { const mockEvent = { node: { req: {}, res: {} } } @@ -99,6 +101,27 @@ describe('errorHandler', () => { expect(mockSetResponseStatus).toHaveBeenCalledWith(mockEvent, 500) }) + + it('does not expose internal context on EvlogError responses', () => { + const err = createError({ + message: 'Not allowed', + status: 403, + why: 'Insufficient role', + internal: { userId: 'u-internal', rawPolicy: 'deny:admin' }, + }) + + errorHandler(err, mockEvent) + + const sentBody = JSON.parse(mockSend.mock.calls[0][1]) + expect(sentBody.internal).toBeUndefined() + expect(JSON.stringify(sentBody)).not.toContain('u-internal') + expect(JSON.stringify(sentBody)).not.toContain('rawPolicy') + expect(sentBody.data).toEqual({ + why: 'Insufficient role', + fix: undefined, + link: undefined, + }) + }) }) describe('non-EvlogError handling', () => { diff --git a/packages/evlog/test/logger.test.ts b/packages/evlog/test/logger.test.ts index aefdb458..08b73948 100644 --- a/packages/evlog/test/logger.test.ts +++ b/packages/evlog/test/logger.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createError } from '../src/error' import { createLogger, createRequestLogger, getEnvironment, initLogger, isEnabled, log } from '../src/logger' describe('initLogger', () => { @@ -314,6 +315,25 @@ describe('createRequestLogger', () => { }) }) + it('captures EvlogError internal on wide-event error object', () => { + const logger = createRequestLogger({}) + const error = createError({ + message: 'Forbidden', + status: 403, + internal: { tenantId: 't-9', attemptedResource: 'proj/secret' }, + }) + + logger.error(error) + + const context = logger.getContext() + expect(context.error).toMatchObject({ + name: 'EvlogError', + message: 'Forbidden', + status: 403, + internal: { tenantId: 't-9', attemptedResource: 'proj/secret' }, + }) + }) + it('captures status/statusText from new-style H3 errors (Nuxt v4.3+)', () => { const logger = createRequestLogger({}) const error = Object.assign(new Error('Not Found'), {