Skip to content

Epic 8 — Error Boundaries & Crash Reporting

Goal: Every unhandled error and every missing page shows a polished full-screen UI rather than a blank/broken state, and every incident is stored for later triage.

Storage decision: Errors are stored in MongoDB (the existing Mongoose connection). Rationale: error logs are schema-flexible (404s carry URL context, runtime crashes carry stack traces, GraphQL errors carry operation info), the write pattern is append-only, and no relational joins are ever needed. No new database is required.


BE-ERR-001 — ErrorLog MongoDB model + reportClientError mutation + admin query

  • [x] Implemented

Files:

  • Create: src/models/mongo/ErrorLog.ts — Mongoose schema
  • Create: src/services/errors/index.tslogClientError, listErrorLogs, getErrorStats
  • Create: src/graphql/resolvers/errors.ts
  • Edit: src/graphql/schema/index.ts — add ErrorLog type, reportClientError mutation, adminErrorLogs / adminErrorStats queries
  • Edit: src/graphql/resolvers/index.ts
  • Create: src/__tests__/resolvers/errors.test.ts

MongoDB schema (ErrorLog):

ts
{
  fingerprint:      string;  // sha1(type + ':' + first stack line) — for deduplication
  type:             'RUNTIME_ERROR' | 'NOT_FOUND' | 'UNHANDLED_REJECTION';
  message:          string;  // max 500 chars
  stack:            string?; // max 5 000 chars
  componentStack:   string?; // React component stack, max 3 000 chars
  url:              string;  // full href at time of error
  route:            string;  // pathname only
  userId:           string?; // if authenticated at the time
  userAgent:        string?;
  appVersion:       string?; // VITE_APP_VERSION env var
  extra:            object?; // any additional metadata
  count:            number;  // incremented on fingerprint collision (upsert)
  firstSeenAt:      Date;
  lastSeenAt:       Date;    // updated on every occurrence
}

Key design choices:

  • fingerprint deduplication: if the same error is thrown repeatedly, a single document is upserted and count is incremented — no unbounded growth.
  • reportClientError is public (no auth required) — errors can occur before login resolves. Inputs are sanitized and size-capped server-side.
  • Soft rate-limit: max 5 reports per IP per minute, enforced via Redis (existing connection). Over-limit calls return true silently to avoid leaking internal state.

GraphQL additions:

graphql
enum ClientErrorType {
  RUNTIME_ERROR
  NOT_FOUND
  UNHANDLED_REJECTION
}

type ErrorLog {
  id:             ID!
  fingerprint:    String!
  type:           ClientErrorType!
  message:        String!
  url:            String!
  route:          String!
  userId:         String
  count:          Int!
  firstSeenAt:    DateTime!
  lastSeenAt:     DateTime!
}

type ErrorLogPage {
  logs:       [ErrorLog!]!
  totalCount: Int!
  page:       Int!
  pageSize:   Int!
  totalPages: Int!
}

type ErrorStats {
  totalErrors:      Int!
  uniqueErrors:     Int!
  notFoundCount:    Int!
  runtimeCount:     Int!
  last24hCount:     Int!
  topRoutes:        [RouteErrorCount!]!
}

type RouteErrorCount {
  route: String!
  count: Int!
}

input ReportClientErrorInput {
  type:           ClientErrorType!
  message:        String!
  stack:          String
  componentStack: String
  url:            String!
  route:          String!
  userId:         String
  appVersion:     String
  extra:          JSON
}

# Public — no auth required
reportClientError(input: ReportClientErrorInput!): Boolean!

# Admin-only
adminErrorLogs(page: Int, pageSize: Int, type: ClientErrorType, route: String): ErrorLogPage!
adminErrorStats: ErrorStats!

Acceptance criteria:

  • Duplicate errors (same fingerprint) are upserted, not inserted — count increments, lastSeenAt updates
  • Messages truncated server-side to 500 chars, stacks to 5 000 chars, componentStack to 3 000 chars
  • IP rate limit enforced via Redis — silent accept over limit
  • adminErrorLogs is paginated and filterable by type and route
  • adminErrorStats returns aggregated counts used by the admin dashboard widget

DS-ERR-001 — ErrorPage design system component

  • [x] Implemented

Files:

  • Create: packages/design-system/src/components/ui/ErrorPage.tsx
  • Edit: packages/design-system/src/index.ts

Description: Full-viewport error page with two variants controlled by the variant prop.

PropTypeDescription
variant'404' | '500'Controls illustration, heading, and default copy
titlestring?Override default heading
descriptionstring?Override default body copy
onRetry() => void?Shows "Try again" button (500 only)
onHome() => void?Shows "Go home" button (both variants)
classNamestring?

Semantic HTML structure:

html
<main role="main" aria-labelledby="error-heading">
  
  <h1 id="error-heading">404 — Page not found</h1>
  <p></p>
  <nav aria-label="Error recovery">
    <a href="/">Go home</a>
    <button>Try again</button>  
  </nav>
</main>

404 defaults:

  • Heading: "Page not found"
  • Description: "The page you're looking for doesn't exist or has been moved."
  • HTTP status context visible to screen readers via aria-label

500 defaults:

  • Heading: "Something went wrong"
  • Description: "An unexpected error occurred. Our team has been notified."
  • Shows "Try again" button that calls onRetry (typically () => window.location.reload())

Acceptance criteria:

  • <h1> is always present and unique on the page
  • Navigation links use <a> (for 404 home link) or <button> (for retry)
  • Both variants look correct in light and dark mode (uses DS semantic tokens only)
  • Component is exported from @castyou/design-system

FE-ERR-001 — ErrorBoundary component, NotFoundPage, and app-level wiring

  • [x] Implemented

Files:

  • Create: apps/app/src/components/ErrorBoundary.tsx — React class component
  • Create: apps/app/src/pages/errors/NotFoundPage.tsx — renders <ErrorPage variant="404" />
  • Create: apps/app/src/lib/queries/errors.tsREPORT_CLIENT_ERROR mutation
  • Edit: apps/app/src/App.tsx — replace <Navigate to="/" /> wildcard with <NotFoundPage />; add TalentRoute guard
  • Edit: apps/app/src/main.tsx — wrap <App /> in <ErrorBoundary>

ErrorBoundary behaviour:

  1. Catches any unhandled render/lifecycle error via componentDidCatch
  2. Calls reportClientError mutation (fire-and-forget — failure to report must never mask the original error)
  3. Reads userId from localStorage key castyou_user_id (set by auth store on login) so the log is attributed without importing Zustand into a class component
  4. Renders <ErrorPage variant="500" onRetry={() => this.setState({ hasError: false })} onHome={() => window.location.href = '/'} />
  5. Resets hasError state on navigation changes (listen for popstate/hashchange)

NotFoundPage:

  • Renders <ErrorPage variant="404" onHome={() => navigate('/')} />
  • Uses useEffect to call reportClientError with type: NOT_FOUND on mount (so 404s are tracked too)
  • Sets document.title = "404 — Not Found | CasTyou"

Wiring summary:

  • main.tsx: <ErrorBoundary> <App /> </ErrorBoundary> — catches anything thrown during rendering
  • App.tsx: <Route path="*" element={<NotFoundPage />} /> — replaces the silent redirect to /

Acceptance criteria:

  • Navigating to /this/does/not/exist shows the 404 page (not a redirect, not a blank screen)
  • Throwing inside a component shows the 500 page with "Try again" and "Go home" buttons
  • Every 404 and every runtime error appears in adminErrorLogs within seconds
  • "Try again" resets the boundary and re-renders the subtree (does not full-page reload)
  • "Go home" navigates to / (uses window.location.href to fully reset boundary state)
  • No console errors when reportClientError network call fails (fire-and-forget, swallowed)