We put OpenTelemetry in a React app. Here's what broke first.
Everyone wants observability. Nobody wants to write the redaction pipeline.
Two months later, that joke was also the project retrospective. We were moving a fintech React SPA off vendor RUM onto OpenTelemetry—traces to Tempo, logs to Loki, Grafana for investigation. On paper: add a browser SDK, create spans, send OTLP. In practice, the browser is where every awkward detail meets: React lifecycle, Redux, Apollo, sagas, feature flags, user data, and a tab that stays open all day.
(I paired with Codex and Claude through this. One wrote the instrumentation; the other reviewed with the energy of someone personally offended by every loose timer and unredacted URL.)
TL;DR
- Redact before you emit, not after.
- A feature flag must stop all telemetry work, not just exporting.
- Clean up patches, listeners, interceptors, and timers on every lifecycle change.
- Cap text, cache static decisions, and lazy-load the SDK.
- Put cardinality bounds on every in-memory map from day one.
This is not a "set up OTel in React in ten minutes" guide. Gigapipe's guide and Grafana Faro will get you started. This is what happened after the demo worked.
What we were trying to see
For payments and reconciliation, we needed to answer four questions when someone said "this page is slow" or "that button did nothing": what started it, which requests fired, did the backend see the same trace, and what broke—without shipping a token or customer input to our logs.
A click starts a root span. HTTP calls, GraphQL operations, and Redux beneath it inherit that context. traceparent, tracestate, session.id, and event.id travel with eligible requests so we can move from "this click was slow" to the backend trace that explains why.
The existing OTel browser libraries cover fetch, XHR, and navigation out of the box. They do not cover Redux, Apollo, or sagas. So most of the instrumentation had to be written by hand.
That part worked early. Then the reviews started.
Redaction was the project
We treated it as the last 10%: wire up spans, then add a couple of replacements before production. That was backwards. Every review round found another escape route: a JWT in a copied URL, target.value from a change event, GraphQL variables with entity IDs, trace headers leaking to another origin, a stack trace large enough to turn one bad exception into an oversized log.
The fix was simple: one shared redaction pipeline. sanitizeErrorMessage, sanitizeStack, and redactSensitiveUrl run before data reaches every sink—spans, raw logs, Apollo errors, and saga errors.
That "one chokepoint" decision matters more than the regexes. If it bypasses the pipeline, it is not ready to emit telemetry.
React lifecycle makes duplicate telemetry easy
The first version worked perfectly until it didn't. StrictMode mounts effects twice. Flags change. A user can log out and back in without a full reload. Each event is an opportunity to leave something behind: a window.fetch wrapper that never gets restored, an Axios interceptor registered twice, a timer that survives the component that created it, event listeners that keep recording after telemetry is disabled.
We stopped treating shutdownOtel() as "best-effort cleanup" and made it a real state transition: Promise<'completed' | 'cancelled' | 'noop'>, never a rejected cleanup promise.
One subtle detail saved us later. When shutting down, we restore window.fetch to the original function we captured during startup—not whatever is assigned at shutdown. During migration, another observability tool can wrap it after you do. Restore the wrong thing and you can quietly remove someone else's wrapper or create duplicate spans that only appear after hours of uptime.
Telemetry is hot-path code
Telemetry gets called "glue code," which makes it easy to wave away its cost. But it runs during clicks, navigation, requests, and Redux dispatches—the exact moments when users are already waiting.
We found ordinary mistakes: a Redux middleware starting a new timer for every dispatched action, UI text heading to Loki without a length cap, the OTel SDK living in the main bundle, endpoint skip checks recomputed for every request.
None needed a clever fix. We use one sweep timer per action type, truncate UI text to 200 characters, dynamically import the SDK (about 68 KB off the critical path), and cache static endpoint-prefix decisions.
Instrument first if you must, but profile the instrumentation too.
A long-lived SPA needs memory limits everywhere
Operations users leave a tab open all day. A map that grows once per interaction is not "temporary state"—it is a slow memory leak with a nice name.
We keep bounded FIFO caches for completed interaction contexts (200 entries) and put limits around pending request maps and open spans. The alternative was waiting until a tab used 400 MB late in the afternoon and then trying to recreate the exact sequence of clicks that got us there.
Decide your cardinality budget while the code is small.
The checklist we should have started with
If I were starting again, every new telemetry source would need four answers before any code was written:
- Redaction rule: What could contain PII, a secret, or a token? Where is it scrubbed?
- Flag or environment gate: Does turning this off stop all work, not just exporting?
- Teardown path: What happens on unmount, StrictMode remount, logout, and reinitialisation?
- Cardinality budget: How many of these can exist at once, and what happens when that limit is reached?
Each item above caused at least one follow-up review fix. Writing those four answers first would have saved a surprising amount of churn.
Would I do it again?
Yes—but I would start with the safety boundaries, not the exporter.
We did not recreate every RUM feature from the previous tool. There is no session replay or funnel analytics yet. But for "what broke, for whom, and what did the trace look like?" the result is useful, owned by us, and easy to follow from the browser into the backend.
That is a pretty good first version. Just don't let anyone tell you the hard part is adding the SDK.
Further reading
- Instrumenting a React app with OpenTelemetry + qryn — a concise introduction to
sdk-trace-web, OTLP, and auto-instrumentation. - Grafana Faro — a maintained RUM SDK with OTel-JS trace export. If you do not need deeply custom Redux, Apollo, and saga instrumentation, it may save you a lot of plumbing.