Integrating Observability SDKs: Sentry, Datadog RUM, and OpenTelemetry

Production browser error monitoring requires more than a global window.onerror handler. Three SDK ecosystems dominate the space — Sentry’s @sentry/browser, Datadog RUM, and the OpenTelemetry Web SDK — and each demands precise initialization sequencing, sampling configuration, and PII scrubbing before a single payload reaches an ingestion endpoint. This page covers all three. It assumes you have already established a Core JavaScript Error Handling & Boundaries foundation and are familiar with Mastering window.onerror and Global Event Listeners and Handling Unhandled Promise Rejections in Modern JS.

After completing this guide you will be able to:

  • Initialize Sentry @sentry/browser with beforeSend filtering and distributed tracing
  • Boot Datadog RUM with datadogRum.init and connect it to your APM backend
  • Configure WebTracerProvider from the OpenTelemetry Web SDK and export spans via OTLP/HTTP
  • Apply dynamic sampling strategies without dropping critical failure signals
  • Scrub PII at the SDK boundary before any payload leaves the browser

Browser Error SDK Integration FlowBrowser error events captured by three parallel SDK pipelines: Sentry beforeSend to Sentry Ingest, Datadog RUM beforeSend to Datadog Backend, and OpenTelemetry WebTracerProvider to OTLP Collector, each passing a sample and PII scrub stage.Browser Runtime Erroronerror + rejection@sentry/browserinit + beforeSendDatadog RUMinit + beforeSendOpenTelemetry WebWebTracerProviderSample + PII ScrubSample + PII ScrubSample + PII ScrubSentry IngestDatadog BackendOTLP Collector

Problem Framing & Symptom Identification

The core problem is that browser runtime errors are ephemeral — once the page unloads they are gone. Without an SDK initializing before application code runs, any exception thrown during module evaluation, hydration, or script parse time is silently lost. The second problem is payload quality: raw Error objects arriving at an ingestion endpoint without symbolication context, sampling metadata, or PII scrubbing create compliance risk and debugging noise in equal measure.

Symptoms of a misconfigured SDK integration include:

  • Error events in Sentry with <anonymous> file names and no source context — indicates missing source map upload steps in CI.
  • Datadog RUM sessions with redacted stack traces showing [object Object] in the message field — indicates a serialization issue in beforeSend.
  • OpenTelemetry spans with status: UNSET instead of status: ERROR — indicates the span exception recording API is being called after the span has already ended.
  • Quota exhaustion in Sentry from 100% error sampling on a high-traffic page — indicates missing tracesSampleRate and sampleRate tuning.

All three SDKs must be initialized synchronously before any import that might throw. In bundled applications this means the SDK init call belongs in the entry point file, before any application module imports execute.

A subtler failure mode appears with code-split bundles. When a route-level chunk is fetched lazily, the SDK must already be live on the main thread before that chunk evaluates; otherwise an exception thrown inside the dynamically imported module escapes capture entirely. Verify this by deliberately throwing inside a lazily loaded route during a staging smoke test and confirming the event lands under the expected release. The naive setup on the left below silently loses that class of error, while the hardened setup on the right survives page unload, quota pressure, and privacy review.

Naive vs Hardened SDK InitBefore column lists 100 percent error sampling, no PII scrubbing, and a missing release tag; after column lists tuned sample rates, beforeSend scrubbing PII, and release-tagged builds.Naive Init100% error samplingNo PII scrubbingMissing release tagHardened InitTuned sample ratesbeforeSend scrubs PIIRelease-tagged builds

Prerequisites & Environment Setup

Versions tested against this guide:

# Sentry browser SDK
npm install @sentry/browser@8

# Datadog RUM
npm install @datadog/browser-rum@5

# OpenTelemetry Web (core + auto-instrumentation + OTLP exporter)
npm install \
  @opentelemetry/sdk-trace-web@1 \
  @opentelemetry/[email protected] \
  @opentelemetry/[email protected] \
  @opentelemetry/[email protected] \
  @opentelemetry/[email protected]

You also need:

  • A Sentry DSN from your project settings (Settings → Client Keys)
  • A Datadog Application ID and Client Token (RUM → New Application)
  • An OTLP collector endpoint accepting HTTP/JSON — either a local OpenTelemetry Collector or a managed vendor endpoint

Step-by-Step Implementation

Step 1 — Initialize Sentry with beforeSend and tracing

Place this block at the very top of your bundle entry point, before any other imports that might throw.

// src/instrumentation.js — imported FIRST in src/main.js
import * as Sentry from '@sentry/browser';

Sentry.init({
  dsn: import.meta.env.VITE_SENTRY_DSN,         // pull from env, never hardcode
  release: import.meta.env.VITE_APP_RELEASE,     // correlates errors to a build
  environment: import.meta.env.MODE,             // 'production' | 'staging'
  tracesSampleRate: 0.1,                         // 10% of transactions carry traces
  sampleRate: 1.0,                               // 100% of errors are captured
  beforeSend(event) {
    // Drop errors originating from browser extensions
    const frames = event.exception?.values?.[0]?.stacktrace?.frames ?? [];
    if (frames.some(f => f.filename?.includes('chrome-extension://'))) {
      return null; // returning null drops the event entirely
    }
    return event;
  },
});

release is the most important field for production debugging. Without it, Sentry cannot locate the source maps you upload to symbolicate stack frames. The tracesSampleRate is distinct from sampleRate: the former controls performance transaction sampling, the latter controls error event capture. They are independent knobs.

Step 2 — Attach beforeSend for dynamic PII scrubbing in Sentry

A static beforeSend checking extension origins is not sufficient for privacy compliance. Add a second layer that redacts sensitive values from the event payload before transmission.

// Extend the Sentry.init call's beforeSend
beforeSend(event) {
  // Scrub email addresses from exception message strings
  if (event.exception?.values) {
    event.exception.values = event.exception.values.map(ex => ({
      ...ex,
      value: ex.value?.replace(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-z]{2,}/g, '[email]'),
    }));
  }
  // Scrub Authorization headers from breadcrumb fetch calls
  if (event.breadcrumbs?.values) {
    event.breadcrumbs.values = event.breadcrumbs.values.map(bc => {
      if (bc.data?.['Authorization']) {
        bc.data['Authorization'] = '[Filtered]'; // replace, not delete, to preserve structure
      }
      return bc;
    });
  }
  return event;
},

beforeSend receives the fully assembled event object. Mutations applied here affect what leaves the browser — this is the correct enforcement point for GDPR Article 25 (data protection by design).

Step 3 — Initialize Datadog RUM

Datadog RUM tracks user sessions, resources, and JavaScript errors. The init call must precede any user interaction or network request you want attributed to a session.

// src/instrumentation.js (continued, or a separate file imported first)
import { datadogRum } from '@datadog/browser-rum';

datadogRum.init({
  applicationId: import.meta.env.VITE_DD_APP_ID,
  clientToken: import.meta.env.VITE_DD_CLIENT_TOKEN,
  site: 'datadoghq.com',                        // or 'datadoghq.eu' for EU region
  service: 'my-frontend-app',
  env: import.meta.env.MODE,
  version: import.meta.env.VITE_APP_RELEASE,
  sessionSampleRate: 100,                        // 100% of sessions are tracked
  sessionReplaySampleRate: 20,                   // 20% of sessions include replay
  trackUserInteractions: true,
  trackResources: true,
  trackLongTasks: true,
  defaultPrivacyLevel: 'mask-user-input',        // mask all form inputs by default
  beforeSend(event) {
    // Drop noisy ResizeObserver loop errors common in some browser extensions
    if (event.type === 'error' && event.error?.message?.includes('ResizeObserver')) {
      return false; // Datadog uses `false`, not `null`, to drop events
    }
    return true;
  },
});

The defaultPrivacyLevel: 'mask-user-input' setting automatically masks text in <input>, <textarea>, and <select> elements in session replays. This is the minimum acceptable default for any application handling user data. For HIPAA-scoped applications, switch to 'mask' to mask all text content.

Step 4 — Wire Datadog RUM to APM distributed tracing

To correlate frontend errors with backend trace spans, configure allowedTracingUrls. This injects W3C traceparent headers into matching fetch and XHR requests.

datadogRum.init({
  // ...existing config...
  allowedTracingUrls: [
    { match: import.meta.env.VITE_API_BASE_URL, propagatorTypes: ['tracecontext'] },
  ],
  // traceSampleRate controls what percentage of RUM sessions emit APM traces
  traceSampleRate: 10, // 10% — keep in sync with backend sampling rate
});

The propagatorTypes: ['tracecontext'] value uses the W3C Trace Context standard (traceparent / tracestate headers) rather than Datadog’s proprietary x-datadog-* headers. Prefer tracecontext for interoperability when your backend also uses OpenTelemetry.

Step 5 — Bootstrap the OpenTelemetry WebTracerProvider

The OpenTelemetry Web SDK is lower-level than Sentry or Datadog RUM — it gives you a standards-based instrumentation layer but requires more assembly. The WebTracerProvider is the core object; instrumentations hook into browser APIs (fetch, XHR) via a plugin model.

// src/otel.js
import { WebTracerProvider } from '@opentelemetry/sdk-trace-web';
import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { registerInstrumentations } from '@opentelemetry/instrumentation';
import { FetchInstrumentation } from '@opentelemetry/instrumentation-fetch';
import { XMLHttpRequestInstrumentation } from '@opentelemetry/instrumentation-xml-http-request';
import { Resource } from '@opentelemetry/resources';
import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions';

const provider = new WebTracerProvider({
  resource: new Resource({
    [SemanticResourceAttributes.SERVICE_NAME]: 'my-frontend-app',
    [SemanticResourceAttributes.SERVICE_VERSION]: import.meta.env.VITE_APP_RELEASE,
    [SemanticResourceAttributes.DEPLOYMENT_ENVIRONMENT]: import.meta.env.MODE,
  }),
});

// OTLP/HTTP exporter — points to your collector or vendor endpoint
const exporter = new OTLPTraceExporter({
  url: import.meta.env.VITE_OTLP_ENDPOINT, // e.g. https://otel.example.com/v1/traces
});

// BatchSpanProcessor buffers spans and flushes them in batches
provider.addSpanProcessor(new BatchSpanProcessor(exporter));

// Register the provider globally so all instrumentations find it
provider.register();

// Auto-instrument fetch and XHR to create child spans under active trace context
registerInstrumentations({
  instrumentations: [
    new FetchInstrumentation({
      propagateTraceHeaderCorsUrls: [new RegExp(import.meta.env.VITE_API_BASE_URL)],
    }),
    new XMLHttpRequestInstrumentation({
      propagateTraceHeaderCorsUrls: [new RegExp(import.meta.env.VITE_API_BASE_URL)],
    }),
  ],
});

BatchSpanProcessor is mandatory in browser environments — SimpleSpanProcessor flushes on every span end and will saturate network connections on high-activity pages. The propagateTraceHeaderCorsUrls regex must match your API base URL precisely or header injection is silently skipped.

Step 6 — Record error events as span exceptions in OpenTelemetry

OpenTelemetry does not automatically capture window.onerror or unhandledrejection events — you must wire them manually and record exceptions against the active span or create a dedicated error span.

// src/otel-error-bridge.js
import { trace, SpanStatusCode } from '@opentelemetry/api';

const tracer = trace.getTracer('browser-error-bridge', '1.0.0');

function recordGlobalError(error, source) {
  const span = tracer.startSpan('browser.error'); // creates a new root span
  span.recordException(error);                    // attaches exception event to span
  span.setStatus({ code: SpanStatusCode.ERROR, message: error?.message });
  span.setAttribute('error.source', source);
  span.end();                                     // must end the span or it never exports
}

window.addEventListener('error', (event) => {
  if (event.error) recordGlobalError(event.error, event.filename ?? 'unknown');
});

window.addEventListener('unhandledrejection', (event) => {
  const err = event.reason instanceof Error ? event.reason : new Error(String(event.reason));
  recordGlobalError(err, 'unhandledrejection');
});

span.recordException follows the OpenTelemetry specification: it attaches a structured event named exception with exception.type, exception.message, and exception.stacktrace attributes. The span status must be set to ERROR separately — recordException alone does not change span status.

One consequence of BatchSpanProcessor deserves a dedicated safeguard: because it buffers spans in memory and flushes them on a timer, an error thrown moments before the user navigates away can be lost when the tab tears down ahead of the next flush. Register a visibilitychange listener that calls provider.forceFlush() when the document becomes hidden, and prefer an exporter transport backed by navigator.sendBeacon so the final batch survives page unload even after the JavaScript context is frozen. Without this, the very errors most correlated with abandonment — the ones users hit right before rage-quitting a broken flow — are the ones you never receive.

SDK Comparison & Selection Criteria

Understanding which SDK provides what capability prevents duplicate instrumentation and redundant network payloads. The three SDKs overlap in error capture but diverge sharply in session context, distributed tracing protocol, and vendor lock-in.

Capability Sentry @sentry/browser Datadog RUM OpenTelemetry Web
Error capture (auto) Yes — hooks window.onerror and unhandledrejection Yes — same global hooks No — requires manual bridge
Session context Breadcrumbs + user scope Full session replay + user journey No built-in concept
Distributed tracing Sentry-proprietary + W3C headers W3C or Datadog headers W3C standard only
PII scrubbing API beforeSend callback beforeSend callback Custom span processor
Source map symbolication Server-side via Sentry platform Server-side via Datadog Collector-side — backend dependent
Vendor lock-in High — Sentry platform required High — Datadog platform required Low — OTLP is a standard
Bundle size (minified+gzip) ~28 KB ~35 KB ~18 KB (core only)

For greenfield projects that prioritize long-term portability, OpenTelemetry Web is the lowest-lock-in choice. For teams that need error lifecycle management (assignment, resolution, regression alerts) and are comfortable with vendor cost, Sentry’s issue tracker has no meaningful equivalent in the OTel ecosystem. Running Sentry for errors plus OpenTelemetry for traces is a common pattern — Sentry’s @sentry/opentelemetry bridge package makes them interoperate without double-counting errors.

Datadog RUM is most compelling when the team already uses Datadog APM for backend services. Correlating a frontend datadogRum session with a backend APM trace via traceparent headers requires zero additional tooling when both sides are Datadog. The same correlation across Sentry frontend and Datadog APM requires custom traceparent propagation code.

Production Telemetry Integration

Symbolication of minified stack traces is a prerequisite for actionable error reports from any of the three SDKs. Sentry handles symbolication server-side using uploaded .map artifacts. Review Uploading Source Maps from GitHub Actions to Sentry to integrate map uploads into your CI pipeline before promoting a release.

The symbolication contract is identical across every backend: the browser reports a minified frame such as main.a1b2.js:1:24500, the platform looks up the matching .map artifact by release identifier, and only then can it resolve that offset back to an original file, line, and column. If the release recorded on the event and the release attached to the uploaded artifact disagree by even a single character, the lookup misses and you are left reading obfuscated frames. The pipeline below is worth internalizing because all three SDKs implement the same three stages, differing only in where the map is stored and which service performs the lookup.

Symbolication PipelineA minified stack is matched to an uploaded source map by release identifier, producing a readable, symbolicated stack trace.Minified stackMatch .mapReadable trace

For Datadog RUM, source map uploads use the datadog-ci CLI:

# Upload source maps to Datadog — run after your build step, before deploy
npx datadog-ci sourcemaps upload ./dist \
  --service my-frontend-app \
  --release-version "$RELEASE_VERSION" \
  --minified-path-prefix "https://cdn.example.com/assets/"

For OpenTelemetry, stack frame symbolication is the responsibility of your OTLP backend. If you route spans to Grafana Tempo or Jaeger, those platforms do not perform browser symbolication natively — run a symbolication proxy or pre-symbolicate stacks using the source-map npm package before emitting spans.

All three SDKs support release correlation. Align the release / version field value with your CI build identifier (git SHA or semantic version tag) and use the same identifier for source map artifact uploads. Mismatches between the release field and the artifact key cause symbolication to silently fall back to raw minified frames.

Verification & Testing

Validate SDK initialization without triggering real errors in production:

// Run this in the browser console after page load to verify Sentry is active
console.log('Sentry hub client:', !!Sentry.getCurrentHub().getClient());
// Expected output: Sentry hub client: true

// Trigger a test error — appears in Sentry under the configured release
Sentry.captureException(new Error('SDK smoke test'));

For Datadog RUM, use the browser extension “Datadog RUM Debugger” or inspect network requests to browser-intake-datadoghq.com in DevTools → Network. Each page view generates a rum request within seconds of init.

For OpenTelemetry, enable the ConsoleSpanExporter alongside OTLPTraceExporter during development:

import { ConsoleSpanExporter } from '@opentelemetry/sdk-trace-base';
// Add during development only — remove before production build
provider.addSpanProcessor(new SimpleSpanProcessor(new ConsoleSpanExporter()));

This prints every span to the browser console as JSON, letting you verify attribute presence and span hierarchy before routing to a real collector.

Failure Modes & Edge Cases

Scenario Root Cause Fix
Sentry events show <unknown> for all file names Source maps not uploaded or release tag mismatch between SDK init and sentry-cli upload Align release value exactly with the --release flag passed to sentry-cli sourcemaps upload
Datadog RUM beforeSend returning false drops the event but the network request still fires beforeSend is called after event serialization; Datadog still initiates the request before the hook resolves in some SDK versions Upgrade to @datadog/[email protected]+ which evaluates beforeSend pre-serialization
OpenTelemetry spans never appear in the collector provider.register() not called before registerInstrumentations or BatchSpanProcessor never flushed on page unload Call provider.register() before any instrumentation; add window.addEventListener('visibilitychange', () => provider.forceFlush())
tracesSampleRate: 1.0 causes Sentry quota exhaustion 100% performance transaction sampling on high-traffic pages floods the ingest quota Lower tracesSampleRate to 0.01–0.1; use tracesSampler function for per-route control
Cross-origin script errors arrive with empty stacks in all three SDKs Missing crossorigin="anonymous" attribute or missing Access-Control-Allow-Origin header on script responses Add crossorigin="anonymous" to all script tags and configure CORS headers on the CDN origin
Sentry beforeSend returning null still charges quota In older SDK versions beforeSend ran after the event was counted against the quota Upgrade to @sentry/browser@8+ and use beforeSendTransaction for transaction filtering

FAQ

What is the difference between sampleRate and tracesSampleRate in Sentry? sampleRate (0.0–1.0) controls what fraction of error events are sent to Sentry. tracesSampleRate controls what fraction of page loads start a performance transaction and attach distributed trace headers to outgoing requests. They are independent. Setting tracesSampleRate: 0 disables distributed tracing while leaving error capture at 100%.

Can I run Sentry and OpenTelemetry simultaneously without double-counting errors? Yes, with explicit coordination. Initialize Sentry with integrations: [] to disable its default fetch/XHR instrumentation and handle network span creation through OpenTelemetry only. Use Sentry’s beforeSend exclusively for error events and OTel for traces. Sentry also ships a first-party @sentry/opentelemetry package that bridges the two via the OTel SDK natively.

Does Datadog RUM replace the need for a separate error tracking tool like Sentry? Datadog RUM captures JavaScript errors as part of session context, but its error grouping and stack trace resolution capabilities are less mature than Sentry’s issue tracker. For teams already invested in Sentry for error workflows and Datadog for infrastructure APM, running both is common: Sentry handles error lifecycle and Datadog handles full session context and distributed traces. A pragmatic split is to let Datadog own session context and performance while Sentry owns the error lifecycle, wiring both to the same traceparent header so a single frontend event can be pivoted from in either platform without maintaining two divergent sets of dashboards.

How do I ensure SDK initialization runs before any module that might throw? In Vite/webpack, create a dedicated instrumentation.js file and import it as the first line of your entry file (main.js / index.js). Do not use dynamic import() for the SDK — static imports are evaluated in declaration order, but dynamic imports are scheduled as microtasks and may resolve after synchronous module evaluation errors have already occurred.