Skip to content
Open
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
41 changes: 41 additions & 0 deletions .changeset/error-audit-catalog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
---
'evlog': minor
---

Add typed error and audit catalogs as a thin layer over `createError` and `defineAuditAction`. Three new primitives, zero runtime registration, zero init step. The whole feature is opt-in: existing `createError({ code, ... })` and `defineAuditAction(...)` call sites keep working unchanged, with no migration required.

```ts
import { defineErrorCatalog, defineAuditCatalog } from 'evlog'

export const billingErrors = defineErrorCatalog('billing', {
PAYMENT_DECLINED: { status: 402, message: 'Card declined', why: '...', fix: '...', link: '...' },
INSUFFICIENT_FUNDS: {
status: 402,
message: ({ available, required }: { available: number, required: number }) =>
`Insufficient funds: $${available}/$${required}`,
},
})

export const billingAudit = defineAuditCatalog('billing', {
INVOICE_REFUND: { target: 'invoice' },
INVOICE_CREATE: { target: 'invoice' },
})

throw billingErrors.PAYMENT_DECLINED({ cause: stripeErr })
throw billingErrors.INSUFFICIENT_FUNDS({ available: 5, required: 100 })
log.audit(billingAudit.INVOICE_REFUND({ actor, target: { id: 'inv_889' } }))
```

New API on the main `evlog` entrypoint:

- `defineError(code, options)` — single-error factory bound to a stable code. Accepts every existing `EvlogError` field plus a `tags` array and an `internal` defaults object. `message` can be either a string or a typed function whose params become required at the call site.
- `defineErrorCatalog(prefix, map)` — bundle a record of entries under a common prefix. The wire `code` for each entry is `${prefix}.${KEY}` (UPPER_SNAKE_CASE keys preserved). Catalog metadata (`_codes`, `_prefix`) exposed for introspection.
- `defineAuditCatalog(prefix, map)` — symmetric primitive for audit actions. Each entry produces a thin wrapper around `defineAuditAction` with the prefix and target type pre-applied. Exposes `_actions` and `_prefix`.

Type-level upgrade (opt-in, zero runtime cost):

- `RegisteredErrorCatalogs` and `RegisteredAuditCatalogs` interfaces (empty by default, augmentable via `declare module 'evlog'`).
- New `ErrorCode` and `AuditAction` types derived from registered catalogs.
- `ErrorOptions.code` and `ParsedError.code` now typed as `ErrorCode | (string & {})` — autocomplete on registered codes everywhere (`createError`, `parseError`, custom helpers) without breaking ad-hoc string usage.

Catalog factories return regular `EvlogError` instances and `AuditInput` objects respectively, so they integrate transparently with every existing evlog primitive (HTTP serializers, `parseError`, wide event capture, audit pipeline, drains). Catalogs are pure data — package them as npm libraries (one prefix per package), and the typing flows transitively to consumers via the published `.d.ts`. No global init, no proxy, no string-based dispatch helper.
1 change: 0 additions & 1 deletion apps/docs/app/components/content/DrainFanOut.vue
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,6 @@ function statusLabel(adapter: Adapter, s: AdapterState) {
<span class="relative inline-flex size-1.5 rounded-full bg-primary" />
</span>
</div>
<!-- eslint-disable vue/multiline-html-element-content-newline -->
<pre class="font-mono text-[10px] leading-relaxed text-muted"><code>{
level: <span class="text-emerald-400">"info"</span>,
method: <span class="text-emerald-400">"POST"</span>,
Expand Down
1 change: 0 additions & 1 deletion apps/docs/app/components/content/LifecycleFlow.vue
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,6 @@ const isDone = computed(() => phase.value >= stages.length - 1)
<UIcon name="i-lucide-package-check" class="size-3 text-primary" />
<span class="text-[10px] uppercase tracking-widest text-dimmed">wide event</span>
</div>
<!-- eslint-disable vue/multiline-html-element-content-newline -->
<pre class="text-[10px] sm:text-[11px] leading-relaxed text-muted overflow-x-auto"><code>{
<span class="text-sky-400">level</span>: <span class="text-emerald-400">"info"</span>,
<span class="text-sky-400">method</span>: <span class="text-emerald-400">"POST"</span>,
Expand Down
2 changes: 0 additions & 2 deletions apps/docs/app/components/content/StructuredErrorContext.vue
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,6 @@ const reachedRendered = computed(() => phase.value === 'rendered')
</div>

<div class="border border-muted bg-elevated/30 px-3.5 py-3 mb-3 min-h-[180px]">
<!-- eslint-disable vue/multiline-html-element-content-newline -->
<pre class="font-mono text-[10px] sm:text-[11px] leading-relaxed text-muted overflow-x-auto"><code><span class="text-violet-400">throw</span> <span class="text-amber-400">new</span> <span class="text-sky-400">Error</span>(<span class="text-emerald-400">"Payment failed"</span>)
</code></pre>
<div class="mt-3 pt-3 border-t border-default/30 transition-opacity duration-500" :class="reachedCaught ? 'opacity-100' : 'opacity-0'">
Expand Down Expand Up @@ -214,7 +213,6 @@ err.fix <span class="text-dimmed">→</span> <span class="text-rose-400/80">
class="border bg-elevated/30 px-3.5 py-3 mb-3 transition-colors duration-500 min-h-[180px]"
:class="reachedRendered ? 'border-primary/25' : 'border-muted'"
>
<!-- eslint-disable vue/multiline-html-element-content-newline -->
<pre class="font-mono text-[10px] sm:text-[11px] leading-relaxed text-muted overflow-x-auto"><code><span class="text-violet-400">throw</span> <span class="text-sky-400">createError</span>({
<template v-for="(field, i) in fields" :key="`f-${field.key}`"><span
class="transition-all duration-500"
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/app/components/content/WideEventCollapse.vue
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,7 @@ const collapsedCount = computed(() => lineCollapsed.value.filter(Boolean).length
class="border bg-elevated/30 px-3.5 py-3 transition-colors duration-500 min-h-[180px]"
:class="phase === 'wide' ? 'border-primary/25' : 'border-muted'"
>
<!-- eslint-disable vue/multiline-html-element-content-newline, vue/html-self-closing -->
<!-- eslint-disable vue/html-self-closing -->
<pre class="font-mono text-[10px] sm:text-[11px] leading-relaxed text-muted overflow-x-auto"><code>{
<span class="text-sky-400">requestId</span>: <span class="text-emerald-400">"req_8a2c"</span>,
<span class="text-sky-400">status</span>: <span class="text-pink-400">200</span>,
Expand Down
1 change: 0 additions & 1 deletion apps/docs/app/components/features/FeatureAiSdk.vue
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,6 @@ function setView(view: 'without' | 'with') {
</div>

<div class="px-5 pt-4 pb-3 font-mono text-xs sm:text-sm leading-relaxed overflow-x-auto border-b border-muted/50">
<!-- eslint-disable vue/multiline-html-element-content-newline -->
<pre><code><span class="text-violet-400">const</span> ai = <span class="text-amber-400">createAILogger</span>(log, {
<span class="text-sky-400">cost</span>: { <span class="text-emerald-400">'claude-sonnet-4.6'</span>: { <span class="text-sky-400">input</span>: <span class="text-pink-400">3</span>, <span class="text-sky-400">output</span>: <span class="text-pink-400">15</span> } },
})
Expand Down
1 change: 0 additions & 1 deletion apps/docs/app/components/features/FeatureAudit.vue
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,6 @@ onMounted(() => {
</div>

<div class="px-5 pt-4 pb-3 font-mono text-xs sm:text-sm leading-relaxed overflow-x-auto border-b border-muted/50">
<!-- eslint-disable vue/multiline-html-element-content-newline -->
<pre><code>log.<span class="text-amber-400">audit</span>({
<span class="text-sky-400">action</span>: <span class="text-emerald-400">'invoice.refund'</span>,
<span class="text-sky-400">actor</span>: { <span class="text-sky-400">type</span>: <span class="text-emerald-400">'user'</span>, <span class="text-sky-400">id</span>: user.id },
Expand Down
1 change: 0 additions & 1 deletion apps/docs/app/components/features/FeatureClientDrain.vue
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,6 @@ const pills = [
</div>

<div class="px-5 pt-5 pb-4 font-mono text-xs sm:text-sm leading-relaxed overflow-x-auto">
<!-- eslint-disable vue/multiline-html-element-content-newline -->
<pre><code><span class="text-violet-400">import</span> { createHttpLogDrain } <span class="text-violet-400">from</span> <span class="text-emerald-400">'evlog/http'</span>

<span class="text-violet-400">const</span> drain = <span class="text-amber-400">createHttpLogDrain</span>({
Expand Down
1 change: 0 additions & 1 deletion apps/docs/app/components/features/FeatureSampling.vue
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,6 @@ function getLevelColor(level: string): string {
<span class="ml-3 font-mono text-xs text-dimmed">evlog.config.ts</span>
</div>
<div class="p-5 font-mono text-xs sm:text-sm leading-relaxed overflow-x-auto">
<!-- eslint-disable vue/multiline-html-element-content-newline -->
<pre><code><span class="text-amber-400">initLogger</span>({
<span class="text-sky-400">sampling</span>: {
<span class="text-dimmed">// Head: per-level rates</span>
Expand Down
1 change: 0 additions & 1 deletion apps/docs/app/components/features/FeatureSimpleApi.vue
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,6 @@ function setOutput(type: 'success' | 'error') {
<span class="ml-3 font-mono text-xs text-dimmed">checkout.post.ts</span>
</div>
<div class="p-5 font-mono text-xs sm:text-sm leading-relaxed overflow-x-auto">
<!-- eslint-disable vue/multiline-html-element-content-newline -->
<pre><code><span class="text-violet-400">export default</span> <span class="text-amber-400">defineEventHandler</span>(<span class="text-violet-400">async</span> (event) => {
<span class="text-violet-400">const</span> log = <span class="text-amber-400">useLogger</span>(event)

Expand Down
4 changes: 2 additions & 2 deletions apps/docs/content/0.landing.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ A modern TypeScript logger built for everything you ship. Simple logs, wide even
Set context. :br Get answers

#description
Accumulate context with log.set, throw structured errors with why and fix. One wide event captures everything, whether the request succeeds or fails.
Accumulate context with log.set, throw structured errors with why and fix, group recurring errors in typed catalogs. One wide event captures everything, whether the request succeeds or fails.
:::

:::features-feature-agent-ready
Expand Down Expand Up @@ -101,7 +101,7 @@ A modern TypeScript logger built for everything you ship. Simple logs, wide even
Compliance-ready :br by composition

#description
First-class who-did-what trails as a thin layer on top of wide events. One enricher, one drain wrapper, one helper. Tamper-evident hash chains, denied actions, redact-aware diffs, and idempotency keys for safe retries — all from the main entrypoint, no parallel pipeline.
First-class who-did-what trails as a thin layer on top of wide events. One enricher, one drain wrapper, one helper. Tamper-evident hash chains, denied actions, redact-aware diffs, idempotency keys for safe retries, and typed action catalogs for refactor-safe alerting — all from the main entrypoint, no parallel pipeline.
:::

:::features-feature-ai-sdk
Expand Down
164 changes: 130 additions & 34 deletions apps/docs/content/2.logging/3.structured-errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ throw createError({
import { createError } from 'evlog'

throw createError({
code: 'PAYMENT_DECLINED',
message: 'Payment failed',
status: 402,
why: 'Card declined by issuer',
Expand All @@ -163,6 +164,7 @@ throw createError({
"statusCode": 402,
"message": "Payment failed",
"data": {
"code": "PAYMENT_DECLINED",
"why": "Card declined by issuer",
"fix": "Try a different payment method",
"link": "https://docs.example.com/payments/declined"
Expand Down Expand Up @@ -354,52 +356,146 @@ throw createError({
```
::

## Error Categories
## Error Catalogs

For anything beyond a handful of one-off errors, group them in a typed **catalog**. evlog ships two primitives for this — `defineError` (single factory) and `defineErrorCatalog` (bundle prefixed). The wire `code` is auto-derived as `${prefix}.${KEY}` and the `EvlogError` instance is built with all defaults applied.

### `defineErrorCatalog`

Consider creating factory functions for common error types:
Define a bundle of errors that share a prefix. Convention: `UPPER_SNAKE_CASE` keys, `lower.dot.case` prefix.

::code-group
```typescript [Definition]
// server/utils/errors.ts
import { createError } from 'evlog'
```typescript [errors/billing.ts]
import { defineErrorCatalog } from 'evlog'

export const errors = {
notFound: (resource: string) =>
createError({
message: `${resource} not found`,
status: 404,
}),

unauthorized: () =>
createError({
message: 'Please log in to continue',
status: 401,
fix: 'Sign in to your account',
}),

validation: (field: string, issue: string) =>
createError({
message: `Invalid ${field}`,
status: 400,
why: issue,
fix: `Please provide a valid ${field}`,
}),
}
export const billingErrors = defineErrorCatalog('billing', {
CART_EMPTY: {
status: 400,
message: 'Cart is empty',
},
PAYMENT_DECLINED: {
status: 402,
message: 'Card declined',
why: 'Issuer declined the charge',
fix: 'Try a different payment method',
link: 'https://docs.example.com/errors/billing.payment_declined',
},
INSUFFICIENT_FUNDS: {
status: 402,
message: ({ available, required }: { available: number, required: number }) =>
`Insufficient funds: $${available} available, $${required} required`,
fix: 'Add funds and retry',
},
})
```
```typescript [Usage]
// server/api/orders/[id].get.ts
import { errors } from '~/server/utils/errors'
```typescript [server/api/checkout.post.ts]
import { billingErrors } from '~/errors/billing'

export default defineEventHandler(async (event) => {
const order = await getOrder(event.context.params.id)
const cart = await getCart(event)

if (!cart.items.length) throw billingErrors.CART_EMPTY()

if (!order) {
throw errors.notFound('Order')
try {
await stripe.charge(cart.total)
}
catch (e) {
if (e.code === 'card_declined') throw billingErrors.PAYMENT_DECLINED({ cause: e })
if (e.code === 'insufficient_funds') {
throw billingErrors.INSUFFICIENT_FUNDS({
available: e.balance,
required: cart.total,
cause: e,
})
}
throw e
}
})
```
::

Each entry becomes a typed factory. Catalog metadata is exposed on `_codes` and `_prefix` for introspection (non-enumerable so `Object.keys(billingErrors)` still returns just the entry names).

```typescript
billingErrors.PAYMENT_DECLINED.code // 'billing.PAYMENT_DECLINED' (literal type)
billingErrors.PAYMENT_DECLINED.status // 402
billingErrors._codes // readonly ['billing.CART_EMPTY', 'billing.PAYMENT_DECLINED', 'billing.INSUFFICIENT_FUNDS']
```

### Templated messages with typed params

Set `message` to a function and the params become **required and typed** at the call site.

```typescript
const InvoiceOverdue = defineError('billing.INVOICE_OVERDUE', {
status: 402,
message: ({ daysOverdue }: { daysOverdue: number }) =>
`Invoice overdue by ${daysOverdue} day(s)`,
fix: 'Pay outstanding invoice to resume service',
})

throw InvoiceOverdue({ daysOverdue: 7 }) // params required and type-checked
```

You can still override any field at the call site (`message`, `status`, `why`, `fix`, `link`, `internal`, `cause`). Catalog defaults for `internal` are shallow-merged with call-site values (call-site wins on conflict).

return order
### `defineError` — standalone factories

For one-off errors that don't fit a catalog (or for very large repos that prefer one file per error), use `defineError` directly. Same factory shape as a catalog entry, no prefix derivation.

```typescript
// errors/FraudDetected.ts
import { defineError } from 'evlog'

export const FraudDetected = defineError('billing.FRAUD_DETECTED', {
status: 403,
message: 'Transaction flagged for review',
why: 'ML fraud-score above threshold',
fix: 'Contact support to verify your identity',
})

throw FraudDetected()
```

### Type-safe codes everywhere (opt-in)

Augment the `RegisteredErrorCatalogs` interface to make every registered code surface as autocomplete on `createError({ code })`, `parseError(err).code`, and any other typed `code` field across the codebase.

::code-group
```typescript [errors/types.ts]
import type { billingErrors } from './billing'
import type { authErrors } from './auth'

declare module 'evlog' {
interface RegisteredErrorCatalogs {
billing: typeof billingErrors
auth: typeof authErrors
}
}
```
```typescript [Anywhere in your codebase]
// createError autocompletes registered codes (and still accepts ad-hoc strings)
throw createError({
code: 'billing.PAYMENT_DECLINED', // ← autocomplete, TS error if typo
message: 'Card declined',
status: 402,
})

// parseError().code is typed as the union of all registered codes
const err = parseError(caught)
if (err.code === 'billing.PAYMENT_DECLINED') retry()
// ↑ autocomplete, refactor-safe
```
::

This is purely type-level — no runtime registration, no init step. Skip it entirely if you don't need it; the runtime API is identical either way.

::callout{icon="i-lucide-package" color="neutral"}
**Packaging tip.** A catalog is regular TypeScript. Publish `@acme/errors-billing` exporting your `defineErrorCatalog(...)` plus the `declare module 'evlog'` augmentation in its `index.d.ts`, and the typing flows transitively to every consumer that depends on it. Each shared package owns its prefix, no conflicts possible.
::

::callout{icon="i-lucide-arrow-right" color="primary" to="/logging/catalogs"}
**Going further.** The dedicated [Catalogs page](/logging/catalogs) covers the scaling story (single file → folder → feature → npm package), the full npm packaging recipe, composition patterns, the type-augmentation deep dive, and common pitfalls.
::

::callout{icon="i-lucide-code" color="neutral"}
Expand Down
Loading
Loading