Browser RUM & session replay
Updated Jul 28, 2026 · 1d ago
Two independent snippets, and you can ship either one alone. OpenTelemetry Web gives you Core Web Vitals, page-load and interaction spans, and frontend errors. Session replay records the DOM with rrweb and stitches each recording to the backend trace — so from a failing trace you jump straight to a video of the user who hit it.
Time to first session: ~10 min · Trial: 14 days, no card · API key: app.getepok.dev → Settings → API Keys
Read this first: the key ships to the browser
Everything on this page runs in your users' browsers, which means the API key is public. There is no way around that — it is true of every RUM product. Use a restricted, ingest-only key from Settings → API Keys, never a full-access one. Ingest-only keys can write telemetry and nothing else, and they are per-tenant rate-limited.
Two consequences worth knowing before you paste: replay data counts against your daily ingest volume like any other signal, and a leaked ingest key can be used to send you junk telemetry. Rotate it from the same screen if that ever happens.
1. Core Web Vitals and frontend errors
There is no separate “RUM” protocol. Browser telemetry rides the same OTLP endpoints as your backend: browser spans go to /v1/traces, web-vitals go to /v1/metrics. If you already send backend traces to Epok, this is the same endpoint and the same kind of key.
// Browser RUM via OpenTelemetry Web. Install:
// npm i @opentelemetry/sdk-trace-web @opentelemetry/exporter-trace-otlp-http
import { WebTracerProvider, BatchSpanProcessor } from '@opentelemetry/sdk-trace-web';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
// Pass processors to the CONSTRUCTOR. provider.addSpanProcessor() was removed in
// SDK 2.x, so a fresh npm install throws on it; this form works on 1.24+ and 2.x.
const provider = new WebTracerProvider({
spanProcessors: [new BatchSpanProcessor(new OTLPTraceExporter({
url: 'https://ingest.getepok.dev/v1/traces',
headers: { 'x-api-key': 'YOUR_INGEST_KEY' }, // ingest-only key — this ships to the browser
}))],
});
provider.register();
// Page-load + interaction spans now stream to Epok — see them in RUM.Gotcha worth 20 minutes of your life: the browser trace exporter above sends JSON, which every build of the metrics endpoint accepts, so the direct path just works. If you route browser telemetry through an OpenTelemetry Collector, set encoding: json on the otlphttp exporter. A stock collector defaults to protobuf instead — newer builds decode that too, but older ones reject it, and the rejection is quiet in exactly the wrong way: traces and logs keep flowing while web-vitals alone are dropped, so it reads as a missing-metrics problem rather than an encoding one. encoding: json is correct against every build, so setting it removes the question.
2. Session replay
Replay is a separate path — rrweb captures DOM mutations and the SDK posts gzipped event chunks to POST /v1/rum/replay. The snippet below is self-contained: no build step, no npm install, paste it before </body>. It is the same snippet the in-app connect flow hands you.
<!-- Epok Session Replay — paste before </body>. Inputs are masked (no typed
values recorded). Each recording is STITCHED to the backend trace via a W3C
traceparent, so from a broken trace you jump straight to the user who hit it.
Uses an ingest-only key (safe to expose in the browser). -->
<script type="module">
import { record } from 'https://cdn.jsdelivr.net/npm/rrweb@2.0.0-alpha.4/+esm';
const KEY = 'YOUR_INGEST_KEY';
const URL = 'https://ingest.getepok.dev/v1/rum/replay';
const sid = (crypto.randomUUID && crypto.randomUUID()) ||
(Date.now() + '-' + Math.random().toString(36).slice(2));
const hex = (n) => Array.from(crypto.getRandomValues(new Uint8Array(n)),
(b) => b.toString(16).padStart(2, '0')).join('');
let buf = [], chunk = 0, hadError = false;
// Per-CHUNK trace linkage. The server keys the "watch the user hit THIS trace"
// pivot off the chunk's trace_id (error chunk preferred), so we tag each chunk
// with a trace_id seen in its window — an errored request's if any, else the
// most recent. Reset every flush so a chunk reflects its own window.
let winTrace = null, winError = false;
// Propagate W3C traceparent so the backend continues the SAME trace_id — that
// shared id is the stitch. If something already set traceparent (e.g. an OTel
// web SDK you also run), REUSE its trace_id instead of overriding, so the two
// coexist and still stitch. Standalone (no OTel), we generate it.
const _fetch = window.fetch.bind(window);
window.fetch = async (input, init) => {
init = init || {};
const headers = new Headers(init.headers ||
(typeof Request !== 'undefined' && input instanceof Request ? input.headers : undefined));
let traceId = (headers.get('traceparent') || '').split('-')[1];
if (!traceId || traceId.length !== 32) {
traceId = hex(16);
headers.set('traceparent', '00-' + traceId + '-' + hex(8) + '-01');
}
try {
const res = await _fetch(input, { ...init, headers });
if (!res.ok) { winTrace = traceId; winError = hadError = true; } // 4xx/5xx
else if (!winError) { winTrace = traceId; }
return res;
} catch (e) { winTrace = traceId; winError = hadError = true; throw e; }
};
record({
emit: (e) => buf.push(e),
maskAllInputs: true, // never record what users type
sampling: { mousemove: 200, scroll: 200 },
});
addEventListener('error', () => { hadError = winError = true; });
addEventListener('unhandledrejection', () => { hadError = winError = true; });
async function flush(final) {
if (!buf.length) return;
const events = buf; buf = [];
const trace_id = winTrace, has_error = winError;
winTrace = null; winError = false; // next chunk gets its own window
const json = JSON.stringify({ session_id: sid, chunk_index: chunk++,
events, page_url: location.href, trace_id, has_error });
let body = json, headers = { 'content-type': 'application/json', 'x-api-key': KEY };
try { // gzip when the browser supports it
const cs = new CompressionStream('gzip');
body = await new Response(new Blob([json]).stream().pipeThrough(cs)).arrayBuffer();
headers['content-encoding'] = 'gzip';
} catch (_) { /* fall back to raw JSON */ }
_fetch(URL, { method: 'POST', keepalive: true, headers, body }).catch(() => {});
}
setInterval(() => flush(false), 5000);
addEventListener('visibilitychange', () => { if (document.hidden) flush(true); });
addEventListener('pagehide', () => flush(true));
</script>Privacy default: maskAllInputs: true means no typed value is ever recorded — passwords, card numbers, and search boxes are all masked at capture time, before anything leaves the browser. Loosen it deliberately in the record() options if you need to, not by accident.
What the trace stitch buys you
The snippet wraps window.fetch to propagate a W3C traceparent, so your backend continues the same trace id the browser started. That shared id is the whole trick: it is what lets a 500 in your checkout service link to a replay of the person who hit it.
If you already run an OTel Web SDK that sets traceparent, the snippet reuses its trace id rather than overriding it, so the two coexist. Chunks that saw a 4xx/5xx are preferred when picking which trace a recording links to — you want the broken request, not the last one.
You have succeeded when you see this
Load a page on your site, click around for about ten seconds, then leave the page — replay flushes on a 5-second timer and again on page-hide, so navigating away is what makes the last chunk land immediately instead of five seconds later. Then check all three:
- Signals → RUM shows a row for the page you just loaded. LCP, CLS and INP fill in after the first few page loads, not the first one — one visit is not enough to grade a vital.
- Signals → Replay lists a session with your page URL, and it plays back. Scrub to a text input: it should be masked. If you can read what you typed,
maskAllInputsis not set and you should fix that before shipping to real users. - The stitch holds. This is the one that matters and the one most likely to be quietly missing. Open a trace in APM → Traces that came from a browser request and click Watch replay on the waterfall. Follow it — do not just look for it. That link is offered on every selected trace whether or not a recording exists, so its presence proves nothing; what proves the stitch is landing on a session that plays at that moment instead of coming up empty.
One and two working while three is missing is a working install with the best part switched off: you have recordings and you have traces, but you cannot get from a broken request to the person who hit it. The “sessions appear, but the replay link lands on nothing” case below is almost always why.
Nothing showed up
- 503 on
/v1/rum/replay— session replay is not enabled on your plan. This is a plan gate, not a bug; RUM web vitals still work. Check pricing or ask us. - 403 — a key arrived and was rejected: wrong, rotated, or missing the
ingestscope. Confirm you copied an ingest-only key. - 401 — no key was found at all, which is a header problem rather than a key problem.
x-api-keyis read on both paths on this page and is the one to use.Authorization: Bearerworks on both as well;Authorization: Tokenis accepted by/v1/rum/replaybut not by/v1/traces, so a key that posts replay chunks happily can still 401 your browser spans. The full carrier matrix is in Authentication. - CORS error in the browser console — you are posting to the wrong host. Browser telemetry goes to
ingest.getepok.dev, notapp.getepok.dev. - Requests succeed but Replay is empty — the usual cause is a Content Security Policy blocking the rrweb import from jsDelivr. Check the console for a CSP violation and add the CDN to
script-src, or vendor rrweb into your own bundle. - Sessions appear, but the replay link lands on nothing — your frontend calls are not going through
window.fetch. Code usingXMLHttpRequestor axios' XHR adapter bypasses the wrapper, so chunks are stored with no trace id attached; the recording still works, it just has nothing to stitch to.
What you don't need
No collector. No separate RUM ingest key. No build-step integration for replay — the snippet is plain ES modules. And you do not need backend tracing for either path to work; the stitch is a bonus that switches on by itself once both sides are sending.
Next
- Upload your source maps — do this one next. Right now every frontend error you just started collecting points at
main.4f2a.js:1:28471. Source maps turn that into a file and a line number, which is the difference between a captured error and a fixable one. It is the direct completion of this path. - Instrument your backend for tracing — this is what turns the replay stitch on. Without it, check three above will never pass.
- Limits — worth reading before you roll replay out past a test page. Recordings count against your daily ingest volume like any other signal, and a chatty page is not a small one.
- Send your logs — frontend errors get a lot more useful next to the server logs from the same request.