Propagating Trace Context from Browser fetch Calls

Set propagateTraceHeaderCorsUrls to an explicit list of your own API origins — it defaults to empty, so a freshly configured web SDK produces browser spans that never reach the backend, and a wildcard sends your trace IDs to every third-party endpoint the page touches.

Context and when it matters

Everything about browser-to-backend continuity comes down to one header on one request. The browser creates a span for the fetch, injects traceparent, and the server adopts it — or does not, in which case the two halves of the user’s experience live in separate traces and nobody can connect a slow page to a slow query.

Two configuration mistakes account for nearly all failures here, and they are opposites. Too narrow — the default empty allow-list — and no header is ever sent, so the setup appears broken with no error anywhere. Too wide — a /.*/ regex — and every analytics beacon, ad pixel, and third-party widget receives your internal trace identifiers, which is both an information leak and a reliable way to break requests to services that reject unknown headers.

What the allow-list controls

Which outbound requests get a traceparent A browser page makes four requests. Two to the application API match the allow-list and carry the traceparent header into the backend trace. One to a third-party analytics endpoint and one to a CDN do not match, so no header is added and no trace ID leaves the origin. Allow-list: /^https:\/\/api\.example\.com/ page app.example.com api.example.com/dashboard · traceparent ✓ api.example.com/orders · traceparent ✓ analytics.vendor.io/collect · no header cdn.example.net/bundle.js · no header Requests outside the list still get a browser span — they just do not carry context, and they do not trigger a CORS preflight.

Implementation

// Fetch and XHR instrumentation with an explicit allow-list.
import { FetchInstrumentation } from '@opentelemetry/instrumentation-fetch';
import { XMLHttpRequestInstrumentation } from '@opentelemetry/instrumentation-xml-http-request';

const API_ORIGINS = [
  /^https:\/\/api\.example\.com/,
  /^https:\/\/checkout\.example\.com/,
  // Same-origin requests are matched by a relative-path pattern.
  /^\//,
];

registerInstrumentations({
  instrumentations: [
    new FetchInstrumentation({
      propagateTraceHeaderCorsUrls: API_ORIGINS,
      // Do not create spans for the exporter's own requests — that recursion
      // is a real and confusing source of runaway span volume.
      ignoreUrls: [/\/v1\/traces$/],
      clearTimingResources: true,
      applyCustomAttributesOnSpan(span, request, result) {
        // Resource timing turns one fetch span into a useful breakdown.
        if (result instanceof Response) {
          span.setAttribute('http.response.status_code', result.status);
        }
      },
    }),
    new XMLHttpRequestInstrumentation({
      propagateTraceHeaderCorsUrls: API_ORIGINS,
      ignoreUrls: [/\/v1\/traces$/],
    }),
  ],
});

ignoreUrls on the exporter endpoint deserves emphasis. Without it, the OTLP export request is itself a fetch, which creates a span, which eventually triggers another export — a feedback loop that shows up as steadily climbing span volume from idle browser tabs.

Keeping the context across async UI code

The header can only be injected if there is an active span when fetch is called. In a browser that is harder than on a server, because promise chains, event handlers, and framework schedulers all break the synchronous call stack.

// ZoneContextManager restores the active context across async boundaries.
// Without it, a fetch inside a click handler's promise chain has no parent
// and produces an orphaned span with no traceparent.
provider.register({ contextManager: new ZoneContextManager() });

// Explicit control where a framework's scheduler defeats zone tracking:
import { context, trace } from '@opentelemetry/api';

async function loadDashboard() {
  const span = tracer.startSpan('load dashboard');
  // Everything inside this callback sees `span` as the active span, including
  // fetches issued from awaited promises several frames deep.
  return context.with(trace.setSpan(context.active(), span), async () => {
    try {
      const [widgets, alerts] = await Promise.all([
        fetch('/api/widgets').then((r) => r.json()),
        fetch('/api/alerts').then((r) => r.json()),
      ]);
      return { widgets, alerts };
    } finally {
      span.end();
    }
  });
}

Both fetches above become children of load dashboard and both carry a traceparent derived from it, so the backend sees two server spans under one browser-side parent — which is exactly the structure that makes “this page view was slow because of these two calls” a single query.

Doing it without the SDK

If the page budget cannot absorb the web SDK, header injection alone is a few dozen lines. You lose browser spans and keep continuity:

// Minimal propagation: generate ids, inject the header, no spans, ~400 bytes.
const hex = (n) => Array.from(crypto.getRandomValues(new Uint8Array(n)))
  .map((b) => b.toString(16).padStart(2, '0')).join('');

const originalFetch = window.fetch;
window.fetch = function (input, init = {}) {
  const url = typeof input === 'string' ? input : input.url;
  if (!API_ORIGINS.some((re) => re.test(url))) return originalFetch(input, init);

  const headers = new Headers(init.headers || (input instanceof Request ? input.headers : {}));
  // 32 hex trace id, 16 hex span id, sampled flag set.
  headers.set('traceparent', `00-${hex(16)}-${hex(8)}-01`);
  return originalFetch(input, { ...init, headers });
};

This is a legitimate choice for a marketing site or a widget where the backend trace is the only thing you need. It is not a substitute for the SDK when you care about document-load timing or interaction latency, and it starts a new trace per request rather than grouping a page view.

What the browser span adds beyond the header

Injecting the header is enough to join the traces. Creating a browser span in addition is what makes the browser’s own contribution measurable, and the difference matters more than it first appears.

A backend server span starts when the request arrives at your ingress. The browser’s fetch span starts when the application called fetch and ends when the response body is available. The gap between the two — often substantial — contains DNS resolution, connection setup, TLS negotiation, request queuing behind the browser’s per-origin connection limit, and response parsing. On a mobile connection those can exceed the server’s entire processing time, and none of it is visible from the backend.

The fetch instrumentation also attaches resource-timing data, which breaks that gap down further. When a page issues twelve API calls at once, the browser queues most of them; the resulting delay is invisible to every server-side measurement, appears to the user as slowness, and shows up clearly as a stack of fetch spans whose start times are staggered while their server spans are not.

There is a cost, and it is worth naming. Every fetch span is a span, so a page making forty API calls produces forty browser spans on top of forty server spans. For an application with heavy client-side interaction that can dominate browser telemetry volume. The mitigation is the same as anywhere else — sample, or restrict span creation to the calls that matter — but the decision should be deliberate rather than a surprise discovered on the invoice.

If you only take one thing from the distinction: the header gives you correlation, the span gives you measurement. Applications that only need to answer “which backend work did this page view cause” can stop at the header; applications trying to answer “why did this page feel slow” need the spans.

Allow-list scope, and what each choice leaks or breaks empty (default) no continuity at all *looks broken, no error your API origins correct the intended setting all origins trace ids sent to third parties *leak plus CORS failures exporter endpoint included self-tracing loop *runaway span volume Allow-list scope, and what each choice leaks or breaks setting effect note empty (default) no continuity at all looks broken, no error your API origins correct the intended setting all origins trace ids sent to third parties leak plus CORS failures exporter endpoint included self-tracing loop runaway span volume Two of these four are silent failures, which is why this option deserves an explicit review.

Verification

# 1. In devtools, check the request headers on an API call:
#      traceparent: 00-<32 hex>-<16 hex>-01
# 2. Confirm no header on a third-party request — that is the leak check.
# 3. Take the trace id and confirm both sides are present:
curl -s "http://tempo:3200/api/traces/${TRACE_ID}" \
  | jq -r '[.batches[].resource.attributes[]
            | select(.key=="service.name") | .value.stringValue] | unique'
# ["dashboard-api","dashboard-web"]   ← both services, one trace
Bundle cost of browser tracing options (gzipped) Bundle cost of browser tracing options (gzipped). hand-rolled header injection ≈0.4 KB; fetch instrumentation only ≈22 KB; full web SDK ≈58 KB; web SDK + attribution ≈64 KB Bundle cost of browser tracing options (gzipped) hand-rolled header injection ≈0.4 KB fetch instrumentation only ≈22 KB full web SDK ≈58 KB web SDK + attribution ≈64 KB Load any of them after first paint — none belongs on the critical rendering path.

Common pitfalls

  • Leaving the allow-list empty. The default. Browser spans exist, backend spans exist, and nothing connects them.
  • Using a permissive regex. Sends internal trace IDs to third parties and triggers preflights that some of them will reject, breaking the request entirely.
  • Forgetting the exporter endpoint. Self-tracing the export request creates a feedback loop.
  • Service workers reconstructing requests. A worker that builds a new Request drops headers added by instrumentation; re-inject inside the worker or exclude those routes.
  • Assuming same-origin needs no configuration. Relative URLs are same-origin and do not need CORS, but they still need to match the allow-list pattern to have the header injected.
  • Missing the CORS side. Injection without a matching Access-Control-Allow-Headers blocks the request outright — see configuring CORS for traceparent headers.

One last check worth adding to a release routine: confirm the allow-list still matches your API origins after any change to hostnames or environments. A staging origin that was never added produces silently disconnected traces in exactly the environment where you would have caught the problem.


Related

↑ Back to Browser-to-Backend Trace Continuity