Optimizing OpenObserve RUM: The Complete Guide to Cost, Performance, Privacy, and Data Quality

Getting Started with OpenObserve

Try OpenObserve Cloud today for more efficient and performant observability.

Real User Monitoring is one of the highest-value signals you can collect: it shows you exactly what real people experience in their real browsers, on their real networks. But left at its defaults, a browser monitoring agent will happily record everything every session, every replay frame, every console warning, every third-party script error and you pay for all of it in ingestion cost, storage, and noise that buries the signal you actually care about.
This guide is an engineering runbook for running the OpenObserve Browser SDK in production well. It covers how to control volume and cost, how to keep the agent from hurting your Core Web Vitals, how to mask sensitive data for GDPR/HIPAA compliance, how to enrich sessions with the business context that makes them actionable, and how to stitch frontend events to your backend traces.
Every code sample uses the real OpenObserve API. The examples assume you have already created a RUM application in OpenObserve and have your applicationId, clientToken, and organizationIdentifier.
There are two ways to load the SDK, and the right choice depends on your build.
NPM is the default for any app with a bundler (Vite, Webpack, Rollup). You get type definitions, tree-shaking, and the SDK versioned alongside your code:
npm install @openobserve/browser-rum @openobserve/browser-logs
import { openobserveRum } from '@openobserve/browser-rum';
import { openobserveLogs } from '@openobserve/browser-logs';
openobserveRum.init({
applicationId: 'YOUR_APPLICATION_ID',
clientToken: 'YOUR_CLIENT_TOKEN',
site: 'your-openobserve-host.com',
organizationIdentifier: 'YOUR_ORG',
service: 'web-app',
env: 'production',
version: '1.2.3',
sessionSampleRate: 100,
sessionReplaySampleRate: 20,
trackResources: true,
trackLongTasks: true,
trackUserInteractions: true,
defaultPrivacyLevel: 'mask-user-input',
insecureHTTP: false,
apiVersion: 'v1',
});
CDN async is the right choice for sites without a build step, or where you want the agent loaded out-of-band so it never sits in your critical bundle. The async bundle exposes a global and a queue:
<script>
(function(h,o,u,n,d) {
h=h[d]=h[d]||{q:[],onReady:function(c){h.q.push(c)}}
d=o.createElement(u);d.async=1;d.src=n
n=o.getElementsByTagName(u)[0];n.parentNode.insertBefore(d,n)
})(window,document,'script','https://browsersdk.openobserve.ai/0.3.1/openobserve-rum.js','OO_RUM')
window.OO_RUM.onReady(function() {
window.OO_RUM.init({
applicationId: 'YOUR_APPLICATION_ID',
clientToken: 'YOUR_CLIENT_TOKEN',
site: 'your-openobserve-host.com',
organizationIdentifier: 'YOUR_ORG',
service: 'web-app',
env: 'production',
sessionSampleRate: 100,
sessionReplaySampleRate: 20,
defaultPrivacyLevel: 'mask-user-input',
});
});
</script>
With the CDN async setup, any early API call (
setUser,startView, etc.) must run insidewindow.OO_RUM.onReady(...)so it only executes once the SDK has finished loading.
The agent should never be the reason a page feels slow. Three rules:
@openobserve/browser-rum-slim instead of @openobserve/browser-rum. It collects views, actions, errors, and resources without the replay recorder, so the bundle is meaningfully smaller which directly helps LCP on bandwidth-constrained devices.sessionReplaySampleRate low (or recording manually see Topic 2) keeps the main thread free for the interactions that INP measures.Pin the CDN URL to an explicit version (.../0.3.1/openobserve-rum.js) rather than a floating "latest" alias so a new release can never change behavior under you mid-incident. For NPM, pin the exact version in package.json and upgrade deliberately: read the changelog, bump in a branch, and smoke-test that sessions, replays, and errors still arrive in OpenObserve before you ship. Treat the agent like any other production dependency.
If you run distinct zones a public marketing site and a logged-in dashboard, say give each its own RUM application (its own applicationId) and a distinct service name. This keeps unauthenticated, high-traffic marketing pageviews from drowning out the lower-volume but higher-value authenticated app sessions, and lets you sample, alert, and budget each independently.
This is where most teams find the biggest wins. RUM cost scales with the number of sessions, the number of events per session, and above all whether those sessions carry a replay recording.
Hard-coding sessionSampleRate: 100 is fine in staging but wasteful in production. Drive it from environment so you keep full fidelity where volume is low and sample where volume is high:
const isProd = import.meta.env.MODE === 'production';
openobserveRum.init({
// ...credentials...
sessionSampleRate: isProd ? 20 : 100, // keep 20% of prod sessions
sessionReplaySampleRate: isProd ? 5 : 100, // record 5% of those as replay
});
sessionSampleRate decides whether a session is tracked at all. sessionReplaySampleRate is applied on top and decides whether a tracked session also gets a pixel-level replay. Because replay is the most expensive data type, the durable pattern is a moderate session rate paired with a much lower replay rate.
Random replay sampling wastes budget on sessions you will never watch. Instead, record replay only where it pays off enterprise accounts, checkout funnels, or sessions a feature flag is targeting. Start with replay off and trigger it from your own code:
openobserveRum.init({
// ...credentials...
sessionReplaySampleRate: 0,
startSessionReplayRecordingManually: true,
});
// Later, once you know this session is worth recording:
if (user.plan === 'enterprise' || route.startsWith('/checkout')) {
openobserveRum.startSessionReplayRecording();
}
This single change routinely cuts replay volume by an order of magnitude while improving the usefulness of what you keep.
beforeSendA large share of raw RUM error volume is noise you can never fix: failures inside browser extensions, third-party ad/analytics scripts, and benign console warnings. beforeSend runs for every event before it leaves the browser return false to drop it. Centralize the rules in one blocklist:
const DROP_ERROR_PATTERNS = [
/chrome-extension:\/\//,
/moz-extension:\/\//,
/ResizeObserver loop limit exceeded/,
/Non-Error promise rejection captured/,
];
const THIRD_PARTY_HOSTS = ['googletagmanager.com', 'doubleclick.net', 'hotjar.com'];
openobserveRum.init({
// ...credentials...
beforeSend: (event) => {
if (event.type === 'error') {
const msg = event.error?.message ?? '';
if (DROP_ERROR_PATTERNS.some((re) => re.test(msg))) return false;
}
if (event.type === 'resource' && event.resource?.url) {
if (THIRD_PARTY_HOSTS.some((h) => event.resource.url.includes(h))) return false;
}
return true; // keep everything else
},
});
beforeSend is also where you can modify an event for example, scrubbing a query-string token out of a URL by mutating the event object and returning true.
Long-polling endpoints, websockets, and analytics beacons keep a "view" looking busy forever and inflate resource counts. Tell the SDK to ignore them so they do not distort loading metrics or pad your volume:
openobserveRum.init({
// ...credentials...
excludedActivityUrls: [
/\/api\/heartbeat/,
(url) => url.startsWith('https://analytics.example.com'),
],
});
The SDK reports a small amount of its own internal telemetry. In a high-traffic app even that adds up. telemetrySampleRate defaults to 20 (percent); lower it if you do not actively use that data:
openobserveRum.init({
// ...credentials...
telemetrySampleRate: 5,
});
Browser-side filtering catches what you can predict in advance. For everything else, OpenObserve ingest pipelines give you a second, server-side line of defense: attach a pipeline to the RUM stream and use a VRL condition to drop high-volume, low-value events before they are stored for example, dropping resource events for static assets, or actions below a relevance threshold. Because this runs at ingest time, it reduces stored volume without touching your frontend deploy, which makes it the fastest lever to pull when a noisy event type suddenly spikes.
Session replay is powerful precisely because it captures the real DOM which means you must be deliberate about what it is allowed to capture. OpenObserve RUM masks data in the browser, before it is sent, so sensitive content never leaves the device.
defaultPrivacyLevel sets the baseline for the whole application:
| Level | Behavior |
|---|---|
allow |
Records text and inputs as-is. Use only for non-sensitive surfaces. |
mask-user-input |
Records page text, but masks anything the user types. A good default for most apps. |
mask |
Masks all text and all inputs. Maximum text privacy while keeping layout/replay. |
For HIPAA/PHI surfaces, start at mask and selectively allow only the elements that are safe to show.
Override the baseline on individual elements directly in your markup. Use the data-oo-privacy attribute or the matching oo-privacy-* CSS classes:
<!-- Completely remove an element from the recording -->
<section data-oo-privacy="hidden">
<!-- patient records, account balance, etc. -->
</section>
<!-- Mask the text of this element and its children -->
<div data-oo-privacy="mask">{{ creditCardSummary }}</div>
<!-- Explicitly allow an otherwise-masked subtree -->
<span data-oo-privacy="allow">{{ orderNumber }}</span>
<!-- Equivalent using CSS classes -->
<div class="oo-privacy-hidden">...</div>
<div class="oo-privacy-mask">...</div>
The privacy level is inherited: set data-oo-privacy="hidden" on a container and everything inside it is hidden, regardless of the app-wide default.
Password, email, and similarly sensitive form fields are masked by the recorder by default you do not need to annotate every <input type="password">. Treat that as a safety net, not a substitute for setting an appropriate defaultPrivacyLevel and hiding known-sensitive regions explicitly.
Where the law requires opt-in (GDPR), do not collect until the user agrees. Initialize with consent withheld and grant it from your cookie banner:
openobserveRum.init({
// ...credentials...
trackingConsent: 'not-granted',
});
// When the user accepts in your consent banner:
openobserveRum.setTrackingConsent('granted');
While consent is not-granted, the SDK buffers nothing to the backend, so you stay compliant by default.
If you run a strict CSP, allow the SDK to load and report:
connect-src must include your OpenObserve collector host (the site you configured) so events and replays can be sent.script-src must include https://browsersdk.openobserve.ai if you load the agent from the CDN.worker-src blob: is required for session replay, which runs its serializer in a web worker.Raw RUM tells you what happened. Enrichment tells you who it happened to and under what conditions which is what turns a session list into a debugging tool.
Attach an internal user identifier so you can find "all sessions for tenant X" or jump from a support ticket to a replay. Set it as early as you safely can:
openobserveRum.setUser({
id: 'usr_8a72f', // stable internal ID prefer this over email
name: 'Jane Doe',
email: 'jane@example.com', // only if your privacy policy permits storing it
plan: 'enterprise', // any custom attributes you want to facet on
tenantId: 'org_4419',
});
Prefer an opaque internal id over email as the primary key it is stable across email changes and avoids putting PII in the identifier itself. Store email only if your compliance posture allows it.
Inject context that applies to every event experiment buckets, region, feature-flag state so you can slice any metric by it later:
openobserveRum.setGlobalContextProperty('ab_test_variant', 'checkout_v2');
openobserveRum.setGlobalContextProperty('region', 'eu-central');
openobserveRum.setGlobalContextProperty('feature_flags', { newNav: true });
Feature-flag evaluations specifically have a first-class API that makes them queryable as their own dimension:
openobserveRum.addFeatureFlagEvaluation('new-checkout', true);
On a shared device, a stale identity bleeds one user's actions into the next person's session. Clear it on sign-out:
function onLogout() {
openobserveRum.clearUser(); // drop the identified user
openobserveRum.removeGlobalContextProperty('tenantId');
}
Use clearUser() to wipe the whole user object, or removeUserProperty(key) to drop a single field.
Automatic instrumentation captures pageviews, clicks, and network calls. Custom telemetry captures meaning the conversion steps and failure modes specific to your product.
Track the events that map to your funnel, using a consistent, predictable naming taxonomy (object_verb) so they group cleanly:
openobserveRum.addAction('checkout_started', { cartValue: 129.0, items: 3 });
openobserveRum.addAction('coupon_applied', { code: 'SUMMER', discount: 0.15 });
openobserveRum.addAction('checkout_completed', { orderId: 'ord_5521' });
Agree on the taxonomy once and document it checkout_started, not a mix of startCheckout, Checkout Begin, and begin-checkout so dashboards and funnels stay coherent as the team grows.
Automatic error tracking only sees errors that bubble up. Anything you catch is invisible unless you report it and that handled-but-important class (failed API promises, validation edge cases) is often the most actionable. Send it with diagnostic context:
try {
await submitPayment(order);
} catch (err) {
openobserveRum.addError(err, {
feature: 'payments',
orderId: order.id,
gateway: 'stripe',
retryable: isRetryable(err),
});
}
The context object is attached to the error in OpenObserve, so you can facet handled errors by feature, gateway, or any field you pass.
The SDK detects route changes automatically for most single-page apps. But in heavily customized routers where the URL does not change on navigation, or one URL represents several logical screens automatic detection misfires. Take over view tracking explicitly:
openobserveRum.init({
// ...credentials...
trackViewsManually: true,
});
// In your router's after-navigation hook:
router.afterEach((to) => {
openobserveRum.startView({ name: to.name, service: 'web-app' });
});
Each startView call starts a new view, ending the previous one so your loading and performance metrics are attributed to the right screen.
Beyond the standard web vitals, you can time business-critical operations "time to interactive dashboard," "search-result render" and have them tracked as durations:
openobserveRum.startDurationVital('dashboard_ready');
await loadDashboardData();
openobserveRum.stopDurationVital('dashboard_ready');
The biggest payoff of RUM is connecting a slow or failed frontend action to the exact backend span that caused it. The SDK does this by injecting trace headers into outgoing requests.
List the backend origins your app calls in allowedTracingUrls. For matching requests, the SDK injects a W3C traceparent header so the resulting backend trace shares an ID with the RUM resource:
openobserveRum.init({
// ...credentials...
service: 'web-app',
allowedTracingUrls: [
'https://api.example.com',
/https:\/\/.*\.internal\.example\.com/,
{ match: 'https://payments.example.com', propagatorTypes: ['tracecontext', 'b3multi'] },
],
});
By default the SDK uses the tracecontext (W3C traceparent) propagator. If your backend expects a different format, set propagatorTypes per URL supported values include tracecontext, b3, b3multi, and datadog. Match the propagator to whatever your backend instrumentation already understands.
For cross-origin API calls, the browser sends a preflight OPTIONS request. If your API does not advertise the tracing headers as allowed, the browser strips them and the trace link is lost. Add them to your API's CORS response:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Headers: traceparent, tracestate, b3, x-b3-traceid, x-b3-spanid
Include only the headers matching the propagators you actually use. Once this is in place, a slow fetch in a RUM session links straight to the backend span that served it.
Minified production bundles turn every stack trace into chunk-8f3a.js:1:338 useless for debugging. OpenObserve RUM deobfuscates them when you upload your source maps, restoring original filenames, line numbers, function names, and even surrounding source.
The three values that must match between your SDK config and your source-map upload are service, version, and env using your git commit SHA as version guarantees uniqueness across deploys. We cover the full setup, CI/CD automation, and troubleshooting in a dedicated guide:
→ RUM Source Maps: Debug Minified Production Errors with Original Source Code
Not every UX problem throws an error. Rage clicks, dead clicks, and error clicks are captured automatically and surface the friction that traditional error tracking misses. If you have enabled trackUserInteractions: true, you are already collecting them see Detecting Frustrated Users Before They Churn for how to query and alert on these signals.
Putting the high-impact settings together, here is a sensible production baseline to adapt:
openobserveRum.init({
applicationId: 'YOUR_APPLICATION_ID',
clientToken: 'YOUR_CLIENT_TOKEN',
site: 'your-openobserve-host.com',
organizationIdentifier: 'YOUR_ORG',
service: 'web-app',
env: 'production',
version: import.meta.env.VITE_GIT_SHA, // match this to your source-map upload
sessionSampleRate: 20, // keep 20% of sessions
sessionReplaySampleRate: 0, // replay off by default...
startSessionReplayRecordingManually: true, // ...triggered only where it matters
telemetrySampleRate: 5,
trackResources: true,
trackLongTasks: true,
trackUserInteractions: true,
defaultPrivacyLevel: 'mask-user-input',
trackingConsent: 'not-granted', // grant via setTrackingConsent('granted')
allowedTracingUrls: ['https://api.example.com'],
excludedActivityUrls: [/\/api\/heartbeat/],
beforeSend: (event) => {
if (event.type === 'error' && /extension:\/\//.test(event.error?.message ?? '')) {
return false;
}
return true;
},
});
browser-rum-slimsessionSampleRate tuned per environment, not hard-coded to 100 in prodsessionReplaySampleRate low or 0; replay triggered manually where it adds valuebeforeSend blocklist drops extension/third-party/benign noiseexcludedActivityUrls set for heartbeats, sockets, and beaconsdefaultPrivacyLevel set; sensitive regions marked data-oo-privacytrackingConsent gated behind your consent banner (GDPR)worker-src blob:setUser / clearUser wired into login and logoutallowedTracingUrls + backend CORS headers connect frontend to backend tracesservice / version / envOptimizing RUM is not about collecting less it is about collecting deliberately. Sample sessions so your metrics stay statistically sound while your bill stays flat. Record replays only where they earn their cost. Mask aggressively so you never have to worry about what a recording captured. Enrich every event with the identity and business context that makes it actionable. And connect the frontend to the backend so a slow click becomes a solvable trace, not a mystery.
Work through the checklist above once, bake the production baseline into your init, and you will have a RUM setup that is cheaper, faster, safer, and most importantly more useful than the defaults ever were.