Overview
Osuite Real User Monitoring (RUM) instruments your frontend the same way the rest of Osuite instruments your backend: as OpenTelemetry spans, logs, and resource attributes. Browser activity arrives as OTLP traces and logs alongside your services, and every browser fetch propagates W3C traceparent and baggage headers so a user’s click stitches into the same trace as the backend services it triggered.
In addition to spans and logs, the RUM SDK records full session replay (powered by rrweb) and uploads it as gzipped chunks. Each replay is linked to its session, its user, and any errors that fired during the visit, so a slow click in production lands you on the exact session, the exact stack trace, and the exact backend trace it produced — in one view.
The browser SDK is published as @osuite/rum. A separate React subpath @osuite/rum/react provides an error boundary without pulling React into the baseline bundle.
How RUM works in Osuite
The SDK runs once on page load and emits three streams:
| Stream | Transport | What it carries |
|---|---|---|
| Spans | OTLP /v1/traces | documentLoad, documentFetch, resourceFetch, HTTP {METHOD}, route_change, user_interaction.* |
| Log records | OTLP /v1/logs | session.start, session.end, user.changed, sampling.upgraded, browser.error, browser.resource_error, browser.unhandled_rejection, browser.react_error, browser.console_*, browser.manual, replay.chunk |
| Replay chunks | S3 presigned PUT | Gzipped rrweb event blobs, plus a replay.chunk pointer log so they are queryable from the session log stream |
Every span and log carries session.id, user.id? (when set), service.name, service.version, service.environment, and browser/connection metadata. That shared schema is what lets RUM data join your backend traces and logs without any extra correlation step.
Install
npm install @osuite/rum
# or
pnpm add @osuite/rum
React is declared as an optional peer dependency, so if your app already uses React you can import the error boundary from @osuite/rum/react without any extra install. The baseline bundle does not pull React in.
Quick start
import { osuite } from '@osuite/rum';
osuite.init({
apiKey: 'pk_live_...', // or OSUITE_INGEST_TOKEN env
endpoint: 'https://ingest.us-east-1.osuite.io', // or OSUITE_INGEST_ENDPOINT env
apiEndpoint: 'https://api.us-east-1.osuite.io', // or OSUITE_API_ENDPOINT env
service: {
name: 'frontend-web',
version: process.env.NEXT_PUBLIC_BUILD_SHA ?? 'dev',
environment: process.env.NEXT_PUBLIC_ENV ?? 'development',
},
sampling: {
sessionSampleRate: 1.0, // sample 100% of sessions
},
replay: {
maskAllText: false,
maskAllInputs: true,
},
});
osuite.setUser({ id: 'u_123', plan: 'pro' });
try {
await doWork();
} catch (err) {
osuite.captureException(err, { extra: { feature: 'checkout' } });
}
That is enough to get traces (page loads, fetch/XHR, route changes, clicks), error logs, and full session replay flowing.
What you can do with RUM
End-to-end trace correlation
The SDK injects W3C traceparent and baggage headers on outgoing fetch and XHR requests. The backend OTel SDK reads them, links its server span as a child of the browser span, and the resulting trace contains both halves of the request — frontend and backend — in one waterfall.
session.id and user.id ride along in baggage so backend logs and traces emitted during that request are also attributable to the originating session. From a backend error, you can pivot to the exact session that produced it; from a session, you can pivot to every backend trace it triggered.
By default propagation is same-origin only — the SDK refuses to send trace headers to third-party APIs, analytics, or ad pixels. You can extend it with an explicit allowlist (see Propagation).
Session replay
rrweb captures the DOM, every interaction, scroll, and route change. Recording is on by default. Replays are stitched per session and linked to the spans, logs, and errors that occurred during the visit.
Replay masking is strict by default:
- All text nodes render as
***in the replay viewer - All
<input>,<textarea>, and<select>values are masked - Whole regions can be replaced with a placeholder using
class="osuite-block" - Events on entire elements can be excluded with
class="osuite-ignore" - Per-element opt-back-in for safe text uses
class="osuite-unmask"
The replay engine (rrweb + pako, ~40KB gzip) is loaded via dynamic import() only when recording starts, so users on unsampled sessions never download it. The baseline SDK is under 15KB gzip.
Frontend errors
The SDK listens on every uncaught error source by default:
window.errorfor runtime errorsunhandledrejectionfor unhandled Promise rejectionswindow.errorfor failed<img>/<script>/<link>/<audio>/<video>/<source>loads- The optional
OsuiteErrorBoundaryfor React render crashes
Every error is emitted as a log record with full stack trace, the browser/route/user context at the time, and a stable event.name (browser.error, browser.unhandled_rejection, browser.resource_error, browser.react_error). Errors group on stack-trace fingerprint so you can see frequency, browsers affected, and trend direction. From an error group, you can jump to the matching session replay or the backend trace that the request produced.
Real user performance
Page load timing is captured from the Navigation Timing API as documentLoad, documentFetch, and resourceFetch spans (via the OpenTelemetry document-load instrumentation), and every fetch/XHR becomes an HTTP {METHOD} span — so you get real, per-session load and request latency rather than synthetic averages.
SPA route changes are captured as route_change spans that automatically adopt the fetch and XHR calls fired during navigation, so you see the true settling time of every page transition rather than a synthetic “navigation start” timestamp. The route is declared settled when the DOM is quiet for 500 ms (5 s hard cap).
Optional UX-friction signals — rage_click, dead_click, scroll_depth — are off by default and switch on per config flag.
Core Web Vitals (LCP, INP, CLS, FCP) are not emitted in v1 and are planned for v2. Until then, use
documentLoadtiming attributes andHTTP {METHOD}span durations as the primary performance signals.
Sampling with error-triggered upgrade
The default is sessionSampleRate: 1.0 — sample every session. As traffic grows you typically lower it (e.g. 0.1 for 10% of sessions).
Lowering the rate does not mean losing the broken sessions. With upgradeOnError: true (the default), unsampled sessions still buffer recent activity in memory; the moment an error fires, the session is upgraded to sampled and the buffered context is flushed. So you keep the spend predictable without losing the sessions that actually matter.
Even when a session is unsampled, lifecycle and error log records always export so the backend can still see session boundaries and errors: session.start, session.end, user.changed, sampling.upgraded, browser.error, browser.resource_error, browser.unhandled_rejection, browser.react_error, replay.chunk, plus any record with severity ERROR or higher.
SDK reference
Configuration
Everything beyond apiKey / endpoint / apiEndpoint / service is optional. Defaults are tuned for a typical SaaS frontend.
Required
| Field | What it does | Notes |
|---|---|---|
apiKey | Public ingest token sent on every OTLP request, also used to mint session JWTs for replay uploads. | Falls back to OSUITE_INGEST_TOKEN. Public — bundled in your JS. Backend rate-limits per key. |
endpoint | Base URL for OTLP traces and logs. The SDK posts to ${endpoint}/v1/traces and ${endpoint}/v1/logs. | Falls back to OSUITE_INGEST_ENDPOINT. |
apiEndpoint | Base URL for the Osuite control plane (replay presign + session JWT). The SDK posts to ${apiEndpoint}/rum/session/init and ${apiEndpoint}/rum/replay-chunk/presign. | Falls back to OSUITE_API_ENDPOINT. Different host from endpoint because replay blobs go to object storage, not OTLP. |
service.name | Identifies the frontend in your traces (e.g. frontend-web, admin-app). | Required. |
service.version | Build id — git SHA, tag, or semver. Used for source-map matching. | Required. |
service.environment | production, staging, development, etc. | Required — backend filters and dashboards key off this. |
Session
A session is a continuous user visit. A new one starts after a long idle, after the hard cap, or when you change the user identity.
session: {
inactivityTimeoutMs: 30 * 60_000, // 30 min — default
hardCapMs: 3 * 60 * 60_000, // 3 h — default
}
| Field | Default | What it does |
|---|---|---|
inactivityTimeoutMs | 1_800_000 (30 min) | Idle time before the session rotates. Activity = clicks, keystrokes, scrolls, fetches, tab focus. |
hardCapMs | 10_800_000 (3 h) | Maximum session length regardless of activity. Prevents week-long zombie sessions. |
The session id is stored in localStorage and shared across tabs via BroadcastChannel. Calling setUser / clearUser rotates the session immediately so a logged-in session is never conflated with the pre-login one.
Sampling
Head-based session sampling for traces and logs, with an error-triggered upgrade safety net.
sampling: {
sessionSampleRate: 1.0, // sample 100% of sessions — default
upgradeOnError: true, // promote unsampled sessions to sampled on first error
preSampleBuffer: {
maxItems: 500, // keep up to 500 spans+logs per unsampled session
maxAgeMs: 60_000, // ...or 60s of recent activity, whichever is smaller
},
}
| Field | Default | What it does |
|---|---|---|
sessionSampleRate | 1.0 | Probability that a session is “sampled” (everything exports immediately). Range [0, 1]. Decided once per session. Set to 0.1 to sample 10% of sessions. |
upgradeOnError | true | When true, an unsampled session is upgraded to sampled the first time an error log fires (window.error, unhandledrejection, React error, captureException, captureMessage('fatal')). Buffered spans/logs drain immediately and exporting stays on for the rest of the session. |
preSampleBuffer.maxItems | 500 | Hard cap on items held for an unsampled session. Older items evicted FIFO. |
preSampleBuffer.maxAgeMs | 60_000 | Items older than this are dropped on each push. |
Replay
rrweb session replay. Privacy defaults are strict — all text and inputs are masked unless you opt in per element.
replay: {
mode: 'always', // default
sampleRate: 0.1, // when mode='probabilistic', record 10% of sessions
bufferSeconds: 30,
trailingSeconds: 15,
maxBufferMB: 50,
maskAllText: true,
maskAllInputs: true,
blockClass: 'osuite-block',
ignoreClass: 'osuite-ignore',
unmaskClass: 'osuite-unmask',
uploadOnSessionEnd: false,
}
| Field | Default | What it does |
|---|---|---|
mode | 'always' | always: record + upload everything. probabilistic: roll once per session at sampleRate; sampled sessions behave like always, others don’t record. on-error / hybrid: planned — currently fall back to always with a warning. |
sampleRate | 0.1 | Used only by probabilistic and (when shipped) hybrid. Range [0, 1]. |
bufferSeconds | 30 | Rolling-window length used by on-error / hybrid (planned). |
trailingSeconds | 15 | How long to keep uploading after a trigger fires (planned). |
maxBufferMB | 50 | Per-session cap on IndexedDB usage. Oldest non-uploaded chunks evicted first. |
maskAllText | true | All text nodes recorded as ***. Opt back in per element with class="osuite-unmask". |
maskAllInputs | true | All <input> / <textarea> / <select> values masked. |
blockClass | 'osuite-block' | Elements with this class become a placeholder in replay (e.g. payment forms). |
ignoreClass | 'osuite-ignore' | Events on these elements aren’t recorded at all. |
unmaskClass | 'osuite-unmask' | Per-element opt-out of maskAllText. |
uploadOnSessionEnd | false | When true, force-flush the rolling buffer when the session rotates. |
HTML annotations:
<!-- Don't mask this text -->
<span class="osuite-unmask">Hello, Jane</span>
<!-- Replace with placeholder in replay -->
<div class="osuite-block">
<CreditCardForm />
</div>
<!-- Don't record events on this element at all -->
<button class="osuite-ignore">Internal debug button</button>
Interactions
User-action spans. click is always on; richer detection is opt-in.
interactions: {
level: 'standard', // default
rageClicks: true,
deadClicks: false,
scrollDepth: { thresholds: [50, 100] },
}
| Field | Default | What it does |
|---|---|---|
level | 'standard' | 'minimal' = clicks only. 'standard' = clicks + form submits + input focus/blur + visibility change. |
rageClicks | false | When true (or an object), emits user_interaction.rage_click when 3 clicks happen within 1 s in a 20 px radius. Tunable: {clickCount, windowMs, radiusPx}. |
deadClicks | false | Emits user_interaction.dead_click when a click triggers no DOM mutation or network within max(mutationWaitMs=100, networkWaitMs=500). Off by default — false-positive prone for legitimate “do nothing” clicks. Tunable: {mutationWaitMs, networkWaitMs}. |
scrollDepth | false | Emits user_interaction.scroll_depth when the user crosses configured percentages of page height. Defaults {thresholds: [25, 50, 75, 100]} when set to true. |
Input field values are never captured — only field name and type.
Errors
Which uncaught error sources to listen on.
errors: {
captureWindowError: true, // default
captureUnhandledRejection: true, // default
captureResourceError: true, // default
}
| Field | Default | What it does |
|---|---|---|
captureWindowError | true | Listen on window.error for runtime errors. |
captureUnhandledRejection | true | Listen on unhandledrejection for unhandled Promise rejections. |
captureResourceError | true | Listen on window.error for failed <img> / <script> / <link> / <audio> / <video> / <source> loads. |
All routed through osuite.captureException internally — same log schema, distinguished by event.name.
Console
Default off. Opt-in to capture console.* calls as log records.
console: {
capture: ['error', 'warn'], // default: []
}
Each captured call:
- Still calls the original
console.*(your dev tools logs unchanged). - Emits
event.name='browser.console_<level>'. - Serializes args with a circular-and-bigint-safe replacer, capped at 4 KB body.
console.log/infomap to OTel severityINFO;warn→WARN;error→ERROR;debug→DEBUG.
Off by default because console output is high-volume and app code typically expects console to be side-effect-free.
Navigation
SPA route-change tracking.
navigation: {
enabled: true, // default
framework: 'auto', // 'next' | 'history' | 'auto' (default)
}
| Field | Default | What it does |
|---|---|---|
enabled | true | Set false to disable route_change spans. |
framework | 'auto' | 'auto' sniffs __NEXT_DATA__ / __next_f and uses the Next adapter; otherwise falls back to a generic History API adapter. |
Both adapters patch pushState / replaceState / popstate, then declare the route settled when the DOM is quiet for 500 ms (or hits a 5 s hard cap). Fetch/XHR spans during that window become children of the route_change span automatically.
Propagation
Which outgoing requests get the traceparent and baggage headers (carrying session.id + user.id).
propagation: {
allowlist: [/\/api\//, 'api.example.com'], // default: []
}
- Empty allowlist (default) = same-origin only. Same-origin requests (those starting with
/or matchingwindow.location.origin) get the headers; everything else does not. - Non-empty allowlist replaces same-origin matching. Strings match by substring; regexes match by
.test(url).
Default same-origin avoids leaking trace context to third-party APIs (analytics, ad pixels, vendor SDKs).
Debug
debug: true, // default false
When true, the SDK prints internal diagnostics (init steps, sampling decisions, transport failures) to console.debug. Use during integration; turn off in production.
Public API
import { osuite } from '@osuite/rum';
// Identity (any session-rotating call)
osuite.setUser({ id: 'u_123', plan: 'pro', tenantId: 't_99' });
osuite.clearUser();
// Errors and messages
osuite.captureException(err, {
extra: { feature: 'checkout', cartTotal: 42.5 },
level: 'error', // default 'error'
});
osuite.captureMessage('payment retried', 'warn', {
extra: { attempt: 2 },
});
// Active span attributes
osuite.addSpanAttributes({
'cart.total': 42.5,
'cart.items': 3,
});
// Replay (Phase 1: no-op debug log — `always` mode already uploads every chunk;
// this will force-flush the rolling buffer once `hybrid` mode lands)
osuite.replay.capture('user-reported-issue');
// Force everything out (useful before a navigation away)
await osuite.flush();
// Stop everything (useful in tests)
await osuite.shutdown();
extra keys become app.<key> log attributes. Levels: 'debug' | 'info' | 'warn' | 'error' | 'fatal'. 'fatal' triggers a sampling upgrade.
React adapter
Subpath @osuite/rum/react so the baseline bundle does not pull React.
import { OsuiteErrorBoundary } from '@osuite/rum/react';
export default function Root() {
return (
<OsuiteErrorBoundary
fallback={<ErrorPage />}
onError={(err, info) => {/* optional side-effect */}}
>
<App />
</OsuiteErrorBoundary>
);
}
Render errors get event.name='browser.react_error' and a react.component_stack attribute. The boundary also triggers a sampling upgrade so the buffered context around the crash exports.
Privacy
- Replay masking is on by default. Text →
***, inputs → masked. Opt-in per element withosuite-unmask/osuite-block/osuite-ignore. - No PII in
user.idorextra— by policy. The SDK does not inspect them; you are responsible for not passing email, phone, etc. - Trace headers same-origin only by default. Trace context never leaks to third-party APIs unless you add an explicit allowlist.
- JWT in memory only — never written to
localStorage. apiKeyis bundled (it is a public token). Backend enforces per-key, per-IP rate limits.
Bundle size
| Entry | Target (gzip) |
|---|---|
@osuite/rum baseline | < 15 KB |
@osuite/rum/react | < 1 KB |
| Replay (rrweb + pako, lazy-loaded) | ~40 KB |
Replay is loaded via dynamic import() so the cost lands only when replay actually starts.
Browser support
Modern evergreen browsers (Chrome, Edge, Firefox, Safari). ES2020 target. Requires BroadcastChannel for cross-tab session sync (falls back to storage event), IndexedDB for replay buffering (falls back to in-memory), and localStorage (falls back to in-memory; cross-tab disabled).
Next steps
- APM & Distributed Tracing — Backend tracing that the RUM SDK propagates into via
traceparentandbaggage - Log Management — RUM error logs and
replay.chunkpointers land in the same Logs Explorer as your backend logs - Alerts — Alert on frontend error rate, Web Vitals regressions, or rage-click spikes