@osuite/rum
v3.0.0
Published
Browser Real User Monitoring SDK for Osuite — OpenTelemetry-native traces and logs, session-aware, with optional React integration.
Downloads
1,046
Maintainers
Readme
@osuite/rum
Browser Real User Monitoring SDK for Osuite. Emits OpenTelemetry-native traces and logs, plus rrweb session replay, so frontends get the same observability shape as backend services: trace-structured user journeys, structured errors, session replay, and end-to-end correlation with backend traces via W3C baggage.
- One init call. Sensible defaults. Zero ceremony.
- OpenTelemetry-native — works with any OTLP-compatible collector.
- Session-aware — every span, log, and replay chunk carries
session.idand (when set)user.id. - Privacy-first — replay masks all text and inputs by default; opt-in per element.
- Sampling with error-triggered upgrade — unsampled sessions still get exported when something goes wrong.
Install
npm install @osuite/rum
# or
pnpm add @osuite/rumFor React apps that want the error boundary, install with react as a peer:
npm install @osuite/rum reactQuick 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',
},
});
osuite.setUser({ id: 'u_123', plan: 'pro' });
try {
await doWork();
} catch (err) {
osuite.captureException(err, { extra: { feature: 'checkout' } });
}That's enough to get traces (page loads, fetch/XHR, route changes, clicks), error logs, and full session replay flowing to your collector.
Configuration
Everything beyond apiKey/endpoint/apiEndpoint/service is optional. Defaults are tuned for a typical SaaS frontend.
Required
osuite.init({
apiKey: 'pk_live_...',
endpoint: 'https://ingest.us-east-1.osuite.io',
apiEndpoint: 'https://api.us-east-1.osuite.io',
service: {
name: 'frontend-web',
version: '1.4.2',
environment: 'production',
},
});| Field | What it does | Notes |
|---|---|---|
| apiKey | Public ingest token sent on every OTLP request and 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. | Required. Used for source-map matching later. |
| service.environment | production, staging, development, etc. | Required — backend filters and dashboards key off this. |
Session
Controls how long a "session" lasts. 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. |
A 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. The default is to sample everything, which is fine until your traffic grows.
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. |
Bypass list. Even when a session is unsampled, certain log records always export so the backend can see lifecycle and error context: 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.
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, // rolling buffer window (used by hybrid/on-error)
trailingSeconds: 15, // post-trigger upload window (used by hybrid/on-error)
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" (or whatever unmaskClass is). |
| 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, // detect frustrated repeated clicks
deadClicks: false, // detect clicks that did nothing
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. Also flushes the replay buffer when shipped. 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).
Why an explicit allowlist matters: trace context is not normally something you want to leak to third-party APIs (analytics, ad pixels, vendor SDKs). Default same-origin avoids that.
Debug
debug: true, // default falseWhen 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; will force-flush 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.
What gets emitted
The full inventory lives in docs/design.md §15. Quick summary:
- Spans (OTLP
/v1/traces):documentLoad,documentFetch,resourceFetch,HTTP {METHOD},route_change,user_interaction.click,user_interaction.form_submit,user_interaction.input_focus/input_blur,user_interaction.visibility_change, plus opt-inuser_interaction.rage_click/dead_click/scroll_depth. - 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_*(opt-in),browser.manual,replay.chunk. - Replay chunks (S3 presigned PUT, not OTLP): gzipped rrweb event blobs. Each successful upload also emits a
replay.chunkpointer log so they're queryable from the session log stream.
Every span and log carries session.id, user.id?, service.name, service.version, service.environment, plus browser/connection metadata.
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're responsible for not passing email, phone, etc. - Trace headers same-origin only by default.
- JWT in memory only — never written to
localStorage. apiKeyis bundled (it's 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).
Design
Full spec, decisions, and signals inventory: docs/design.md.
