Basilic
Architecture

Error Handling

Error handling with Sentry integration and app-level error catalogs.

Complete guide for error handling across the monorepo: @repo/sentry for reporting to Sentry, @repo/utils/error for message extraction, and app-level catalogs for safe API responses.

Overview

  • @repo/sentrycaptureError(options) sends errors to Sentry (void; does not return a catalog). Use platform subpaths: node, nextjs, browser, react.
  • @repo/utils/errorgetErrorMessage(error) and related utilities for type-safe message extraction.
  • Sentry initialization – Not in @repo/sentry. Initialize per your platform’s Sentry docs (Node, Next.js, Browser).
  • Error catalogs and safe responses – Implemented in each app (e.g. Fastify’s apps/fastify/src/lib/catalogs/ with getError, mapHttpStatusToErrorCode). Apps build the safe { code, message } response themselves.

Key principles:

  • Two-track: Sentry gets real errors (debugging); API returns safe catalog errors (users), built by the app.
  • Security-first: Sentry built-in PII scrubbing; never expose internals to users.
  • Performance: Async Sentry capture, non-blocking.

Quick Start

Capture Error (Most Common)

// For Next.js apps
import { captureError } from '@repo/sentry/nextjs'

// For Node.js/Fastify apps
// import { captureError } from '@repo/sentry/node'

captureError({
  error,
  label: 'API Call',
  code: 'NETWORK_ERROR', // optional, for Sentry tags
  data: { endpoint: '/api/data' },
  tags: { app: 'web' },
})
// void – does not return a catalog. Build API response from your app's catalog (e.g. getError).

Extract Error Message

import { getErrorMessage } from '@repo/utils/error'

const message = getErrorMessage(error) // Type-safe

Initialize Sentry

Sentry is not initialized by @repo/sentry. Follow your platform’s setup:

Core Concepts

Two-Track Error Handling

Critical Distinction: When an error occurs, two separate things happen:

  1. Sentry (Internal) - REAL error with full stack trace and internal context for debugging
  2. API Response (External) - SAFE catalog error with user-friendly message
User Action → Error Occurs

     ├─→ [Sentry] Real error + stack trace + internal context (developers)
     └─→ [API Response] Safe catalog error { code, message } (users)

Example (Fastify):

// Real error occurs (e.g., database connection failure)
const realError = new Error('Connection to postgres://internal-db:5432 failed: ECONNREFUSED')

// captureError sends REAL error to Sentry (void)
captureError({
  error: realError,
  label: 'Database Connection',
  data: { host: 'internal-db', port: 5432 },
  tags: { app: 'api' },
})

// App returns SAFE catalog error from its own catalog (e.g. getError('SERVER_ERROR'))
const catalogError = getError('SERVER_ERROR') ?? { code: 'UNEXPECTED_ERROR', message: 'An unexpected error occurred' }
reply.status(500).send(catalogError)
// ❌ User NEVER sees: stack traces, connection strings, internal IPs

Why:

  • Security: Users never see sensitive internal details
  • UX: Users see friendly, actionable messages
  • Debugging: Developers have full context in Sentry

App-level error catalogs

Error catalogs and safe responses are implemented per app, not in @repo/sentry. For example, the Fastify app defines catalogs in apps/fastify/src/lib/catalogs/ (e.g. server.ts, client.ts, common.ts) and exposes getError(code), mapHttpStatusToErrorCode(statusCode). The error handler calls captureError from @repo/sentry/node then builds the response using the app’s getError.

Adding new error codes: Add them to your app’s catalog (e.g. apps/fastify/src/lib/catalogs/), then use getError(yourCode) when building responses. Use UPPER_SNAKE_CASE (e.g. NETWORK_ERROR, USER_NOT_FOUND).

Security

Sentry's Built-in PII Scrubbing: Sentry automatically handles sensitive data scrubbing. No custom beforeSend hook required by default.

Why Sentry's built-in approach:

  • Battle-tested and maintained by Sentry
  • Handles edge cases (circular refs, depth limits, etc.)
  • No custom code to maintain
  • Works across all event types (errors, breadcrumbs, contexts)
  • Automatically scrubs: password, token, secret, apiKey, authorization, cookie, session

Optional Custom Scrubbing: Add a beforeSend hook to Sentry initialization only if you need domain-specific scrubbing beyond Sentry's defaults.

Never expose internal details to users - Always return safe catalog errors.

Framework Integration

Fastify (Backend API)

Global Error Handler Plugin

Use captureError from @repo/sentry/node (void); build the response from the app’s catalog (getError, mapHttpStatusToErrorCode from apps/fastify/src/lib/catalogs/):

// apps/fastify/src/plugins/error-handler.ts
import { captureError } from '@repo/sentry/node'
import { getError, mapHttpStatusToErrorCode } from '../lib/catalogs/mapper.js'
// ... route extraction, redaction ...

fastify.setErrorHandler((error, request, reply) => {
  const statusCode = error.statusCode ?? 500
  const errorCode = mapHttpStatusToErrorCode(statusCode)

  captureError({
    error,
    logger: request.log,
    label: `${request.method} ${request.url}`,
    data: { method: request.method, url: request.url, headers: sanitizedHeaders, body: sanitizedBody },
    tags: { app: 'api', module, route: routePath, method: request.method },
  })

  const catalogError = getError(errorCode) ?? getError('UNEXPECTED_ERROR') ?? { code: 'UNEXPECTED_ERROR', message: 'An unexpected error occurred' }
  reply.status(statusCode).send({ code: catalogError.code, message: catalogError.message })
})

Setup: Initialize Sentry with @sentry/node at app bootstrap (see Sentry Node). Then register the error handler plugin. Routes can throw; the global handler captures to Sentry and returns the app’s catalog response.

React (Frontend Components)

Error Boundaries

Recommended: Use AppErrorBoundary

// apps/next/app/layout.tsx
'use client'

import { AppErrorBoundary } from '@repo/sentry/react'
import { captureError } from '@repo/sentry/nextjs'

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <AppErrorBoundary app="web" captureError={captureError}>
      {children}
    </AppErrorBoundary>
  )
}

Custom implementation: Use captureError from @repo/sentry/nextjs in onError; it returns void. Show user-facing message via your fallback (e.g. getErrorMessage(error) from @repo/utils/error).

Event Handlers

import { captureError } from '@repo/sentry/nextjs'
import { getErrorMessage } from '@repo/utils/error'
import { toast } from 'sonner'

async function handleSubmit() {
  try {
    await submitForm()
  } catch (error) {
    captureError({ error, label: 'Form Submission', tags: { app: 'web', module: 'form-handler' } })
    toast.error(getErrorMessage(error)) // or use your app's catalog for a safe message
  }
}

Use for: App-level, section-level, feature-level error handling, third-party component errors

Don't use for: Event handlers (use try/catch with captureError), async code (use try/catch), SSR errors

Next.js (App Router)

Error Pages

// app/error.tsx
'use client'
import { captureError } from '@repo/sentry/nextjs'
import { useEffect } from 'react'

export default function ErrorPage({ 
  error, 
  reset 
}: { 
  error: Error & { digest?: string }
  reset: () => void 
}) {
  useEffect(() => {
    captureError({
      code: 'UNEXPECTED_ERROR',
      error,
      label: 'Next.js Error Page',
      tags: { app: 'web', module: 'error-page' },
      data: {
        digest: error.digest,
      },
    })
  }, [error])

  return (
    <div>
      <h2>Something went wrong</h2>
      <button onClick={() => reset()}>Try again</button>
    </div>
  )
}

Server Actions

Pattern 1: Return result object – Capture with captureError, then return a safe error (e.g. from your app’s catalog or getErrorMessage):

'use server'
import { captureError } from '@repo/sentry/nextjs'
import { getErrorMessage } from '@repo/utils/error'

export async function serverAction() {
  try {
    const result = await db.query('...')
    return { success: true, data: result }
  } catch (error) {
    captureError({ error, label: 'Server Action', tags: { app: 'web', module: 'server-action' } })
    return { success: false, error: { message: getErrorMessage(error) } }
  }
}

Pattern 2: Throw error – Capture then throw; Next.js handles serialization.

'use server'
import { captureError } from '@repo/sentry/nextjs'

export async function serverAction() {
  try {
    return await db.query('...')
  } catch (error) {
    captureError({ error, label: 'Server Action', tags: { app: 'web', module: 'server-action' } })
    throw error
  }
}

Initialization: Sentry is not initialized by @repo/sentry. Use Sentry’s Next.js setup in next.config, instrumentation.ts, etc.

API Reference

captureError(options): void (@repo/sentry/*)

Sends the error to Sentry asynchronously. Does not return a catalog; each app builds its own response.

import { captureError } from '@repo/sentry/nextjs' // or /node, /browser

captureError({
  error: unknown,        // Converted to Error if needed; full stack sent to Sentry
  label: string,        // Component/feature label
  code?: string,        // Optional tag for Sentry
  data?: Record<string, unknown>,
  tags?: Record<string, string>,
  level?: 'error' | 'warning' | 'info',
  report?: boolean,    // If false, skip Sentry (default true)
  logger?: Logger,     // Optional; in Fastify pass request.log
})

See packages/sentry/README.md for full options. Serverless: Consider Sentry.flush() before function exit if the process terminates quickly.

getErrorMessage(error) (@repo/utils/error)

import { getErrorMessage } from '@repo/utils/error'

const message = getErrorMessage(error) // Type-safe extraction from unknown

App-level: getError, mapHttpStatusToErrorCode

These live in each app (e.g. Fastify’s apps/fastify/src/lib/catalogs/mapper.ts). mapHttpStatusToErrorCode(statusCode) maps HTTP status to a catalog code; getError(code) returns { code, message } for the response. Not part of @repo/sentry.

Best Practices

Common Patterns

import { getErrorMessage } from '@repo/utils/error'
import { captureError } from '@repo/sentry/nextjs'

// Extract message
const message = getErrorMessage(error)

// Capture with context (void)
captureError({ error, label: 'Operation Name', tags: { app: 'api' } })

// In route/handler: capture then return safe response from app catalog
captureError({ error, label: 'Validation', tags: { app: 'api' } })
return reply.status(422).send(getError('INVALID_INPUT'))

Anti-Patterns

// ❌ DON'T: Silent failures, expose internals, type assertions, console.*
catch (error) { /* silent */ }
return { error: error.message, stack: error.stack }
const message = (error as Error).message

// ✅ DO: Capture, return safe messages, use getErrorMessage
captureError({ error, label: 'Op', tags: { app: 'api' } })
return { message: getErrorMessage(error) } // or getError(code) from app catalog

Decision Tree

Error occurs

├─ React component render? → Use Error Boundary (automatic)
├─ Fastify route? → Let error handler catch (automatic)
├─ Next.js Server Action? → Capture and return error object or throw
├─ Event handler (onClick, onSubmit)? → Try/catch with captureError + feedback
├─ Async code (Promise)? → Try/catch with captureError
├─ Background job/worker? → Capture with captureError, log for monitoring
├─ API route handler? → Let Fastify error handler catch (automatic)
└─ Recoverable? 
   ├─ Yes → Capture with 'warning' + retry/fallback
   └─ No → Capture with 'error' + show user message

On this page