Lesson 20 of 41
Observability for Frontend
Real-user metrics (Web Vitals), error reporting, distributed traces across the client/server boundary, logs, and the honest rules for what to collect and what to leave alone.
A rendered app is a distributed system whose edges you don't fully control. The user is on a device you don't own, a network you don't own, a browser that may be weird, and a geography you may never have tested from. Observability — the collection of practices and tools that lets you see what's actually happening in that system — is how you turn that distribution from invisible into measurable.
This lesson covers the four load-bearing kinds of observability for a frontend: real-user metrics, error reporting, distributed traces, and logging. It also covers the one thing that usually goes wrong: sending the wrong data to the wrong place, at the wrong scale.
The four observability surfaces
Think of frontend observability as four overlapping sets of data, each answering a different question:
- Step 1
Real-user metrics (RUM)
Is my app fast enough for actual users, in actual conditions? Web Vitals (LCP, INP, CLS, TTFB), as a distribution across users.
- Step 2
Error reporting
What's breaking, for whom, how often? Stack traces, browser context, user context (just enough to find the bug), deduplicated.
- Step 3
Distributed traces
What happened across the whole causal chain of one user action? Browser click → Server Action → database query → cache invalidation → re-render. One trace ID ties them together.
- Step 4
Logs
What happened at this specific moment? Scoped, small, sampled on the client; richer on the server. 'Last resort, when a trace or metric isn't enough.'
You usually want all four. Dropping one leaves you blind in a specific way — a system with good errors but no RUM is one where you know when things break but not when they're slow; a system with RUM but no traces is one where you know things are slow but can't tell which backend hop is responsible.
Real-user metrics: measure what Google measures
Lighthouse and WebPageTest give you a lab measurement — one device, one network, one geography. That's useful when iterating on a specific change, but it's one point on a distribution. Real- user monitoring (RUM) collects the same metrics from every real user and reports them as a distribution (median, p75, p95).
The specific metrics that matter for Core Web Vitals are:
- LCP (Largest Contentful Paint) — when the biggest content element paints. Target: ≤2.5s at p75.
- INP (Interaction to Next Paint) — the responsiveness of user interactions. Target: ≤200ms at p75.
- CLS (Cumulative Layout Shift) — how much the layout jumps around during the session. Target: ≤0.1 at p75.
- TTFB (Time to First Byte) — how fast the server responds. Not a Core Web Vital but useful.
The web-vitals library is the standard way to collect these:
import { onCLS, onINP, onLCP, onTTFB } from "web-vitals";
const send = (metric) => {
navigator.sendBeacon("/vitals", JSON.stringify(metric));
};
onLCP(send);
onCLS(send);
onINP(send);
onTTFB(send);The business point: Google's search ranking uses these metrics at the 75th percentile of your real users, not a lab score. A p95 LCP of 8s with a p75 of 1.8s is a passing score. A p75 of 4s — even with a perfect Lighthouse — is a failing one. RUM is the only way to know which bucket you're in.
Error reporting: the shape that scales
Every frontend eventually ships a Sentry, Datadog RUM, or equivalent integration. The design questions that make it useful rather than noisy:
- Deduplicate on the error's shape, not on the specific stack frame. 10,000 users hitting the same bug is one issue, not 10,000.
- Breadcrumbs: the sequence of actions leading to the error (clicks, navigations, fetch calls). More useful than the stack trace alone, in almost every case.
- Source maps: upload the production source maps on every deploy so the minified stack trace is readable. The single most-skipped setup step and the one that most degrades the experience of using the tool.
- User context without PII:
Sentry.setUser({ id: hashedId })— the hashed user ID so you can group by user, nothing more. Email, name, address never go into the error reporter. - Release tagging: every event is tagged with the release it came from. When a regression spikes, you want to see which release introduced it.
What not to capture: form state, full URLs with tokens in query string, full request bodies, the whole DOM, user input that may contain PII. Most error reporters default to capturing some of these; configure the deny-list explicitly.
Distributed traces: the causal chain
The thing that error reporting and RUM alone can't do is show you one user action across every layer it touched. The user clicks Save at 14:37:22. The click fires a React handler, which calls a Server Action, which queries the database, which updates a row, which invalidates a cache, which triggers a re-render.
A trace gives all of those steps a shared trace ID and a hierarchy of spans (parent/child relationships). When something goes wrong at the end of that chain, you can pivot from the failure to the full story that produced it, without guessing which three log lines in three different places belonged together.
The industry standard for emitting traces is OpenTelemetry (OTEL). The frontend half is still maturing but usable; the server half is mature and well-supported. The practical shape:
// server — a Server Action emits a span for the DB call
import { trace } from "@opentelemetry/api";
const tracer = trace.getTracer("my-app");
export async function saveComment(input: CommentInput) {
return tracer.startActiveSpan("saveComment", async (span) => {
try {
const comment = await db.comment.create({ data: input });
span.setAttribute("comment.id", comment.id);
return comment;
} catch (err) {
span.recordException(err);
span.setStatus({ code: 2 }); // ERROR
throw err;
} finally {
span.end();
}
});
}On the frontend, a lighter-weight approach is to emit a span for
each user interaction (a click, a form submit) and propagate its
trace ID in the Server Action / fetch call's headers
(traceparent) so the server can continue the trace. Most OTEL
backends (Honeycomb, Datadog, Grafana Tempo) stitch the trace
together at the backend.
Logs: the frontend-vs-backend asymmetry
Logs are the one observability surface where frontend and backend have very different rules.
Backend logs are cheap. They live on your servers, go into your log aggregator, cost you money but not the user anything. "Log everything" is a viable default; dial back only when it gets expensive.
Frontend logs are not cheap:
- Every log sent is bandwidth the user paid for.
- Every log sent is battery on their device.
- Every log sent is a privacy surface — user data leaving their browser for a server they didn't opt into.
The right default is roughly the opposite of the backend:
- Send errors, not info logs. Info logs stay in the browser console for devs and in RUM for aggregate metrics.
- Sample. If 10,000 users hit a page per minute, don't send 10,000 logs per minute. Send 1%.
- Keep log payloads small and structured. A log is a tag and a counter, not a free-text dump of the UI state.
- Never send raw inputs. User form state, textareas, URL parameters that may contain tokens — none of it belongs in a log sent over the wire.
The most practical shape is a console.log in development
(which costs the user nothing) plus an error-only pipeline in
production (which sends the specific failures you need to see).
The one trap most frontends fall into
The single pattern that most hurts frontend observability in
practice is over-collection. A default-configured RUM tool
captures full HTTP request headers (including Authorization), a
default error reporter captures breadcrumbs with full URLs
(including query strings with tokens), a session replay tool
captures every keystroke (including password fields unless
configured not to), and the end state is a surveillance-grade
telemetry pipeline no one meant to build.
Two concrete habits that prevent it:
- Audit the default collection for every tool you add. Read the "data scrubbing" / "sensitive data" section of the tool's docs. It exists for a reason; defaults are usually too eager.
- Make the data minimum your explicit policy. "We collect user ID (hashed), release, route, browser, error stack, and web vitals. We do not collect full URLs, request bodies, form state, or session tokens." Write it down. Configure the tools to it.
The user's trust and the regulatory cost of a breach are both bigger than the marginal debugging value of the extra field.
What to remember
- Four observability surfaces, each answering a different question: RUM (is my app fast enough for real users?), error reporting (what's breaking?), distributed traces (what happened across the whole chain?), logs (what happened at this moment?). You want all four.
- Lab measurements (Lighthouse) are one point on the distribution; RUM collects the distribution from real users. Core Web Vitals scoring is RUM at p75. Use
web-vitalslibrary +sendBeacon. - Error reporting: dedupe on error shape, capture breadcrumbs, upload source maps, hash user IDs, tag releases. Configure a PII deny-list explicitly.
- Distributed traces tie a user action across every layer it touched into one trace. OpenTelemetry is the standard; propagate trace IDs across Server Actions / fetches via the
traceparentheader. - Frontend logging is not backend logging. Bandwidth, battery, and privacy all cost the user. Send errors not info, sample, keep payloads small and structured, never send raw inputs.
- The one trap: over-collection. Default-configured tools capture more than you meant to collect, including PII and tokens. Audit defaults; write a minimum-collection policy; configure the tools to it.
Check yourself
4 questions · pass 3/4 to unlock Why and When to Split a Frontend
1.What's the practical difference between 'lab data' (a Lighthouse score, a WebPageTest run) and 'real-user monitoring' (RUM)?
2.A user visits a page, clicks a 'Save' button that fires a Server Action, and the save silently fails. What makes a *distributed trace* different from reading three separate logs (browser console, server log, database log)?
3.Which of these is something you should *not* ship to your error reporter (Sentry, Datadog RUM) without scrubbing?
4.What's the single most important rule about frontend logging that distinguishes it from backend logging?
4 left to answer
Discussion
Sign in to postNo comments yet. Be the first to say something.