Logging

Structured Errors

Create errors that explain why they occurred and how to fix them. Add actionable context with why, fix, and link fields for humans and AI agents.

evlog provides a createError() function that creates errors with rich, actionable context.

Use structured errors in my app

Why Structured Errors?

error context·idle
vanilla·throw new Error()
throw new Error("Payment failed")
↓ caller catches
err.message  "Payment failed"
err.status   undefined
err.why      undefined
err.fix      undefined
error

Something went wrong.
Please try again.

structured·createError()
throw createError({
  message: "Payment failed", // what went wrong
  status: 402, // HTTP status
  why: "Card declined by issuer", // technical reason
  fix: "Try a different card", // actionable advice
  link: "/docs/payments/declined" // docs link
})
↓ parseError(err)
{ message, status, why, fix, link }
all fields available · safe by default
payment failed · 402

Card declined by issuer. Try a different card.

read more
1 field · user has to guess
5 fields · actionable end-to-end

Traditional errors are often unhelpful:

server/api/checkout.post.ts
// Unhelpful error
throw new Error('Payment failed')

This tells you what happened, but not why or how to fix it.

Structured errors provide context:

import { createError } from 'evlog'

throw createError({
  code: 'PAYMENT_DECLINED',
  message: 'Payment failed',
  status: 402,
  why: 'Card declined by issuer (insufficient funds)',
  fix: 'Try a different payment method or contact your bank',
  link: 'https://docs.example.com/payments/declined',
})

Error Fields

FieldRequiredDescription
messageYesWhat happened (shown to users)
codeNoStable machine-readable identifier for client branching (e.g. 'PAYMENT_DECLINED')
statusNoHTTP status code (default: 500)
whyNoTechnical reason (for debugging)
fixNoActionable solution
linkNoDocumentation URL
causeNoOriginal error (for error chaining)
internalNoBackend-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.

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

Simple Error

import { createError } from 'evlog'

throw createError({
  message: 'User not found',
  status: 404,
})

Error with Full Context

import { createError } from 'evlog'

throw createError({
  message: 'Payment failed',
  status: 402,
  why: 'Card declined by issuer',
  fix: 'Try a different payment method',
  link: 'https://docs.example.com/payments/declined',
})

Error Chaining

Wrap underlying errors while preserving the original:

server/api/checkout.post.ts
import { createError } from 'evlog'

try {
  await stripe.charges.create(charge)
} catch (err) {
  throw createError({
    message: 'Payment processing failed',
    status: 500,
    why: 'Stripe API returned an error',
    cause: err, // Original error preserved
  })
}

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.

structured error · server → client·SERVER
servercheckout.post.ts
throw createError({
  code:    'PAYMENT_DECLINED',
  message: 'Payment failed',
  status:  402,
  why:     'Card declined by issuer',
  fix:     'Try a different…',
})
awaiting throw
networkPOST /api/checkout
json envelope
{
  statusCode: 402,
  message: 'Payment failed',
  data: { code: 'PAYMENT_DECLINED' }
}
server
client
clientuseCheckout.ts
parseError(err)
{
  code:    'PAYMENT_DECLINED',
  message: 'Payment failed',
  status:  402,
  why:     'Card declined…',
  fix:     'Try another…',
}
switch (error.code)
case'PAYMENT_DECLINED':
showRetryWithDifferentCard()
case'CART_EXPIRED':
rebuildCart()
default:
toast.add({ ...error })
toast →Try a different payment method
stable code, no message parsing
composables/useCheckout.ts
import { parseError } from 'evlog'

try {
  await $fetch('/api/checkout', { method: 'POST', body: cart })
} catch (err) {
  const error = parseError(err)

  switch (error.code) {
    case 'PAYMENT_DECLINED':
      return showRetryWithDifferentCard()
    case 'CART_EXPIRED':
      return rebuildCart()
    default:
      return toast.add({ title: error.message, color: 'error' })
  }
}

parseError() also surfaces code from Node-style errors (e.g. 'ENOENT', 'ECONNRESET') and any Error instance with a string .code property, so existing system errors flow through the same branch.

code is also copied onto wide events under error.code, so dashboards and drains can group, alert, and chart by code without parsing free-text messages.

Frontend Error Handling

Use parseError() to extract all fields from caught errors:

import { parseError } from 'evlog'

try {
  await $fetch('/api/checkout', { method: 'POST', body: cart })
} catch (err) {
  const error = parseError(err)

  console.log(error.message)  // "Payment failed"
  console.log(error.status)   // 402
  console.log(error.code)     // "PAYMENT_DECLINED"
  console.log(error.why)      // "Card declined"
  console.log(error.fix)      // "Try another card"
}

Error Display Component

Create a reusable error display:

components/ErrorAlert.vue
<script setup lang="ts">
import { parseError } from 'evlog'

const { error } = defineProps<{
  error: unknown
}>()

const parsed = computed(() => parseError(error))
</script>

<template>
  <UAlert
    :title="parsed.message"
    :description="parsed.why"
    color="error"
    icon="i-lucide-alert-circle"
  >
    <template v-if="parsed.fix" #description>
      <p>{{ parsed.why }}</p>
      <p class="mt-2 font-medium">{{ parsed.fix }}</p>
    </template>
  </UAlert>
</template>

Best Practices

Use Appropriate Status Codes

// Client error - user can fix
throw createError({
  message: 'Invalid email format',
  status: 400,
  fix: 'Please enter a valid email address',
})

Provide Actionable Fixes

// Unhelpful fix
throw createError({
  message: 'Upload failed',
  fix: 'Try again',
})

Error Categories

Consider creating factory functions for common error types:

// server/utils/errors.ts
import { createError } 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}`,
    }),
}
See the Next.js guide for a working implementation.

Next Steps

  • Wide Events: Accumulate context and emit comprehensive events
  • Adapters: Send errors and events to Axiom, Sentry, PostHog, and more
  • Frameworks: Auto-managed request logging per framework
  • Quick Start: See all evlog APIs in action