Configuring OpenTelemetry for Frontend Performance

You want browser navigation, document load, and outbound fetch/XHR timing to arrive as distributed traces in your own backend — not a vendor SaaS — so you can correlate frontend spans with the server spans they trigger. This page is the concrete, runnable setup for that: install the web SDK and auto-instrumentations, stand up a WebTracerProvider, batch spans, and export them over OTLP/HTTP to a Collector you control. It sits under OpenTelemetry for Web RUM, which frames where tracing fits alongside beacon-based field measurement. If your goal is shipping the Core Web Vitals numbers themselves rather than request traces, pair this with a proper web-vitals API implementation; tracing answers “what did this session do”, vitals answer “how fast did it feel”.

Browser OTel export pipeline Auto-instrumentations create spans, a WebTracerProvider batches them, OTLPTraceExporter posts JSON over HTTP to a Collector that processes and exports to a trace store. Browser document-load fetch + xhr spans WebTracerProvider BatchSpanProcessor queues + flushes OTLP/HTTP JSON Collector otlp receiver batch + CORS Trace store Tempo / Jaeger traceparent header propagates to your API
Spans are batched in the browser and posted over OTLP/HTTP to a Collector you own, which fans out to a trace store. Aggregate span durations at p75 alongside your field beacon collection data.

Prerequisites

Before the first span leaves the browser, have these in place:

  • A bundler (Vite, webpack, or esbuild) — the web SDK is ESM and ships as multiple packages.
  • An OpenTelemetry Collector reachable from the browser over HTTPS, with CORS allowed for your site origin. A local otelcol-contrib binary is enough to start.
  • A downstream trace store (Grafana Tempo, Jaeger, or any OTLP-compatible backend) wired to the Collector. The browser never talks to it directly.
  • Decide your endpoint shape now: OTLP/HTTP for traces is POST {base}/v1/traces. The browser exporter uses HTTP/JSON, not gRPC — gRPC needs HTTP/2 trailers that browsers cannot send.
  • SDK version awareness: the snippets below target the JS SDK 1.x line of @opentelemetry/sdk-trace-web. Span processors are passed to the provider constructor; the older provider.addSpanProcessor() mutator is removed in 2.x, so prefer the constructor form to stay forward-compatible.

How to configure browser OpenTelemetry

Step 1 — Install the web SDK and auto-instrumentations

npm install \
  @opentelemetry/sdk-trace-web \
  @opentelemetry/sdk-trace-base \
  @opentelemetry/resources \
  @opentelemetry/semantic-conventions \
  @opentelemetry/exporter-trace-otlp-http \
  @opentelemetry/context-zone \
  @opentelemetry/instrumentation \
  @opentelemetry/auto-instrumentations-web

Why: sdk-trace-web is the browser provider; sdk-trace-base carries the span processors and exporters; exporter-trace-otlp-http is the JSON-over-HTTP transport. auto-instrumentations-web is a meta-package that bundles document-load, fetch, XHR, and user-interaction instrumentations so you do not hand-wire each one. context-zone is what keeps a span’s context attached across async boundaries (timers, promises) in the browser, where there is no AsyncLocalStorage.

Step 2 — Define a resource so spans are attributable

import { resourceFromAttributes } from '@opentelemetry/resources';
import {
  ATTR_SERVICE_NAME,
  ATTR_SERVICE_VERSION,
} from '@opentelemetry/semantic-conventions';

export const resource = resourceFromAttributes({
  [ATTR_SERVICE_NAME]: 'storefront-web',
  [ATTR_SERVICE_VERSION]: import.meta.env.VITE_APP_VERSION ?? 'dev',
  'deployment.environment': import.meta.env.MODE,
});

Why: resource attributes are attached to every span and let you filter traces by service and release in the store. Keep them low-cardinality — service name, version, environment. Never put a user id, full URL, or session token here; those explode cardinality and make the backend index unusable.

Step 3 — Configure the OTLP/HTTP exporter to your Collector

import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';

export const exporter = new OTLPTraceExporter({
  // Collector OTLP/HTTP traces endpoint. Same-origin reverse-proxy
  // avoids CORS preflight entirely; cross-origin needs CORS on the Collector.
  url: 'https://collector.example.com/v1/traces',
  headers: {}, // browsers reject custom auth headers without CORS allow-headers
  // Cap retries so a dead Collector never stalls page unload.
  timeoutMillis: 5000,
});

Why: the exporter posts protobuf-encoded JSON to /v1/traces. The cleanest deployment is to reverse-proxy /v1/traces on your own origin straight to the Collector — that sidesteps the CORS preflight that otherwise fires on every cross-origin POST. Keep timeoutMillis short so a slow Collector cannot hold the page hostage during unload.

Step 4 — Build the provider with a BatchSpanProcessor

import { WebTracerProvider } from '@opentelemetry/sdk-trace-web';
import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base';
import { ZoneContextManager } from '@opentelemetry/context-zone';
import { resource } from './resource.js';
import { exporter } from './exporter.js';

const provider = new WebTracerProvider({
  resource,
  spanProcessors: [
    new BatchSpanProcessor(exporter, {
      maxQueueSize: 2048,
      maxExportBatchSize: 64,
      scheduledDelayMillis: 5000, // flush at most every 5s
    }),
  ],
});

provider.register({
  // ZoneContextManager keeps span context across async callbacks.
  contextManager: new ZoneContextManager(),
});

export { provider };

Why: BatchSpanProcessor queues spans and ships them in batches instead of one HTTP request per span — essential in the browser, where per-span POSTs would saturate the connection. scheduledDelayMillis: 5000 trades a little freshness for far fewer requests. provider.register() makes this the global tracer and installs the ZoneContextManager, without which fetch spans started inside a promise chain would lose their parent and show up as orphans.

Step 5 — Register document-load and fetch/XHR instrumentation

import { registerInstrumentations } from '@opentelemetry/instrumentation';
import { DocumentLoadInstrumentation } from '@opentelemetry/instrumentation-document-load';
import { FetchInstrumentation } from '@opentelemetry/instrumentation-fetch';
import { XMLHttpRequestInstrumentation } from '@opentelemetry/instrumentation-xml-http-request';

registerInstrumentations({
  instrumentations: [
    new DocumentLoadInstrumentation(),
    new FetchInstrumentation({
      // Inject traceparent only on calls to YOUR API origins.
      propagateTraceHeaderCorsUrls: [/https:\/\/api\.example\.com\/.*/],
      clearTimingResources: true,
    }),
    new XMLHttpRequestInstrumentation({
      propagateTraceHeaderCorsUrls: [/https:\/\/api\.example\.com\/.*/],
    }),
  ],
});

Why: DocumentLoadInstrumentation reads the Navigation Timing entry and emits a documentLoad span tree (DNS, connect, request, response, DOM processing) — this is your closest trace-side analogue to the load timeline that FCP and TTFB analysis covers as field metrics. FetchInstrumentation wraps window.fetch and, critically, injects the traceparent header so the resulting server span links to the browser span. Scope propagateTraceHeaderCorsUrls tightly: matching a third-party origin sends your trace context to systems that did not ask for it and triggers their CORS to reject the unexpected header.

Step 6 — Flush on page hide

import { provider } from './provider.js';

// Force a final export before the tab is frozen or closed.
addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') {
    provider.forceFlush().catch(() => { /* swallow on teardown */ });
  }
}, { capture: true });

Why: the batch processor’s 5-second timer will not fire if the user navigates away first, stranding the last batch. visibilitychange → hidden is the one lifecycle event that reliably fires on mobile tab discards and bfcache freezes — beforeunload and unload do not. forceFlush() drains the queue immediately. This mirrors why field RUM uses the same hidden transition to finalize a beacon collection payload.

Step 7 — Run a minimal Collector

receivers:
  otlp:
    protocols:
      http:
        endpoint: 0.0.0.0:4318
        cors:
          allowed_origins:
            - https://www.example.com
          allowed_headers:
            - traceparent
            - content-type

processors:
  batch:
    timeout: 5s

exporters:
  otlp/tempo:
    endpoint: tempo:4317
    tls:
      insecure: true

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [batch]
      exporters: [otlp/tempo]

Why: the http receiver listens on the OTLP/HTTP port 4318 (the exporter’s /v1/traces resolves under it). The cors block is mandatory for cross-origin browsers — it must allow your exact site origin and the traceparent header, or every export fails the preflight silently. The Collector’s own batch processor smooths bursts before fanning out to Tempo over gRPC, which the browser cannot speak but server-to-server can.

Verifying it works

Confirm the pipeline end to end:

  • DevTools Network panel: filter to v1/traces. You should see periodic POST requests (roughly every 5 s or on tab hide) returning 200 with an empty body. A 204 is also success; a 403/CORS error means the Collector’s allowed_origins does not match.
  • Request headers on your API calls: open a request to api.example.com and confirm a traceparent header like 00-<32 hex trace>-<16 hex span>-01. Its absence means the URL did not match propagateTraceHeaderCorsUrls.
  • Console exporter in dev: temporarily add a SimpleSpanProcessor(new ConsoleSpanExporter()) to see span names (documentLoad, HTTP GET) and durations printed synchronously, proving spans are created even before export is wired.
  • Trace store: search by service.name = storefront-web in Tempo/Jaeger. A document-load trace should show the nested timing spans, and clicking an API span should reveal the linked server span if header propagation worked.

Edge cases & gotchas

Symptom Cause Fix
Exports return CORS error Collector allowed_origins/allowed_headers missing your origin or traceparent Add both to the receiver cors block, or reverse-proxy same-origin
Fetch spans have no parent ZoneContextManager not registered Pass it in provider.register({ contextManager })
Last batch never arrives Relying on beforeunload/unload Flush on visibilitychange → hidden
Third party rejects requests propagateTraceHeaderCorsUrls too broad Restrict the regex to your own API origins only
Span volume floods the Collector No sampling Wrap the provider in a TraceIdRatioBasedSampler; tune via RUM data sampling strategies
gRPC exporter errors in browser Used exporter-trace-otlp-grpc Use exporter-trace-otlp-http — browsers cannot send gRPC trailers

Two timing hazards are worth calling out. First, DocumentLoadInstrumentation must be registered before the load event resolves; lazy-loading the SDK after first paint loses the navigation span entirely, so initialize tracing in your entry module. Second, Safari’s PerformanceObserver exposes a narrower set of buffered resource entries than Chromium, so fetch-span resource correlation can be incomplete on iOS — treat those traces as best-effort and lean on the explicit fetch span timings instead.

FAQ

Should I use OTLP/HTTP or OTLP/gRPC from the browser?

HTTP/JSON only. The gRPC exporter depends on HTTP/2 trailers that browsers cannot produce, so @opentelemetry/exporter-trace-otlp-http is the supported browser transport. Use gRPC for Collector-to-backend hops, not browser-to-Collector.

Why are my fetch spans showing up as orphans with no parent?

Because the ZoneContextManager is not installed. Without it, span context is lost across async callbacks, and a fetch started inside a promise cannot find its parent. Register it via provider.register({ contextManager: new ZoneContextManager() }).

How do I stop browser tracing from overwhelming my Collector?

Add a sampler at the provider level — new TraceIdRatioBasedSampler(0.1) keeps 10% of traces deterministically by trace id, so a trace is either fully kept or fully dropped. Combine with the Collector’s batch processor, and aggregate the surviving durations at p75 rather than averaging.