Appearance
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.ts—logClientError,listErrorLogs,getErrorStats - Create:
src/graphql/resolvers/errors.ts - Edit:
src/graphql/schema/index.ts— addErrorLogtype,reportClientErrormutation,adminErrorLogs/adminErrorStatsqueries - 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:
fingerprintdeduplication: if the same error is thrown repeatedly, a single document is upserted andcountis incremented — no unbounded growth.reportClientErroris 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
truesilently 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 —
countincrements,lastSeenAtupdates - 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
adminErrorLogsis paginated and filterable by type and routeadminErrorStatsreturns 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.
| Prop | Type | Description |
|---|---|---|
variant | '404' | '500' | Controls illustration, heading, and default copy |
title | string? | Override default heading |
description | string? | Override default body copy |
onRetry | () => void? | Shows "Try again" button (500 only) |
onHome | () => void? | Shows "Go home" button (both variants) |
className | string? |
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.ts—REPORT_CLIENT_ERRORmutation - Edit:
apps/app/src/App.tsx— replace<Navigate to="/" />wildcard with<NotFoundPage />; addTalentRouteguard - Edit:
apps/app/src/main.tsx— wrap<App />in<ErrorBoundary>
ErrorBoundary behaviour:
- Catches any unhandled render/lifecycle error via
componentDidCatch - Calls
reportClientErrormutation (fire-and-forget — failure to report must never mask the original error) - Reads
userIdfromlocalStoragekeycastyou_user_id(set by auth store on login) so the log is attributed without importing Zustand into a class component - Renders
<ErrorPage variant="500" onRetry={() => this.setState({ hasError: false })} onHome={() => window.location.href = '/'} /> - Resets
hasErrorstate on navigation changes (listen forpopstate/hashchange)
NotFoundPage:
- Renders
<ErrorPage variant="404" onHome={() => navigate('/')} /> - Uses
useEffectto callreportClientErrorwithtype: NOT_FOUNDon mount (so 404s are tracked too) - Sets
document.title = "404 — Not Found | CasTyou"
Wiring summary:
main.tsx:<ErrorBoundary> <App /> </ErrorBoundary>— catches anything thrown during renderingApp.tsx:<Route path="*" element={<NotFoundPage />} />— replaces the silent redirect to/
Acceptance criteria:
- Navigating to
/this/does/not/existshows 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
adminErrorLogswithin seconds - "Try again" resets the boundary and re-renders the subtree (does not full-page reload)
- "Go home" navigates to
/(useswindow.location.hrefto fully reset boundary state) - No console errors when
reportClientErrornetwork call fails (fire-and-forget, swallowed)