Osuite Cloud

Real User Monitoring

Last updated on May 11, 2026

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:

StreamTransportWhat it carries
SpansOTLP /v1/tracesdocumentLoad, documentFetch, resourceFetch, HTTP {METHOD}, route_change, user_interaction.*
Log recordsOTLP /v1/logssession.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 chunksS3 presigned PUTGzipped 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.error for runtime errors
  • unhandledrejection for unhandled Promise rejections
  • window.error for failed <img>/<script>/<link>/<audio>/<video>/<source> loads
  • The optional OsuiteErrorBoundary for 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 documentLoad timing attributes and HTTP {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

FieldWhat it doesNotes
apiKeyPublic 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.
endpointBase URL for OTLP traces and logs. The SDK posts to ${endpoint}/v1/traces and ${endpoint}/v1/logs.Falls back to OSUITE_INGEST_ENDPOINT.
apiEndpointBase 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.nameIdentifies the frontend in your traces (e.g. frontend-web, admin-app).Required.
service.versionBuild id — git SHA, tag, or semver. Used for source-map matching.Required.
service.environmentproduction, 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
}
FieldDefaultWhat it does
inactivityTimeoutMs1_800_000 (30 min)Idle time before the session rotates. Activity = clicks, keystrokes, scrolls, fetches, tab focus.
hardCapMs10_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
  },
}
FieldDefaultWhat it does
sessionSampleRate1.0Probability that a session is “sampled” (everything exports immediately). Range [0, 1]. Decided once per session. Set to 0.1 to sample 10% of sessions.
upgradeOnErrortrueWhen 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.maxItems500Hard cap on items held for an unsampled session. Older items evicted FIFO.
preSampleBuffer.maxAgeMs60_000Items 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,
}
FieldDefaultWhat 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.
sampleRate0.1Used only by probabilistic and (when shipped) hybrid. Range [0, 1].
bufferSeconds30Rolling-window length used by on-error / hybrid (planned).
trailingSeconds15How long to keep uploading after a trigger fires (planned).
maxBufferMB50Per-session cap on IndexedDB usage. Oldest non-uploaded chunks evicted first.
maskAllTexttrueAll text nodes recorded as ***. Opt back in per element with class="osuite-unmask".
maskAllInputstrueAll <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.
uploadOnSessionEndfalseWhen 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] },
}
FieldDefaultWhat it does
level'standard''minimal' = clicks only. 'standard' = clicks + form submits + input focus/blur + visibility change.
rageClicksfalseWhen 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}.
deadClicksfalseEmits 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}.
scrollDepthfalseEmits 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
}
FieldDefaultWhat it does
captureWindowErrortrueListen on window.error for runtime errors.
captureUnhandledRejectiontrueListen on unhandledrejection for unhandled Promise rejections.
captureResourceErrortrueListen 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 / info map to OTel severity INFO; warnWARN; errorERROR; debugDEBUG.

Off by default because console output is high-volume and app code typically expects console to be side-effect-free.

SPA route-change tracking.

navigation: {
  enabled: true,                     // default
  framework: 'auto',                 // 'next' | 'history' | 'auto' (default)
}
FieldDefaultWhat it does
enabledtrueSet 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 matching window.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 with osuite-unmask / osuite-block / osuite-ignore.
  • No PII in user.id or extra — 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.
  • apiKey is bundled (it is a public token). Backend enforces per-key, per-IP rate limits.

Bundle size

EntryTarget (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 traceparent and baggage
  • Log Management — RUM error logs and replay.chunk pointers land in the same Logs Explorer as your backend logs
  • Alerts — Alert on frontend error rate, Web Vitals regressions, or rage-click spikes