willianpinho.com Blog
Cover image for The cross-origin observability tax

The cross-origin observability tax

When one team adds a request header, the backend CORS allowlist becomes a coordination point. Here is what that tax actually costs, why it fails silently, and four patterns for handling it.

When one team adds a request header, the backend's CORS allowlist becomes a coordination point. The failure mode of that coupling is invisible.

Every shop is rolling out distributed tracing this year. W3C trace context is becoming the default in OpenTelemetry SDKs, browser instrumentation libraries, and most vendor agents. The instrumentation work looks like a frontend concern: add the SDK, configure the exporter, ship. It is not a frontend concern. It is a contract between the browser and every cross-origin service the browser calls, and the contract enforcement point is your CORS middleware. Here is what that tax actually costs, why it fails silently, and four patterns for handling it before it eats incident time.

What the tax actually is

Observability rollouts add headers to outbound requests. W3C defines traceparent and tracestate (Trace Context spec) plus baggage (a separate W3C Baggage spec). OpenTelemetry propagates all three together when configured to do so. Vendor agents add their own. Datadog: x-datadog-trace-id, x-datadog-parent-id, x-datadog-sampling-priority, x-datadog-origin, x-datadog-tags. Sentry (current SDK): sentry-trace plus baggage with sentry-prefixed entries (the older x-sentry-trace is legacy). AWS X-Ray: x-amzn-trace-id. Feature work adds custom auth or correlation headers on top. None of these are CORS-safelisted.

OpenTelemetry's browser SDKs do not inject these cross-origin by default. You must allowlist target URLs via propagateTraceHeaderCorsUrls. The CORS tax fires the moment a team flips that flag without coordinating with the backend. Vendor agents behave similarly: Datadog's browser RUM requires allowedTracingUrls; Sentry requires tracePropagationTargets.

Any non-safelisted header on a cross-origin request triggers a CORS preflight. The browser sends an OPTIONS request with Access-Control-Request-Headers listing every header it wants to send. The server responds with Access-Control-Allow-Headers listing what it accepts. If a requested header is not in the server's response, the browser refuses to send the main request.

It does not surface a 4xx at the network layer. The main request is simply stripped from the wire. The backend never sees it because it never arrives.

Symmetric problem on the response side: if the backend returns a server-generated trace ID so the frontend can log it for support correlation, JavaScript cannot read that response header without Access-Control-Expose-Headers. Observability teams typically hit this one minute after fixing Allow-Headers. Same coordination contract, opposite direction.

Why this fails silently

The fingerprint lives in places engineers do not look first. DevTools Network shows the OPTIONS preflight returning 200 or 204, then the main request appears in red as (failed) net::ERR_FAILED. The line is there, easy to miss between successful sibling requests, and the failure reason is one click away in the request details, but most engineers scan for 4xx and 5xx, see neither, and move on. DevTools Console does log the CORS error verbatim with the missing header name. The fingerprint exists; engineers just do not look there first when chasing what feels like a state bug.

Backend logs show the OPTIONS preflight but no main request after, which engineers reading the log do not notice as anomalous unless they specifically know to look for the missing follow-up. APM traces show a span that starts in the browser and terminates with no downstream child, looking identical to a network timeout or a misconfigured exporter. Most E2E harnesses report a generic timeout or assertion failure with no CORS string attached.

Test infrastructure flakes first. If frontend and backend deploy out of step, E2E suites pass on Monday and fail on Tuesday with no code change visible to the test author. Engineers chase auth flow, cookie scope, session state, CSRF tokens, race conditions in the test harness. Anything but CORS, because CORS does not error in a way that points at itself.

A SaaS I'm shipping spent about three weeks on this failure mode. The E2E suite flaked for five days, then settled into steady fail as traffic shifted. Pure CORS errors are deterministic; the apparent flake was OpenTelemetry's browser sampler, which only injects trace headers on a fraction of requests by default. So only a fraction preflight-failed in the early window. When the sampler ratio increased on a configuration push, the fail rate snapped to 100%. No 4xx ever surfaced. Two PRs landed that fixed the wrong layer. The fix when finally found was four lines in CORS config to allowlist traceparent, tracestate, and baggage. The three weeks bought one lesson: header coupling is a coordination contract, not a config detail. The sampler made it look like flake; the contract was already broken.

The mental model is the cost. The four lines of config were the easy part.

Four patterns to handle the tax

1. Paired-PR pattern

Every PR that adds a request header on the frontend includes the backend allow_headers change in the same merge train. Best for small teams with monorepo discipline. Cost: a checklist line on the PR template and a reviewer who notices.

2. Allowlist-broad pattern

The CORS middleware allowlists W3C tracing headers and common vendor headers by default, ahead of instrumentation rollout. Trades a wider blast radius for zero per-header coordination cost. Reasonable default for mid-size teams shipping observability across multiple services. Note: "wider blast radius" here is hygiene, not exploit. CORS protects response sharing across origins, not header acceptance. But the allowlist drifts, and credentialed requests (withCredentials) still cannot use Allow-Headers: * per spec, so the wildcard trick fails the moment auth flips to cookies.

3. Reverse-proxy rewrite

Collapse the cross-origin problem entirely. Serve frontend and API from the same origin via Next.js rewrites, an API gateway, or a CDN edge worker. Eliminates preflight for the affected routes. Usually the right answer when the routing topology allows it cleanly.

4. Gateway strip and re-attach

Strip observability headers at the gateway before the cross-origin hop, re-attach to the trace context server-side from a header the gateway sets. Most enterprise. Most overhead. Right answer when compliance audits treat header propagation across origins as data leakage.

When each pattern wins

Small team, monorepo, fast review: paired-PR. The discipline is cheap and the coupling stays explicit. Mid team, multi-repo, slow cross-team PR coordination: allowlist-broad, accepting that the allowlist will drift. Any team where same-origin is achievable: reverse-proxy rewrite, almost always the strongest answer because it removes the contract instead of managing it. Regulated environments with header-level audit requirements: gateway strip and re-attach.

Audit checklist

Three questions for any team rolling out distributed tracing across origins:

  1. Does the CORS middleware allowlist W3C tracing headers before frontend instrumentation ships?
  2. Is there a merge-train policy pairing header-adding PRs with their backend allow_headers counterpart?
  3. Has same-origin via gateway or rewrite been ruled out, or just not considered?

Cross-origin observability comes with a CORS tax. You either schedule it into the rollout, or it shows up later as incident time.

Sources

  1. W3C Trace Context — https://www.w3.org/TR/trace-context/
  2. W3C Baggage — https://www.w3.org/TR/baggage/
  3. Fetch Living Standard, §3.2.2 CORS-preflight fetch — https://fetch.spec.whatwg.org/#cors-preflight-fetch
  4. MDN, Cross-Origin Resource Sharing (CORS) — https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS
  5. @opentelemetry/instrumentation-fetch — https://github.com/open-telemetry/opentelemetry-js/tree/main/experimental/packages/opentelemetry-instrumentation-fetch
  6. Datadog Trace Context Propagation — https://docs.datadoghq.com/tracing/trace_collection/trace_context_propagation/
  7. Sentry Distributed Tracing (JavaScript) — https://docs.sentry.io/platforms/javascript/tracing/distributed-tracing/
  8. Next.js rewrites — https://nextjs.org/docs/app/api-reference/config/next-config-js/rewrites
  9. Jake Archibald, "How to win at CORS" — https://jakearchibald.com/2021/cors/