Instrumenting Browser Errors with OpenTelemetry Web
The OpenTelemetry Web SDK provides a vendor-neutral instrumentation layer, but it does not automatically intercept window.onerror or unhandledrejection events. Wiring browser errors into OTel spans requires explicit bridge code, correct span lifecycle management, and a working OTLP exporter. This page covers the complete path from raw browser error to structured span exception exported to a collector. It is a companion to Integrating Observability SDKs: Sentry, Datadog RUM, and OpenTelemetry and the overarching Core JavaScript Error Handling & Boundaries reference.
Symptom / Trigger
The most common sign of incomplete OTel browser error instrumentation is a collector that receives traces from fetch/XHR auto-instrumentation but shows no browser.error spans. The second symptom is spans with status: UNSET instead of status: ERROR, indicating that recordException was called but setStatus was omitted.
// Span exported to collector — incorrectly missing status and exception event:
{
"name": "browser.error",
"kind": "INTERNAL",
"status": { "code": 0 }, // UNSET — should be ERROR (2)
"events": [], // empty — recordException was never called
"attributes": {}
}
A third failure mode is spans that appear in the exporter but never arrive at the collector. This is almost always caused by BatchSpanProcessor not flushing before the page unloads — spans buffered in memory are discarded when the browser tab closes.
Root Cause Explanation
The OpenTelemetry specification defines recordException as a span method, not a standalone API. Without an active (un-ended) span to attach the exception to, there is nowhere to record the event. The typical mistake is calling span.end() before recordException:
// Broken pattern — span ends before the exception is recorded
const span = tracer.startSpan('browser.error');
span.end(); // span is finalized here
span.recordException(error); // this call is a no-op on a finished span
span.setStatus({ code: SpanStatusCode.ERROR }); // also a no-op
Span operations called after span.end() are silently ignored by the SDK. No warning is emitted. The span exports with status: UNSET and an empty events array.
Step-by-Step Fix
1. Wire global error events to a dedicated OTel tracer
Create a bridge module that subscribes to both the synchronous error event and the async rejection event. Keep the tracer instance at module scope — creating a new tracer per event is wasteful but not incorrect.
// src/otel-error-bridge.js
import { trace, SpanStatusCode } from '@opentelemetry/api';
const tracer = trace.getTracer(
'browser-global-errors', // instrumentation library name
'1.0.0' // instrumentation library version
);
function recordGlobalError(error, attributes = {}) {
const span = tracer.startSpan('browser.error'); // start span first
span.setAttributes({
'error.type': error?.name ?? 'Error',
'browser.language': navigator.language,
...attributes,
});
span.recordException(error); // attach structured exception event
span.setStatus({ // must be set separately from recordException
code: SpanStatusCode.ERROR,
message: error?.message ?? String(error),
});
span.end(); // finalize — triggers BatchSpanProcessor
}
// Wire up global handlers — call this once before any application code
export function installGlobalErrorBridge() {
window.addEventListener('error', (event) => {
if (!event.error) return; // resource errors (img, script) have no error object
recordGlobalError(event.error, {
'error.filename': event.filename,
'error.lineno': event.lineno,
'error.colno': event.colno,
});
});
window.addEventListener('unhandledrejection', (event) => {
const err = event.reason instanceof Error
? event.reason
: new Error(String(event.reason));
recordGlobalError(err, { 'error.type': 'UnhandledRejection' });
});
}
Call installGlobalErrorBridge() from your OTel initialization file, after provider.register(). The tracer must be retrieved after provider registration or it resolves to a no-op implementation.
2. Record span exceptions inside async operations
For errors caught inside business logic, attach the exception to the active span if one exists, or create a child span if you need an isolated event.
import { trace, SpanStatusCode, context } from '@opentelemetry/api';
async function loadUserProfile(userId) {
const tracer = trace.getTracer('user-profile');
const span = tracer.startSpan('user-profile.load');
// Propagate span context into the async execution
return context.with(trace.setSpan(context.active(), span), async () => {
try {
const res = await fetch(`/api/users/${userId}`);
if (!res.ok) {
const httpErr = new Error(`HTTP ${res.status} for /api/users/${userId}`);
span.recordException(httpErr);
span.setStatus({ code: SpanStatusCode.ERROR, message: httpErr.message });
throw httpErr;
}
const data = await res.json();
span.setStatus({ code: SpanStatusCode.OK });
return data;
} catch (err) {
span.recordException(err); // safe to call before span.end()
span.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
throw err; // re-throw so calling code sees the error
} finally {
span.end(); // always end in finally to prevent span leaks
}
});
}
The finally block ensures span.end() is called even when throw exits the try block. Forgetting span.end() creates a span leak — the span remains open indefinitely and may never be exported.
3. Force-flush the BatchSpanProcessor on page unload
BatchSpanProcessor exports spans on a timer (exportTimeoutMillis, default 30 seconds) and when the buffer reaches maxExportBatchSize (default 512 spans). Neither trigger fires reliably when the user closes the tab. Use the visibilitychange event to force a flush.
// src/otel.js — add after provider.register()
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
// forceFlush returns a Promise — use sendBeacon inside to survive page unload
provider.forceFlush().catch(console.error);
}
});
visibilitychange to 'hidden' fires reliably on tab close, navigation, and backgrounding — more reliably than beforeunload or pagehide in mobile browsers. forceFlush triggers the exporter synchronously; the OTLPTraceExporter uses sendBeacon internally when available to survive page dismissal.
4. Export to an OTLP collector with CORS headers configured
The OTLPTraceExporter sends HTTP POST requests with Content-Type: application/json to /v1/traces. Your collector or vendor endpoint must return appropriate CORS headers or the browser will block the request.
# otel-collector-config.yaml — enable CORS on the OTLP/HTTP receiver
receivers:
otlp:
protocols:
http:
endpoint: "0.0.0.0:4318"
cors:
allowed_origins:
- "https://app.example.com"
- "http://localhost:*"
allowed_headers:
- "Content-Type"
Without allowed_origins, the collector receives the data but the browser console shows a CORS error and the OTel SDK’s error callback fires. Verify CORS by checking the preflight OPTIONS response includes Access-Control-Allow-Origin matching your app origin.
Verification
Use the ConsoleSpanExporter during development to inspect exported spans without needing a collector:
import { ConsoleSpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base';
// Add only in development builds
if (import.meta.env.DEV) {
provider.addSpanProcessor(new SimpleSpanProcessor(new ConsoleSpanExporter()));
}
// Trigger a test exception and inspect the console output
const testSpan = tracer.startSpan('test.error.span');
testSpan.recordException(new Error('OTel verification test'));
testSpan.setStatus({ code: SpanStatusCode.ERROR, message: 'test' });
testSpan.end();
// Expected console output (abbreviated):
// {
// traceId: "...", spanId: "...", name: "test.error.span",
// status: { code: 2 }, // 2 = ERROR
// events: [{ name: "exception", attributes: { "exception.message": "OTel verification test", ... } }]
// }
A status.code of 2 confirms SpanStatusCode.ERROR is set correctly. The events array must contain one entry named exception with exception.type, exception.message, and exception.stacktrace attributes.
Edge Cases & Gotchas
trace.getTracer()called beforeprovider.register()returns a no-op tracer. All span operations on a no-op tracer succeed silently but produce no data. Always callprovider.register()before retrieving tracer instances.BatchSpanProcessoris not safe to share betweenWebWorkerand main thread. Each worker context needs its ownWebTracerProviderinstance. Span data from workers must be posted to the main thread viapostMessageand recorded against a span in the main thread context.recordExceptionaccepts any value, not justErrorinstances. Ifevent.reasonfrom anunhandledrejectionis a plain string or object,recordExceptionserializes it. Theexception.typeattribute will beundefinedfor non-Error objects — normalize tonew Error(String(reason))to preserve schema consistency.OTLPTraceExporterwithsendBeaconignores the response status. If your collector returns 4xx (for example, due to an auth header mismatch), the beacon is sent but no error is surfaced. Use a development environment withXMLHttpRequesttransport to catch auth issues before they silently fail in production.
FAQ
Does OpenTelemetry Web automatically capture console.error calls as span events?
No. console.error is not intercepted by any standard OTel instrumentation. You must monkey-patch console.error manually and call span.addEvent or span.recordException inside the wrapper. This is generally not recommended — wire errors from window.onerror and unhandledrejection instead, which capture real thrown exceptions rather than manually logged messages.
Can I attach error spans to an existing fetch span as a child?
Yes, but only if the fetch span’s context is active when you call tracer.startSpan. The FetchInstrumentation sets the active span context during the fetch lifecycle. If your error occurs inside a .then() or .catch() handler on the fetch promise, the active context is still the fetch span’s context — tracer.startSpan('fetch.error') will create a child span automatically.
What is the difference between span.addEvent and span.recordException?
span.recordException(error) is a semantic convention wrapper over span.addEvent('exception', ...). It populates the exception.type, exception.message, and exception.stacktrace attributes according to the OTel specification. span.addEvent creates an arbitrary named event without semantic structure. Always prefer recordException for Error objects — backends like Jaeger and Grafana Tempo render exception events with dedicated UI components.