Observability: Seeing What Your React App Is Actually Doing in Production
Build a practical observability layer for a React application — structured logging, error monitoring, performance monitoring, user context, and correlation IDs — so that when something breaks for a real user, you can find out what, why, and for whom, without being able to reproduce it locally.
In development, you can see everything: the console is right there, React DevTools shows every render, and you can set a breakpoint and step through the exact moment something goes wrong. In production, none of that exists. Your app runs on thousands of different devices, browsers, and network conditions you'll never see, and the only way to know what happened is if your app told you — proactively, before it crashed, and in enough detail to actually debug it after the fact. Observability is the discipline of instrumenting your app so it *reports on itself*: logging events with structure and context, capturing errors with stack traces and user state, measuring real performance as real users experience it, and tying it all together with correlation IDs so a single user session's story can be reconstructed from scattered log lines. This lesson builds that layer for a React app, and is equally about what *not* to log — privacy has to be designed in from the start, not bolted on after a compliance review.
Logging: Structured, Not console.log
console.log is fine for local debugging, but it is nearly useless in production: it produces unstructured text that a human has to be actively watching, it doesn't get shipped anywhere durable, and it can't be searched, filtered, or aggregated across thousands of user sessions. Production logging means sending *structured* events (JSON objects with consistent fields, not free-text strings) to a backend log aggregator (services like Datadog, Sentry, LogRocket, or a self-hosted ELK stack), where they can be searched, alerted on, and correlated across a whole user session or across your whole user base.
A structured log entry should always carry the same skeleton of context — a timestamp, a severity level, an event name, the user (if known), and a correlation ID — plus whatever payload is specific to that event. That consistency is what makes logs queryable later: 'show me every checkout_failed event for this correlation ID' only works if every event was shaped the same way.
• Log *events*, not sentences — logEvent('checkout_failed', { reason: 'card_declined', orderId }) beats console.log('checkout failed because card was declined') because it's machine-searchable. • Attach severity levels (debug, info, warn, error) so you can filter noise out in production while keeping detail available when investigating an incident. • Batch and send logs asynchronously (don't block the UI thread waiting for a logging network call to complete) and make sure logging failures never crash the app they're trying to observe.
Error Monitoring: Catching What React's Error Boundaries See
React error boundaries (componentDidCatch / static getDerivedStateFromError, or a library like react-error-boundary) catch rendering errors that would otherwise unmount your whole app. But catching the error is only half the job — the other half is *reporting* it somewhere durable, with a full stack trace, component stack, and enough application context to reproduce or at least understand it. Tools like Sentry are purpose-built for this: they capture the error, the breadcrumb trail of recent user actions leading up to it, the browser/OS/viewport, and (via source maps) the original TypeScript file and line number instead of a mangled minified one.
Error boundaries only catch errors thrown during rendering, lifecycle methods, and constructors of the components below them — they do not catch errors in event handlers, in async code, in setTimeout callbacks, or during server-side rendering. Those need their own explicit try/catch plus a call into the same error-reporting pipeline, and a global window.addEventListener('error', ...) / window.addEventListener('unhandledrejection', ...) pair as a last-resort safety net for anything that slips through everything else.
• React error boundaries catch render/lifecycle errors — wrap route-level and widget-level boundaries so one broken feature doesn't take down the whole page. • Wrap async event handlers in try/catch explicitly and report caught errors the same way as boundary-caught ones, using the same reporting function. • Add a global window.onerror / unhandledrejection listener as a safety net for anything that isn't caught by a boundary or a local try/catch.
Performance Monitoring: What Users Actually Experience
Performance monitoring in production means measuring real user experience (Real User Monitoring, or RUM), not just your local Lighthouse score. Core Web Vitals — Largest Contentful Paint (LCP), Interaction to Next Paint (INP), and Cumulative Layout Shift (CLS) — are the industry-standard metrics because they map directly to what users perceive as 'fast' or 'janky', and they can all be measured in the real browser of a real user via the web-vitals library and reported the same way errors are.
For a React app specifically, it's also worth measuring React-specific signals: time-to-first-meaningful-render for a route, the duration of expensive useEffect chains, and slow API calls surfaced through your data-fetching layer (React Query's onError/timing hooks, or a fetch wrapper that times every request). These get reported as performance events, tagged with the same correlation ID and user context as your logs and errors, so a slow page and the errors it triggered can be tied to the same session.
• LCP (Largest Contentful Paint) — how long until the main content is visible. • INP (Interaction to Next Paint) — how responsive the app feels to clicks/taps/keypresses. • CLS (Cumulative Layout Shift) — how much visible content jumps around unexpectedly. • Custom timers around slow operations specific to your app — a checkout flow, a search query, a large table render.
User Context, Correlation IDs, and Source Maps
A stack trace alone rarely tells you enough. 'TypeError at line 42' is far more useful when attached to: which user (or at least an anonymized user ID) hit it, what page/route they were on, what actions they took in the prior few seconds (breadcrumbs), what version of the app was deployed, and what device/browser they were using. This is 'user context' — a bundle of metadata attached to every log, error, and performance event so an isolated line in a log viewer can be traced back to a full, coherent story.
A correlation ID (sometimes called a request ID or trace ID) is a unique identifier generated once — typically per user session, or per individual user action like a page load or a form submission — and attached to every log line, error report, and outgoing API request tied to that action. When the frontend sends the correlation ID as a request header and the backend logs it too, you can search both frontend and backend logs by the same ID and see the entire cross-system story of one user's one action, instead of trying to line up timestamps by hand.
Source maps are what make any of this readable at all: production JavaScript is minified and bundled, so a raw stack trace points to meaningless minified variable names and a single-line bundle file. Uploading source maps to your error monitoring tool (as a build step, kept private and never shipped to the public) lets it automatically translate that stack trace back into your real, original TypeScript file names, function names, and line numbers.
• Correlation ID: generate one per session (or per user action) with crypto.randomUUID(), store it in memory/context, attach it to every log/error/API call. • User context: attach a stable but privacy-safe user identifier (an internal user ID, never raw PII like email or name unless your tooling and legal review explicitly allow it) plus app version, route, and device info. • Source maps: generate them in your build (sourcemap: true in most bundlers), upload them privately to your error monitoring provider, and never serve them publicly from your production domain. • Privacy: scrub or redact obviously sensitive fields (passwords, tokens, full card numbers, precise location) before any payload — log or error — leaves the browser; treat this as a hard rule, not a judgment call made per log line.
A Minimal Observability Client
The correlation ID is generated once when the module loads and lives for the app session; a per-navigation or per-action ID can be layered on top for finer-grained tracing. redact() runs on every payload before it leaves the browser — treating privacy scrubbing as automatic infrastructure, not something each call site has to remember. sendToLogBackend(...).catch(() => {}) guarantees a failed logging call never throws an unhandled rejection that itself becomes an error you have to observe.
// observability.ts — a small wrapper used by the whole app
type LogLevel = "debug" | "info" | "warn" | "error";
interface ObservabilityContext {
correlationId: string;
userId?: string;
route?: string;
}
let context: ObservabilityContext = { correlationId: crypto.randomUUID() };
export function setUserContext(userId: string) {
context = { ...context, userId };
}
export function setRoute(route: string) {
context = { ...context, route };
}
export function logEvent(level: LogLevel, event: string, payload: Record<string, unknown> = {}) {
const entry = {
timestamp: new Date().toISOString(),
level,
event,
...context,
...redact(payload),
};
// send asynchronously; never let logging failures break the app
sendToLogBackend(entry).catch(() => {});
}
export function reportError(error: unknown, extra: Record<string, unknown> = {}) {
logEvent("error", "unhandled_error", {
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
...extra,
});
}
function redact(payload: Record<string, unknown>) {
const sensitiveKeys = ["password", "token", "ssn", "cardNumber"];
return
…Code Examples
The boundary catches render-time errors below it, tags the report with the route name it protects, and forwards the full component stack (which shows exactly which nested component tree threw) to the same reportError pipeline used everywhere else in the app — so this error shows up in the same monitoring dashboard as every other error, already carrying the session's correlation ID and user context.
reportError(error, { componentStack: info.componentStack, boundary: this.props.routeName });
Core Web Vitals are reported automatically as the browser measures them, using the same structured logEvent pipeline as everything else. The custom loadDashboardData timer shows the more general pattern: wrap any operation you care about with a performance.now() start/end and log the duration alongside the outcome, whether it succeeded or failed — this is how you catch 'the dashboard is slow for 5% of users' before it becomes a support ticket.
Common mistake: A common mistake is only logging performance on success. Failed requests are often the slowest (they may time out, or fail after a long retry chain) and are exactly the data you need to diagnose a reliability problem — the catch block here logs duration too, not just the success path.
import React from "react";
import { reportError, setRoute } from "./observability";
interface Props { children: React.ReactNode; routeName: string; }
interface State { hasError: boolean; }
class RouteErrorBoundary extends React.Component<Props, State> {
state: State = { hasError: false };
static getDerivedStateFromError(): State {
return { hasError: true };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
setRoute(this.props.routeName);
reportError(error, {
componentStack: info.componentStack,
boundary: this.props.routeName,
});
}
render() {
if (this.state.hasError) {
return (
<div role="alert">
<p>Something went wrong loading this page.</p>
<button onClick={() => this.setState({ hasError: false })}>Try again</button>
</div>
);
}
return this.props.children;
}
}
export default RouteErrorBoundary;import { onLCP, onINP, onCLS } from "web-vitals";
import { logEvent } from "./observability";
// Report Core Web Vitals as they're measured
onLCP((metric) => logEvent("info", "web_vital", { name: "LCP", value: metric.value }));
onINP((metric) => logEvent("info", "web_vital", { name: "INP", value: metric.value }));
onCLS((metric) => logEvent("info", "web_vital", { name: "CLS", value: metric.value }));
// A custom timer around a specific slow operation
async function loadDashboardData(userId: string) {
const start = performance.now();
try {
const res = await fetch(`/api/dashboard?user=${userId}`);
const data = await res.json();
logEvent("info", "dashboard_load", {
durationMs: Math.round(performance.now() - start),
status: res.status,
});
return data;
} catch (error) {
logEvent("error", "dashboard_load_failed", {
durationMs: Math.round(performance.now() - start),
});
throw error;
}
}Correlation IDs Tie One Session's Story Together
Without a shared correlation ID, these events are scattered, unrelated lines across a log aggregator; with one, they reconstruct a single coherent incident timeline.
Browser session (correlationId: 8f2a-...)
│
├─ logEvent("route_change", { route: "/checkout" }) ──────┐
├─ fetch("/api/cart", { headers: { "X-Correlation-Id": .. }})│
├─ logEvent("api_call", { url: "/api/cart", ms: 120 }) │ same correlationId
├─ [error boundary catches TypeError] → reportError(...) │ on every event
└─ logEvent("web_vital", { name: "LCP", value: 2400 }) ────┘
Log backend / error monitor: search by correlationId = 8f2a-...
→ returns every event above, in order, reconstructing exactly
what this one user did right up to the moment it broke.Important Rules
Log structured events (consistent JSON shape), not free-text strings — structure is what makes logs searchable and aggregable later.
Wrap route-level and critical-widget-level UI in React error boundaries, and explicitly try/catch + report errors in async event handlers, since boundaries don't catch those.
Generate a correlation ID per session (or per user action) and attach it to every log line, error report, and outgoing API request so a session's full story can be reconstructed.
Measure real-user performance (Core Web Vitals via web-vitals, plus app-specific timers) in production — a local Lighthouse score does not reflect what actual users on real networks and devices experience.
Upload source maps privately to your error monitoring tool so production stack traces resolve to real file/line/function names, and never serve source maps publicly.
Redact or omit sensitive data (passwords, tokens, full payment details, precise location) from every log and error payload as a default behavior, not a per-call-site decision.
Debugging a Bug You Can't Reproduce
The real test of an observability setup is the ticket that says 'checkout is broken for me' with no other details, from a user you cannot contact for a repro. With correlation IDs, error monitoring, and structured logs in place, you search by their approximate timestamp and route, find their session's correlation ID from an error report, and pull every log line, API call timing, and Core Web Vital tied to that exact ID — reconstructing precisely what they did, what the app did, what the backend returned, and where it went wrong, without ever needing to reproduce it locally. Teams that skip this step end up asking users to 'try again and send a screenshot,' which is slow, unreliable, and simply doesn't scale past a handful of users.
console.log('user clicked checkout', user.email, cart);
logEvent('info', 'checkout_clicked', { userId: user.id, itemCount: cart.items.length });
The first version logs raw PII (a full email address) to the console, where it may end up in browser extensions, screen recordings, or accidentally shipped log aggregation — and it isn't structured, so it can't be searched or aggregated. The second logs a stable internal ID (not PII) and structured, aggregable fields, through the same pipeline as every other event.
try { await submitOrder(); } catch (e) { /* swallow it */ } try { await submitOrder(); } catch (e) { reportError(e, { action: 'submit_order' }); showErrorToast(); }
Silently swallowing an error in an event handler means React's error boundaries never see it (they don't catch handler errors anyway) and neither does your monitoring — the failure is completely invisible in production. Every catch block that isn't a deliberate, documented no-op should report the error and give the user feedback.
Which of the following does a React error boundary NOT catch? A. An error thrown inside an async onClick event handler. B. An error thrown during a component's render method. C. An error thrown in static getDerivedStateFromError's own component during a child's render. D. An error thrown by a lifecycle method like componentDidMount in a child component.
A. An error thrown inside an async onClick event handler. — Error boundaries only catch errors during rendering, lifecycle methods, and constructors of components below them in the tree. Errors thrown inside event handlers (including async ones) are not caught by boundaries and must be handled with an explicit try/catch that reports them separately.
What is the primary purpose of a correlation ID in an observability setup? A. To tie every log line, error report, and API call from one session/action together so they can be searched as a single coherent story. B. To encrypt log payloads before they are sent to the backend. C. To automatically fix errors without developer intervention. D. To replace the need for source maps.
A. To tie every log line, error report, and API call from one session/action together so they can be searched as a single coherent story. — A correlation ID is a shared identifier attached to every event related to one session or action. It lets you search a log aggregator or error monitor for that one ID and reconstruct the full sequence of what happened, instead of trying to manually correlate scattered, timestamp-only log lines.
Why are source maps uploaded privately to an error monitoring tool rather than served publicly from the production domain? A. Source maps let a stack trace resolve back to real file/line/function names, but publicly serving them would expose your original, unminified source code to anyone. B. Source maps are required for the browser to run minified JavaScript at all. C. Source maps make the production bundle smaller. D. Browsers refuse to execute JavaScript that has no associated source map.
A. Source maps let a stack trace resolve back to real file/line/function names, but publicly serving them would expose your original, unminified source code to anyone. — Source maps translate minified production code back to original source for readable stack traces — extremely useful for debugging, but also a way to expose your full original source code if served publicly. Uploading them privately to the error monitoring provider keeps the readability benefit without exposing the source.
Extend the observability.ts module from this lesson with a withTiming helper: a function that takes an event name and an async function, runs it, and logs an info event with the duration on success or an error event with the duration on failure (re-throwing the error afterward so callers can still handle it).
async function withTiming<T>(event: string, fn: () => Promise<T>): Promise<T> { const start = performance.now(); try { const result = await fn(); logEvent('info', event, { durationMs: Math.round(performance.now() - start), status: 'success' }); return result; } catch (error) { logEvent('error', event, { durationMs: Math.round(performance.now() - start), status: 'failure' }); throw error; } }
Use performance.now() before and after calling the passed function inside a try/catch/finally, call logEvent in both the success and catch branches, and throw again in the catch block after logging.