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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/evl-140-create-error-internal.md
Original file line number Diff line number Diff line change
@@ -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)).
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>` — 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

Expand Down
1 change: 1 addition & 0 deletions apps/docs/content/1.getting-started/3.quick-start.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion apps/docs/content/2.logging/0.overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
25 changes: 25 additions & 0 deletions apps/docs/content/2.logging/3.structured-errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 10 additions & 1 deletion apps/docs/skills/review-logging-patterns/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

```
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
60 changes: 58 additions & 2 deletions apps/playground/app/config/tests.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
{
Expand All @@ -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) {
Expand All @@ -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<string, unknown> }
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,
{
Expand Down
23 changes: 23 additions & 0 deletions apps/playground/server/api/test/error-internal.get.ts
Original file line number Diff line number Diff line change
@@ -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',
},
})
})
3 changes: 3 additions & 0 deletions packages/evlog/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1081,9 +1081,12 @@ createError({
fix?: string // How to fix it
link?: string // Documentation URL
cause?: Error // Original error
internal?: Record<string, unknown> // 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.
Expand Down
20 changes: 20 additions & 0 deletions packages/evlog/src/error.ts
Original file line number Diff line number Diff line change
@@ -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
*
Expand All @@ -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<string, unknown> | undefined {
return (this as EvlogError & { [evlogErrorInternalKey]?: Record<string, unknown> })[evlogErrorInternalKey]
}

constructor(options: ErrorOptions | string) {
const opts = typeof options === 'string' ? { message: options } : options

Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion packages/evlog/src/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -402,7 +402,7 @@ export function createLogger<T extends object = Record<string, unknown>>(initial
stack: err.stack,
}
const errRecord = err as unknown as Record<string, unknown>
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]
}

Expand Down
5 changes: 5 additions & 0 deletions packages/evlog/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>
}

/**
Expand Down
50 changes: 50 additions & 0 deletions packages/evlog/test/error.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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 })
})
})
})

Expand Down
Loading
Loading