Testing OpenTelemetry Browser Instrumentation
Browser instrumentation is the hardest part of OpenTelemetry to test. Unlike server-side code, where you can inject an in-memory exporter directly, browser code runs in a JavaScript runtime that has no concept of "injectable test components" at the module level. You need strategies for both unit-level testing (simulated browser environment) and E2E testing (real browser with intercepted exports).
This post covers how to test OpenTelemetry browser instrumentation in Jest/Vitest with a simulated DOM, how to verify context propagation to the backend in Playwright, and how to debug missing browser traces.
Setting Up @opentelemetry/sdk-trace-web
The relevant packages:
npm install \
@opentelemetry/sdk-trace-web \
@opentelemetry/sdk-trace-base \
@opentelemetry/resources \
@opentelemetry/semantic-conventions \
@opentelemetry/context-zone \
@opentelemetry/instrumentation-fetch \
@opentelemetry/instrumentation-document-load \
@opentelemetry/propagator-b3A typical browser SDK setup:
// src/telemetry/browser.ts
import { WebTracerProvider } from '@opentelemetry/sdk-trace-web';
import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { ZoneContextManager } from '@opentelemetry/context-zone';
import { registerInstrumentations } from '@opentelemetry/instrumentation';
import { FetchInstrumentation } from '@opentelemetry/instrumentation-fetch';
import { DocumentLoadInstrumentation } from '@opentelemetry/instrumentation-document-load';
import { Resource } from '@opentelemetry/resources';
import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions';
import { trace, context } from '@opentelemetry/api';
let provider: WebTracerProvider | null = null;
export function initBrowserTelemetry(serviceName: string, endpoint: string) {
const resource = new Resource({
[SemanticResourceAttributes.SERVICE_NAME]: serviceName,
[SemanticResourceAttributes.SERVICE_VERSION]: process.env.APP_VERSION || 'unknown',
});
provider = new WebTracerProvider({ resource });
const exporter = new OTLPTraceExporter({ url: endpoint });
provider.addSpanProcessor(new BatchSpanProcessor(exporter));
provider.register({
contextManager: new ZoneContextManager(),
});
registerInstrumentations({
instrumentations: [
new FetchInstrumentation({
propagateTraceHeaderCorsUrls: [/https:\/\/api\.example\.com/],
}),
new DocumentLoadInstrumentation(),
],
});
return provider;
}
export function getTracer(name: string) {
return trace.getTracer(name);
}Testing Page Load Spans in Jest
For Jest/Vitest testing, use @opentelemetry/sdk-trace-base's InMemorySpanExporter and a SimpleSpanProcessor to capture spans synchronously:
// src/telemetry/__tests__/browser.test.ts
import { SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base';
import { InMemorySpanExporter } from '@opentelemetry/sdk-trace-base';
import { WebTracerProvider } from '@opentelemetry/sdk-trace-web';
import { DocumentLoadInstrumentation } from '@opentelemetry/instrumentation-document-load';
import { registerInstrumentations } from '@opentelemetry/instrumentation';
import { trace } from '@opentelemetry/api';
// Note: Jest runs in jsdom which simulates the browser environment
// DocumentLoadInstrumentation hooks into the Navigation Timing API
describe('Browser Telemetry - Document Load', () => {
let spanExporter: InMemorySpanExporter;
let provider: WebTracerProvider;
beforeEach(() => {
spanExporter = new InMemorySpanExporter();
provider = new WebTracerProvider();
provider.addSpanProcessor(new SimpleSpanProcessor(spanExporter));
provider.register();
registerInstrumentations({
instrumentations: [new DocumentLoadInstrumentation()],
tracerProvider: provider,
});
});
afterEach(() => {
spanExporter.reset();
provider.shutdown();
trace.disable();
});
it('emits documentLoad span on page initialization', async () => {
// DocumentLoadInstrumentation hooks into window.performance.timing
// In jsdom, we need to simulate the timing data
Object.defineProperty(window, 'performance', {
value: {
timing: {
navigationStart: 1000,
fetchStart: 1010,
domainLookupStart: 1015,
domainLookupEnd: 1025,
connectStart: 1025,
connectEnd: 1035,
requestStart: 1035,
responseStart: 1100,
responseEnd: 1200,
domLoading: 1200,
domInteractive: 1300,
domContentLoadedEventStart: 1350,
domContentLoadedEventEnd: 1360,
loadEventStart: 1400,
loadEventEnd: 1450,
},
getEntriesByType: () => [],
getEntriesByName: () => [],
},
writable: true,
});
// Trigger document load instrumentation
const { DocumentLoadInstrumentation: DLI } =
await import('@opentelemetry/instrumentation-document-load');
const instrumentation = new DLI();
instrumentation.setTracerProvider(provider);
instrumentation.enable();
// Allow microtasks to flush
await new Promise(resolve => setTimeout(resolve, 100));
const spans = spanExporter.getFinishedSpans();
const docLoadSpan = spans.find(s => s.name === 'documentLoad');
expect(docLoadSpan).toBeDefined();
expect(docLoadSpan?.attributes['document.load']).toBeDefined();
});
});Testing Custom User Action Spans
Custom spans for user interactions are the most important thing to test, because these are the spans you write yourself:
// src/features/checkout/CheckoutForm.tsx
import { getTracer } from '../../telemetry/browser';
import { SpanStatusCode } from '@opentelemetry/api';
const tracer = getTracer('checkout.ui');
export async function submitCheckout(formData: CheckoutFormData): Promise<void> {
const span = tracer.startSpan('checkout.submit', {
attributes: {
'checkout.payment_method': formData.paymentMethod,
'checkout.item_count': formData.items.length,
}
});
const ctx = trace.setSpan(context.active(), span);
try {
await context.with(ctx, async () => {
const response = await fetch('/api/checkout', {
method: 'POST',
body: JSON.stringify(formData),
});
if (!response.ok) {
throw new Error(`Checkout failed: ${response.status}`);
}
const result = await response.json();
span.setAttribute('checkout.order_id', result.orderId);
});
span.setStatus({ code: SpanStatusCode.OK });
} catch (error) {
span.setStatus({
code: SpanStatusCode.ERROR,
message: (error as Error).message
});
span.recordException(error as Error);
throw error;
} finally {
span.end();
}
}// src/features/checkout/__tests__/CheckoutForm.test.ts
import { InMemorySpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base';
import { WebTracerProvider } from '@opentelemetry/sdk-trace-web';
import { trace, SpanStatusCode } from '@opentelemetry/api';
import { submitCheckout } from '../CheckoutForm';
describe('Checkout Form Instrumentation', () => {
let spanExporter: InMemorySpanExporter;
let provider: WebTracerProvider;
beforeEach(() => {
spanExporter = new InMemorySpanExporter();
provider = new WebTracerProvider();
provider.addSpanProcessor(new SimpleSpanProcessor(spanExporter));
provider.register();
});
afterEach(() => {
spanExporter.reset();
provider.shutdown();
trace.disable();
});
it('emits checkout.submit span with correct attributes', async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ orderId: 'ord-test-1' }),
} as Response);
await submitCheckout({
paymentMethod: 'visa',
items: [{ id: '1', price: 999 }, { id: '2', price: 1499 }],
});
const spans = spanExporter.getFinishedSpans();
const checkoutSpan = spans.find(s => s.name === 'checkout.submit');
expect(checkoutSpan).toBeDefined();
expect(checkoutSpan?.attributes['checkout.payment_method']).toBe('visa');
expect(checkoutSpan?.attributes['checkout.item_count']).toBe(2);
expect(checkoutSpan?.attributes['checkout.order_id']).toBe('ord-test-1');
expect(checkoutSpan?.status.code).toBe(SpanStatusCode.UNSET);
});
it('marks checkout span as ERROR when API fails', async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: false,
status: 402,
} as Response);
await expect(submitCheckout({
paymentMethod: 'visa',
items: [{ id: '1', price: 99999 }],
})).rejects.toThrow('Checkout failed: 402');
const spans = spanExporter.getFinishedSpans();
const checkoutSpan = spans.find(s => s.name === 'checkout.submit');
expect(checkoutSpan?.status.code).toBe(SpanStatusCode.ERROR);
const events = checkoutSpan?.events || [];
const exceptionEvent = events.find(e => e.name === 'exception');
expect(exceptionEvent).toBeDefined();
expect(exceptionEvent?.attributes?.['exception.message']).toContain('402');
});
it('span ends even when exception is thrown', async () => {
global.fetch = jest.fn().mockRejectedValue(new Error('Network error'));
await expect(submitCheckout({
paymentMethod: 'visa',
items: [],
})).rejects.toThrow('Network error');
const spans = spanExporter.getFinishedSpans();
const checkoutSpan = spans.find(s => s.name === 'checkout.submit');
// Span must have an end time — if it's undefined, the span was never ended
expect(checkoutSpan?.endTime).toBeDefined();
expect(checkoutSpan?.endTime[0]).toBeGreaterThan(0);
});
});Testing Context Propagation to Backend in Playwright
The most important browser instrumentation test: does the traceparent header actually reach your backend API?
// e2e/telemetry/context-propagation.spec.ts
import { test, expect } from '@playwright/test';
test.describe('OpenTelemetry Context Propagation', () => {
test('checkout API request carries traceparent header', async ({ page }) => {
const capturedHeaders: Record<string, string> = {};
// Intercept the checkout API call and capture its headers
await page.route('**/api/checkout', async route => {
const request = route.request();
const headers = await request.allHeaders();
Object.assign(capturedHeaders, headers);
// Forward the request normally
await route.continue();
});
await page.goto('/checkout');
await page.fill('[name="email"]', 'test@example.com');
await page.fill('[name="card-number"]', '4111111111111111');
await page.fill('[name="expiry"]', '12/26');
await page.fill('[name="cvv"]', '123');
await page.click('[type="submit"]');
// Wait for the API call to complete
await page.waitForResponse('**/api/checkout');
// Verify traceparent was sent
expect(capturedHeaders['traceparent']).toBeDefined();
expect(capturedHeaders['traceparent']).toMatch(
/^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$/
);
});
test('traceparent trace ID is consistent across same page session', async ({ page }) => {
const traceIds: string[] = [];
// Intercept multiple API calls and collect trace IDs
await page.route('**/api/**', async route => {
const request = route.request();
const traceparent = (await request.allHeaders())['traceparent'];
if (traceparent) {
// Extract trace ID from traceparent: 00-{traceId}-{spanId}-{flags}
const parts = traceparent.split('-');
if (parts.length === 4) {
traceIds.push(parts[1]);
}
}
await route.continue();
});
await page.goto('/checkout');
// Trigger multiple API calls in the same session
await page.click('[data-testid="load-cart"]'); // GET /api/cart
await page.click('[data-testid="apply-coupon"]'); // POST /api/coupons
await page.waitForResponse('**/api/cart');
await page.waitForResponse('**/api/coupons');
// All requests in the same page session should share the same trace ID
// (root trace started by documentLoad spans)
const uniqueTraceIds = new Set(traceIds);
expect(uniqueTraceIds.size).toBe(1);
});
test('browser trace connects to backend trace', async ({ page, request: apiRequest }) => {
let browserTraceparent: string | null = null;
await page.route('**/api/checkout', async route => {
const headers = await route.request().allHeaders();
browserTraceparent = headers['traceparent'] || null;
await route.continue();
});
await page.goto('/checkout');
await page.fill('[name="card-number"]', '4111111111111111');
await page.fill('[name="expiry"]', '12/26');
await page.fill('[name="cvv"]', '123');
await page.click('[type="submit"]');
await page.waitForResponse('**/api/checkout');
expect(browserTraceparent).not.toBeNull();
// Extract trace ID from the traceparent header
const traceId = browserTraceparent!.split('-')[1];
// Wait a moment for spans to be exported to tracing backend
await page.waitForTimeout(5000);
// Query Tempo/Jaeger to verify the trace exists in the backend
const traceResponse = await apiRequest.get(
`http://localhost:3200/api/traces/${traceId}`
);
expect(traceResponse.ok()).toBeTruthy();
const trace = await traceResponse.json();
const services = new Set(
trace.batches?.map((b: any) =>
b.resource?.attributes?.find((a: any) => a.key === 'service.name')?.value?.stringValue
)
);
// Both frontend and backend should appear in the trace
expect(services).toContain('checkout-frontend');
expect(services).toContain('payments-api');
});
});Testing with an In-Browser OTLP Interceptor
For Playwright tests where you want to capture exported spans without a full tracing backend:
// e2e/telemetry/span-capture.spec.ts
import { test, expect } from '@playwright/test';
test('page load emits OpenTelemetry spans', async ({ page }) => {
const capturedSpans: any[] = [];
// Intercept OTLP HTTP export calls
await page.route('**/v1/traces', async route => {
const body = route.request().postDataJSON();
// Extract spans from OTLP format
for (const resourceSpan of body?.resourceSpans || []) {
for (const scopeSpan of resourceSpan.scopeSpans || []) {
capturedSpans.push(...(scopeSpan.spans || []));
}
}
// Return 200 to the SDK
await route.fulfill({
status: 200,
body: JSON.stringify({ partialSuccess: {} }),
});
});
await page.goto('/');
// Wait for BatchSpanProcessor to flush (default: 5 seconds)
await page.waitForTimeout(6000);
expect(capturedSpans.length).toBeGreaterThan(0);
// Verify documentLoad span was emitted
const docLoadSpan = capturedSpans.find(s => s.name === 'documentLoad');
expect(docLoadSpan).toBeDefined();
// Verify span has timing attributes
expect(docLoadSpan?.attributes?.find((a: any) =>
a.key === 'document.interactive'
)).toBeDefined();
});
test('page load emits resourceFetch spans for loaded assets', async ({ page }) => {
const capturedSpans: any[] = [];
await page.route('**/v1/traces', async route => {
const body = route.request().postDataJSON();
for (const rs of body?.resourceSpans || []) {
for (const ss of rs.scopeSpans || []) {
capturedSpans.push(...(ss.spans || []));
}
}
await route.fulfill({ status: 200, body: JSON.stringify({}) });
});
await page.goto('/');
await page.waitForTimeout(6000);
const resourceFetchSpans = capturedSpans.filter(s => s.name === 'resourceFetch');
// Page should have fetched some resources (JS, CSS)
expect(resourceFetchSpans.length).toBeGreaterThan(0);
// Each resource fetch span should have a URL attribute
for (const span of resourceFetchSpans) {
const httpUrlAttr = span.attributes?.find((a: any) => a.key === 'http.url');
expect(httpUrlAttr).toBeDefined();
}
});Debugging Missing Browser Traces
When browser traces aren't appearing in your backend, work through this checklist:
1. Verify the SDK is initialized
// In browser devtools:
window.__OTEL_TRACE_PROVIDER__ // should be defined if provider was registered2. Check the OTLP endpoint is reachable
// In devtools Network tab:
// Look for POST requests to /v1/traces
// Check status code — 200 = exported successfully
// Check CORS headers if sending to a different origin3. Verify traceparent headers on outbound requests
// In devtools Network tab:
// Select an API request
// Check Request Headers for 'traceparent'
// Format: 00-{32 hex}-{16 hex}-{2 hex}4. Force immediate flush in tests
// BatchSpanProcessor defaults to 5s flush interval — too slow for tests
// Use SimpleSpanProcessor instead, or force flush:
await provider.forceFlush();
// Or configure short batch timeout:
provider.addSpanProcessor(new BatchSpanProcessor(exporter, {
scheduledDelayMillis: 500, // flush every 500ms instead of 5000ms
exportTimeoutMillis: 3000,
}));5. Check CORS configuration for the OTLP collector
# otel-collector-config.yaml
receivers:
otlp:
protocols:
http:
cors:
allowed_origins:
- "http://localhost:3000" # your frontend origin
allowed_headers:
- "traceparent"
- "tracestate"Browser telemetry testing requires more setup than server-side testing, but the payoff is high: you get verified end-to-end trace coverage from the user's browser click all the way through your backend services, with confidence that the trace IDs are connected and the spans have the attributes your dashboards depend on.