Observability
Real User Monitoring architecture, error tracking with source maps, and correlating a frontend error with the backend trace that caused it.
Advertisement
Why It Matters
Backend observability is a solved discipline with mature tooling. Frontend observability is harder for a structural reason: your code runs on hardware you do not own, in a browser you did not choose, on a network you cannot measure, and it can fail without ever contacting your server.
A user on a low-end Android phone whose page took eleven seconds and then crashed generates no server-side signal at all. Your metrics look fine. Without deliberate client-side instrumentation, you are blind to the experience you are actually delivering.
Real User Monitoring
RUM measures what real users on real devices actually experienced, as opposed to synthetic monitoring, which runs a scripted check from a chosen location on a chosen device with a warm cache.
Both are useful and they answer different questions. Synthetic is reproducible, so it is a good regression alarm between deploys. RUM captures the real distribution - including the cheap phone on a congested network - which is almost always worse than the lab and is what your users live with. Optimise against RUM; alert on synthetic.
What to collect
- Core Web Vitals - LCP, INP, CLS, gathered from the browser's own performance APIs.
- Navigation timing - TTFB, DNS, TLS, response time. This is what lets you tell a slow server from a slow client.
- Resource timing - which assets were slow, and which came from third parties.
- Custom marks - the milestones that matter to your product. "Search results visible", "checkout form interactive". Generic metrics tell you the page was slow; custom marks tell you which part of the experience was.
- Context - device, connection type, viewport, route, release version. Without this, an aggregate number is unactionable, because the aggregate hides the population that is actually suffering.
Getting the data out without becoming the problem
Monitoring that degrades performance is self-defeating, and the mechanics matter.
Diagram100%flowchart LR subgraph Client["Browser"] API["Performance APIs<br/>web-vitals, PerformanceObserver"] Marks["Custom marks<br/>product milestones"] Ctx["Context<br/>device, connection, route, release"] API --> Buf["In-memory buffer"] Marks --> Buf Ctx --> Buf Buf --> Sample{"Sampled in?"} Sample -->|"no"| Drop["Discard"] Sample -->|"yes"| Scrub["Scrub PII<br/>redact by key pattern"] Scrub --> Flush{"Flush trigger"} end Flush -->|"visibilitychange to hidden"| Beacon["sendBeacon /<br/>fetch keepalive"] Flush -->|"buffer full"| Beacon Flush -->|"interval elapsed"| Beacon Beacon --> Ingest["Ingestion endpoint"] Ingest --> Store[("Time-series store<br/>tagged by release")] Store --> Dash["Dashboards - p75, p95<br/>sliced by device and route"] Store --> Alert["Alerting on<br/>regression by release"] style Client fill:#0f172a,stroke:#64748b style Beacon fill:#1e3a5f,stroke:#3b82f6 style Store fill:#1e3f2d,stroke:#22c55evisualized by
Buffer, do not send per event. One request with fifty measurements, not fifty requests.
Use sendBeacon or fetch with keepalive. A normal request is cancelled when the page unloads - which is precisely when you most need the data to leave. These are designed to survive it.
Flush on visibilitychange to hidden, not on unload. unload does not fire reliably on mobile when the OS kills a backgrounded tab, and a lot of teams lose their most interesting sessions to this.
Sample. You rarely need every session. A percentage gives you the same distribution at a fraction of the ingestion cost - though errors are usually worth sampling at a much higher rate than performance data.
Report percentiles, not averages. An average LCP of 2.1s can hide a p95 of nine seconds. The p95 is the experience of the users most likely to leave, and averages systematically hide the population you most need to see.
Error Tracking
Capturing everything
Two global handlers catch what component-level boundaries miss:
window.onerror/ theerrorevent - uncaught synchronous exceptions.unhandledrejection- rejected promises with no.catch(). This one is frequently forgotten and, in an async-heavy codebase, is where most silent failures actually live.
Framework error boundaries sit on top of these. They let you render a recovery UI, but they do not replace the global handlers - an error in an event handler or a timer never passes through a boundary at all.
Source maps
Production JavaScript is minified, so a raw production stack trace is a.b.c is not a function at main.f8a3.js:1:48221 - which tells you nothing.
The fix is to upload source maps to your error tracking service at build time, tagged with the same release identifier you stamp on the client bundle. The service de-minifies incoming traces server-side by matching release to map.
Two things go wrong reliably:
Publicly served source maps are source disclosure. They contain your original code. Upload them to the tracker and remove them from the deployed output - do not ship them to a public URL and call it done.
Release identifier mismatch. If the map is uploaded under a different version than the client reports, every trace stays minified while the upload appears to have succeeded. Deriving the release from a single source - the commit SHA - and using it in both places is the fix.
Logging from Client to Backend
What must never be logged
Client-side telemetry leaves your infrastructure and lands in a third-party system with its own retention policy and access model. That raises the stakes on what goes into it.
Never send: authentication tokens, passwords, payment details, or personal information - email addresses, names, precise location, anything that identifies a person.
This is harder than it sounds because the leaks are almost never deliberate:
- A form object logged wholesale on submit contains the password field.
- A URL logged for context contains a reset token in the query string.
- An error object serialised in full contains the request body.
- A session replay tool captures whatever the user typed into an unmasked field.
The architectural answer is a scrubbing layer inside the reporting client that redacts by key pattern - password, token, authorization, card, secret, email - before anything is queued. Redaction cannot be left to whoever writes the individual log line, because eventually one of them will not.
Structured, not string-formatted
Log objects, not sentences. logger.warn({ event: 'checkout_retry', attempt: 2, reason: 'timeout' }) is queryable and aggregatable. logger.warn('Retrying checkout, attempt 2 because timeout') requires someone to write a regex later.
Keep a consistent envelope on every entry: level, event name, release, route, session ID, correlation ID, timestamp.
Correlation IDs
The payoff. A user reports "checkout failed" - and you need to get from that to the failing database query.
Diagram100%sequenceDiagram participant U as User participant C as Client participant BFF as BFF / API participant S as Payment service participant O as Observability U->>C: Clicks "Pay" C->>C: Generate correlationId = req_9f2a C->>BFF: POST /checkout<br/>X-Correlation-ID: req_9f2a BFF->>BFF: Attach req_9f2a to every log + span BFF->>S: charge()<br/>X-Correlation-ID: req_9f2a S->>S: DB timeout - logs with req_9f2a S-->>BFF: 500 BFF-->>C: 500 C->>O: Error report<br/>correlationId: req_9f2a<br/>sessionId, release, route BFF->>O: Server logs + trace, req_9f2a S->>O: Service logs, req_9f2a Note over O: One ID joins the user's session,<br/>the frontend error, and the failing queryvisualized by
The client generates an ID per request, sends it in a header, and attaches the same ID to any error it reports. The backend stamps it on every log line and span it produces for that request, and propagates it through every internal hop.
Two details decide whether this works: the ID must be per request, not per session, or you cannot distinguish which of forty calls failed. And every service must propagate the header - one service that drops it ends the trace there, which is the most common reason a correlation setup silently underdelivers.
Tradeoffs
| Option | Pros | Cons | When to Use |
|---|---|---|---|
| RUM | Measures the real user distribution, catches device and network effects a lab never sees | Noisy, needs sampling, costs ingestion, no reproducible baseline | Any product where real-world performance matters |
| Synthetic monitoring | Reproducible, isolates regressions between deploys, works pre-launch | Measures one artificial environment, systematically optimistic | Regression alarms and pre-production gates |
| Full session capture | Complete picture, every session available for investigation | Expensive at volume, heavier client, larger privacy surface | Low-traffic products, or short debugging windows |
| Sampled telemetry | Same distribution at a fraction of the cost, lighter on the client | Rare issues may never be captured | Default at any real traffic volume |
| Source maps uploaded to the tracker | Readable production traces, source stays private | Needs matching release IDs, silently fails when they drift | Always |
| Publicly served source maps | Trivial to set up | Discloses your source code to anyone | Never |
| Correlation IDs | One ID joins client error to backend trace, turns reports into root causes | Every service must propagate the header, needs cross-team agreement | Anything with more than one service behind it |
Where This Applies
The metrics collected here are the ones defined in Performance Engineering - that article explains what LCP, INP, and CLS mean; this one covers measuring them on real users. The rule against logging tokens depends directly on the storage decision made in Security Architecture, and CSP violation reports are another telemetry stream flowing into this same pipeline. Correlation IDs cross the boundary described in Application Architecture at Scale, and a BFF is usually where they are first stamped server-side.
In the applied practice problems, this underpins Video Player, where startup time and stall duration exist nowhere except the client, and File Upload System, where partial failures need enough context to resume rather than restart. The permission funnel in Notification System is another metric with no server-side equivalent.
Advertisement
Why does synthetic monitoring disagree with your Real User Monitoring numbers, and which should you trust?
Because they measure different populations. Synthetic runs a scripted check from a chosen location on a chosen device with a warm cache and a stable network - it is reproducible and therefore good for catching regressions between deploys. RUM measures the actual distribution of your users, including cheap Android devices on congested mobile networks, cold caches, and browser extensions injecting scripts. That distribution is almost always worse than the lab, and it is what your users experience, so RUM is what you optimise against. Synthetic is a regression alarm, not a truth source.
How do you send RUM data without the monitoring itself hurting performance?
Three rules. Buffer rather than sending per event, so you make one request instead of fifty. Send with sendBeacon or fetch with keepalive rather than a normal request, because a normal request is cancelled when the page unloads - which is exactly when you most need the data to leave. And flush on visibilitychange to hidden rather than on unload, because unload does not fire reliably on mobile when the OS kills a backgrounded tab. On top of that, sample - you almost never need every session, and a percentage of traffic gives you the same distribution at a fraction of the cost.
Your production stack traces are unreadable minified nonsense. Explain the fix and its main risk.
Upload source maps to the error tracking service at build time, tagged with the same release identifier you stamp on the client bundle. The service then de-minifies incoming stack traces server-side by matching release to map. The main risk is that source maps expose your original source code, so they must not be publicly served - upload them to the tracker and delete them from the deployed output, or the fix becomes a source disclosure. The second common failure is a release identifier mismatch, where the map is uploaded under a different version than the client reports, and every trace stays minified while the upload appears to have worked.
How do you connect a frontend error to the backend request that caused it?
Generate a correlation ID on the client for each request, send it in a header, and have the backend attach it to every log line and span it produces for that request. The client attaches the same ID to any error it reports. Now one identifier links the user's session, the frontend error, and the full server-side trace, so you can move from a user report straight to the failing query. The important detail is that the ID must be generated per request rather than per session, and it must propagate through every internal service hop - if a service drops the header, the trace ends there.
What should never be logged client-side, and why is this harder than it sounds?
Never log tokens, passwords, payment details, or personal information like email addresses, names, and precise location - client-side telemetry leaves your infrastructure and lands in a third-party system with its own retention and access model. It is harder than it sounds because the leaks are almost never deliberate. A full form object logged on submit contains the password field. A URL logged for context contains a reset token in the query string. An error object serialised wholesale contains the request body. A DOM snapshot from a session replay tool captures whatever the user typed. The architectural answer is a scrubbing layer in the reporting client that redacts by key pattern before anything is sent, so redaction is not left to whoever writes the log line.