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

> A practical engineering runbook for getting the most out of OpenObserve Real User Monitoring control ingestion volume and cost, protect the page load, mask PII, enrich sessions with business context, and stitch frontend events to backend traces.

Source: https://openobserve.ai/blog/optimize-rum-browser-monitoring/
Published: 2026-06-29
Authors: Bhargav Patel
Category: How To
Tags: RUM, OpenObserve, Frontend

---

## Why RUM Optimization Matters

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`.

RUM tells you what happened to a real visitor who showed up. It can't tell you a service is down if nobody visited it in the last five minutes. For that outside-in signal, pair RUM with [Synthetic Monitoring](https://openobserve.ai/blog/synthetic-monitoring-release/), which runs scheduled HTTP, TCP, TLS, SSH, and full browser checks against your endpoints from outside your infrastructure.

For where RUM fits alongside API and infrastructure monitoring, see our [web application monitoring best practices](https://openobserve.ai/blog/web-application-monitoring-tools-best-practices/).

---

## Topic 1: SDK Setup & Installation Strategy

### NPM vs. CDN

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:

```bash
npm install @openobserve/browser-rum @openobserve/browser-logs
```

```javascript
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:

```html
<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 inside `window.OO_RUM.onReady(...)` so it only executes once the SDK has finished loading.

### Protecting Page Load (LCP / INP)

The agent should never be the reason a page feels slow. Three rules:

1. **Load async, always.** Whether via the CDN snippet above or a deferred bundle entry, the SDK must not block the main document parse or your first paint.
2. **Pick the lighter package when you can.** If you do not need session replay, install `@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.
3. **Gate the expensive feature.** Session replay is the heaviest thing the SDK does. Keeping `sessionReplaySampleRate` low (or recording manually  see Topic 2) keeps the main thread free for the interactions that INP measures.

### Version Pinning & Upgrades

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.

### Multi-Application Setup

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.

---

## Topic 2: Cost & Volume Management

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.

### Dynamic Sampling by Environment

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:

```javascript
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**.

### Targeted Replay Recording

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:

```javascript
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.

### Filtering Noise with `beforeSend`

A 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:

```javascript
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`.

### Excluding Activity URLs

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:

```javascript
openobserveRum.init({
  // ...credentials...
  excludedActivityUrls: [
    /\/api\/heartbeat/,
    (url) => url.startsWith('https://analytics.example.com'),
  ],
});
```

### Trimming SDK Telemetry

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:

```javascript
openobserveRum.init({
  // ...credentials...
  telemetrySampleRate: 5,
});
```

### Server-Side Exclusion with Ingest Pipelines

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.

---

## Topic 3: Security, Privacy & Compliance (GDPR / HIPAA)

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.

### Default Privacy Levels

`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.

### Granular Element Controls

Override the baseline on individual elements directly in your markup. Use the `data-oo-privacy` attribute or the matching `oo-privacy-*` CSS classes:

```html
<!-- 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>
```

```html
<!-- 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.

### Secure Inputs Are Masked Automatically

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.

### Tracking Consent (Opt-In Regions)

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:

```javascript
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.

### Content Security Policy

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.

---

## Topic 4: Session Enrichment & Identity

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.

### User Identity

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:

```javascript
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.

### Global Business Context

Inject context that applies to *every* event  experiment buckets, region, feature-flag state  so you can slice any metric by it later:

```javascript
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:

```javascript
openobserveRum.addFeatureFlagEvaluation('new-checkout', true);
```

### Clearing State on Logout

On a shared device, a stale identity bleeds one user's actions into the next person's session. Clear it on sign-out:

```javascript
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.

---

## Topic 5: Custom Telemetry & Business Events

Automatic instrumentation captures pageviews, clicks, and network calls. Custom telemetry captures *meaning*  the conversion steps and failure modes specific to your product.

### Custom Actions

Track the events that map to your funnel, using a consistent, predictable naming taxonomy (`object_verb`) so they group cleanly:

```javascript
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.

### Custom Error Capture

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:

```javascript
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.

### Manual SPA View Tracking

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:

```javascript
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.

### Custom Performance Vitals

Beyond the standard web vitals, you can time business-critical operations  "time to interactive dashboard," "search-result render"  and have them tracked as durations:

```javascript
openobserveRum.startDurationVital('dashboard_ready');
await loadDashboardData();
openobserveRum.stopDurationVital('dashboard_ready');
```

---

## Topic 6: End-to-End Tracing & Backend Integration

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.

### Distributed Tracing Config

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:

```javascript
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.

### CORS Configuration

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.

---

## Topic 7: Error Resolution & Source Maps

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](/blog/rum-source-map)**

---

## Bonus: Detecting Frustration Before Churn

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](/blog/rum-frustration-signals)** for how to query and alert on these signals.

---

## A Production-Ready Configuration

Putting the high-impact settings together, here is a sensible production baseline to adapt:

```javascript
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;
  },
});
```

---

## Optimization Checklist

- [ ] Agent loaded **async**; replay-free apps use `browser-rum-slim`
- [ ] `sessionSampleRate` tuned per environment, not hard-coded to 100 in prod
- [ ] `sessionReplaySampleRate` low or 0; replay triggered manually where it adds value
- [ ] `beforeSend` blocklist drops extension/third-party/benign noise
- [ ] `excludedActivityUrls` set for heartbeats, sockets, and beacons
- [ ] Server-side ingest pipeline drops the remaining high-volume, low-value events
- [ ] `defaultPrivacyLevel` set; sensitive regions marked `data-oo-privacy`
- [ ] `trackingConsent` gated behind your consent banner (GDPR)
- [ ] CSP allows the collector host, CDN, and `worker-src blob:`
- [ ] `setUser` / `clearUser` wired into login and logout
- [ ] Global context carries experiment, region, and feature-flag state
- [ ] Custom actions follow a documented naming taxonomy
- [ ] `allowedTracingUrls` + backend CORS headers connect frontend to backend traces
- [ ] Source maps uploaded with matching `service` / `version` / `env`

---

## Conclusion

Optimizing 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.
