
Catch it first.
You shipped a bug. You found out from a support ticket because your tooling didn't catch it. Logs only cover the errors you anticipated. Error monitoring covers the rest: the unhandled promise rejection at 3am, the TypeError in a code path nobody tested.
So you build a capture layer. It hooks into the runtime, routes everything to a service that aggregates, deduplicates, and alerts. Your application code doesn't change.
The Abstraction
Start with one function. Every error in your app goes through it. Swap the backend later without touching call sites.
export function logError(
error: unknown,
context: Record<string, unknown> = {}
) {
const err = error instanceof Error ? error : new Error(String(error));
console.error("[error]", err.message, context);
// In production: Sentry.captureException(), your own endpoint, etc.
}Console output during development, production reporting when you wire up a backend. The context object carries whatever state you need to reproduce the issue.
Explicit Error Handling
Use logError in try/catch blocks where you anticipate failures. The context object carries the state you need to reproduce the issue.
import { logError } from "./errors";
async function chargeCard(orderId: string, amount: number) {
try {
const result = await stripe.charges.create({ amount });
return result;
} catch (error) {
logError(error, { orderId, amount, operation: "chargeCard" });
throw new PaymentError("Card charge failed", { cause: error });
}
}Report first, then re-throw if the caller needs to handle it. Or recover gracefully if you can. Either way, the error is captured.
catch block with no reporting hides the problem. If you catch it, report it.Error Boundaries (React)
React won't catch render errors with a global handler. Without an error boundary, one broken component takes down the entire tree.
import React from "react";
import { logError } from "./errors";
interface Props {
children: React.ReactNode;
fallback: React.ReactNode;
}
interface State {
hasError: boolean;
}
class ErrorBoundary extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(): State {
return { hasError: true };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
logError(error, { componentStack: info.componentStack });
}
render() {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}Same logError call. The boundary catches it, your abstraction routes it.
Global Error Handlers
Global handlers catch the errors you didn't anticipate. They install once at startup and route everything through your abstraction.
import { logError } from "./errors";
window.addEventListener("error", (event) => {
logError(event.error, {
message: event.message,
filename: event.filename,
lineno: event.lineno,
colno: event.colno,
});
});
window.addEventListener("unhandledrejection", (event) => {
logError(event.reason, { type: "unhandledrejection" });
});import { logError } from "./errors";
process.on("uncaughtException", (error) => {
logError(error, { type: "uncaughtException" });
process.exit(1); // Let it crash — restart cleanly
});
process.on("unhandledRejection", (reason) => {
logError(reason, { type: "unhandledRejection" });
});Catch at specific boundaries and let the rest bubble up to global handlers.
Wire Up a Backend
Once your capture layer exists, pointing it at a real service is just configuration. Sentry gives you stack traces with source maps, intelligent grouping, release tracking, and alerts out of the box. It also handles the global handlers and error boundaries for you, so you can skip that boilerplate entirely.
import * as Sentry from "@sentry/node";
Sentry.init({
dsn: "https://examplePublicKey@o0.ingest.sentry.io/0",
environment: process.env.NODE_ENV,
});
export function logError(
error: unknown,
context: Record<string, unknown> = {}
) {
const err = error instanceof Error ? error : new Error(String(error));
Sentry.captureException(err, { extra: context });
}Minified stack traces are useless. a.js:1:34523 tells you nothing. checkout.ts:142 in processPayment tells you where to look.