Observability
Error tracking, logging, and production error handling
Overview
The application uses @repo/observability (following the next-forge pattern) to wire up Sentry for error tracking and BetterStack Logtail for structured logging. Every unhandled error, catch block, and error boundary reports to Sentry. In production, logs go to BetterStack; in development, they go to the console.
Architecture
packages/observability/
├── keys.ts # env validation (Sentry DSN, BetterStack keys)
├── log.ts # Logtail (prod) / console (dev) logger
├── error.ts # parseError() — Sentry capture + logging
├── server-error.ts # handleServerError() — parseError for server contexts
├── instrumentation.ts # runtime dispatcher (Node.js / Edge)
├── server.ts # Sentry init for Node.js
├── edge.ts # Sentry init for Edge
├── client.ts # Sentry init for browser (+ session replays)
└── next-config.ts # withSentry(), withLogging() config wrappersWiring (apps/web)
Five files connect the web app to the observability package:
| File | Purpose |
|---|---|
instrumentation.ts | export const register = initializeSentry — server + edge init via Next.js register hook |
instrumentation-client.ts | initializeSentry() + initializeAnalytics() — browser init |
sentry.server.config.ts | initializeSentry() from server — Sentry webpack plugin entry |
sentry.edge.config.ts | initializeSentry() from edge — Sentry webpack plugin entry |
next.config.ts | withLogging() always, withSentry() on Vercel only |
Environment variables are pulled in through env.ts which extends @repo/observability/keys.
Error handling utilities
parseError(error) — universal error parser
Located in packages/observability/error.ts. Used everywhere.
import { parseError } from "@repo/observability/error";
try {
// ...
} catch (error) {
parseError(error); // → Sentry captureException + log.error
return NextResponse.json({ error: "..." }, { status: 500 });
}What it does:
- Extracts a message string from any error type (
Error, object with.message, or stringifies) - Calls
Sentry.captureException(error) - Calls
log.error()(Logtail in prod, console in dev) - Returns the message string
Use this in API routes, webhooks, and non-React server code.
handleServerError(error) — server catch handler
Located in packages/observability/server-error.ts. A thin wrapper around parseError for server-side catch blocks.
import { handleServerError } from "@repo/observability/server-error";
try {
// ...
} catch (error) {
handleServerError(error); // → parseError (Sentry + Logtail)
return [];
}What it does:
- Calls
parseError(error)— Sentry capture + structured logging - Returns the error message string
Use this in server actions, queries, server components, and any server-side catch blocks.
When to use which
| Context | Use | Why |
|---|---|---|
Server actions ("use server") | handleServerError | Server-only import, Sentry + logging |
| Query functions | handleServerError | Server-only import, Sentry + logging |
API routes (route.ts) | parseError | No server-only restriction |
| Webhooks | parseError | External entry point |
| Client components | captureException directly | Can't import server-only modules |
| Error boundaries | captureException in useEffect | Client-side, follows next-forge pattern |
Fire-and-forget .catch() | parseError | Inline promise catch |
handleError(error) — client-side toast
Located in packages/ui-web/lib/handle-error.ts. For client components that need to show error toasts.
import { handleError } from "@repo/ui-web/lib/handle-error";
try {
await someAction();
} catch (error) {
handleError(error); // → parseError + toast.error(message)
}Error boundaries
Three error boundaries catch unhandled errors and report to Sentry:
| File | Scope |
|---|---|
app/global-error.tsx | Fatal app-level errors |
app/[locale]/error.tsx | Route-level errors |
app/(admin)/admin/error.tsx | Admin panel errors |
All follow the same pattern:
useEffect(() => {
captureException(error);
}, [error]);Action clients
@repo/features/action-clients.ts uses parseError in handleServerError to report technical errors to Sentry before returning a translated user-facing message:
handleServerError: async (e) => {
if (isUserFacingError(e.message)) return e.message;
parseError(e); // → Sentry + Logtail
const t = await getTranslations({ locale, namespace: "global.errors" });
return t("unexpected");
},Sentry features
| Feature | Config |
|---|---|
| Error tracking | All runtimes (server, edge, client) |
| Session replays | Client only: 100% on error, 10% normal |
| Console capture | console.log/error/warn → Sentry logs |
| Tunnel route | /monitoring — bypasses ad-blockers |
| Source maps | Uploaded on CI builds (widenClientFileUpload) |
| Cron monitors | Automatic Vercel cron instrumentation |
Environment variables
| Variable | Required | Purpose |
|---|---|---|
NEXT_PUBLIC_SENTRY_DSN | Optional | Sentry data source name |
SENTRY_ORG | Optional | Sentry org (for source map uploads) |
SENTRY_PROJECT | Optional | Sentry project name |
All variables are optional. Without them, Sentry silently no-ops and logging falls back to console.
Graceful degradation
| Feature | Without env vars | With env vars |
|---|---|---|
| Error boundaries | Render error UI, no Sentry | Render UI + report to Sentry |
parseError / handleServerError | Logs to console | Sentry + Logtail |
| Session replays | Disabled | Active (client only) |
| Source map uploads | Skipped | Uploaded on CI |
| BetterStack status | Component returns null | Shows uptime badge |
Tunnel route (/monitoring) | No-op | Proxies Sentry requests |