Distributed Tracing Across Micro-Frontends #

When a host shell and its federated remotes each initialize their own tracer, a single user click produces several disconnected traces instead of one — making it impossible to follow a request across the boundary. This guide shows how to share one OpenTelemetry tracer as a singleton across the host and remotes, propagate the W3C traceparent header, and wrap remote mount/load in child spans so every interaction yields a single connected trace exported to your collector.

Why one shared tracer matters #

In a Module Federation setup the host loads remotes at runtime, and each remote ships its own JavaScript. If each bundle calls WebTracerProvider on its own, you get two tracer providers in one tab. Their spans never share a parent, the traceparent header is generated by whichever provider happens to fire first, and your collector shows orphaned root spans.

The fix is structural: the host owns the tracer, exposes it on a well-known global, and remotes look it up instead of creating their own. This mirrors how you treat any shared runtime dependency in a federated app — exactly one instance, owned by the host, reused everywhere.

One trace across host and remotes A user action opens a host span, which parents remote load and mount child spans; all spans carry the same trace id and export over OTLP to a collector. User Host shell Remote Collector click root span load remote child span traceparent OTLP export (same trace id)
One click opens a host root span; the remote load and mount become child spans, and all of them export over OTLP under a single trace id.

Prerequisites #

npm install @opentelemetry/sdk-trace-web \
  @opentelemetry/context-zone \
  @opentelemetry/exporter-trace-otlp-http \
  @opentelemetry/instrumentation \
  @opentelemetry/instrumentation-fetch \
  @opentelemetry/instrumentation-xml-http-request \
  @opentelemetry/resources \
  @opentelemetry/semantic-conventions

Step 1 — Initialize the OTel web provider in the host #

Create the tracer once, during host bootstrap, before any remote loads. Register W3CTraceContextPropagator so spans inject and extract the traceparent header, and ZoneContextManager so async context survives across await and event callbacks.

// host/src/tracing.ts
import { WebTracerProvider, BatchSpanProcessor } from '@opentelemetry/sdk-trace-web';
import { ZoneContextManager } from '@opentelemetry/context-zone';
import { W3CTraceContextPropagator } from '@opentelemetry/core';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { Resource } from '@opentelemetry/resources';
import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions';
import { registerInstrumentations } from '@opentelemetry/instrumentation';
import { FetchInstrumentation } from '@opentelemetry/instrumentation-fetch';
import { XMLHttpRequestInstrumentation } from '@opentelemetry/instrumentation-xml-http-request';

export function initTracing() {
  const provider = new WebTracerProvider({
    resource: new Resource({
      [SemanticResourceAttributes.SERVICE_NAME]: 'host-shell',
    }),
  });

  provider.addSpanProcessor(
    new BatchSpanProcessor(
      new OTLPTraceExporter({ url: 'http://localhost:4318/v1/traces' }),
    ),
  );

  provider.register({
    contextManager: new ZoneContextManager(),
    propagator: new W3CTraceContextPropagator(),
  });

  return provider;
}

Step 2 — Share the tracer as a singleton #

Register the provider once and publish a lookup on a namespaced global. Remotes call getTracer() and never touch WebTracerProvider. The guard makes a second call a no-op, so a remote that mistakenly imports this file cannot create a duplicate.

// host/src/tracing-singleton.ts
import { trace, Tracer } from '@opentelemetry/api';
import { initTracing } from './tracing';

const GLOBAL_KEY = '__MFE_OTEL__';

interface OtelGlobal { initialized: boolean }

export function setupTracingOnce(): void {
  const w = window as unknown as Record<string, OtelGlobal>;
  if (w[GLOBAL_KEY]?.initialized) return; // host already booted tracing
  initTracing();                          // registers the global provider
  w[GLOBAL_KEY] = { initialized: true };
}

// Remotes import ONLY this — they reuse the host's registered provider.
export function getTracer(name = 'mfe'): Tracer {
  return trace.getTracer(name);
}

Call setupTracingOnce() at the very top of the host entry, before mounting the shell:

// host/src/bootstrap.tsx
import { setupTracingOnce } from './tracing-singleton';
setupTracingOnce();

import('./App').then(({ mountApp }) => mountApp());

Step 3 — Wrap fetch and XHR to inject traceparent #

The fetch and XHR instrumentations registered in Step 1 already inject traceparent on outgoing requests, but only to URLs you allow. List the origins of your remotes and APIs in propagateTraceHeaderCorsUrls so the header crosses the boundary instead of being silently dropped.

// host/src/tracing.ts (extend Step 1, inside initTracing before register)
const propagateTo = [
  /https?:\/\/localhost:\d+\/.*/, // remotes + APIs in dev
  /https:\/\/remotes\.example\.com\/.*/,
];

registerInstrumentations({
  instrumentations: [
    new FetchInstrumentation({ propagateTraceHeaderCorsUrls: propagateTo }),
    new XMLHttpRequestInstrumentation({ propagateTraceHeaderCorsUrls: propagateTo }),
  ],
});

Step 4 — Create spans around remote mount and load #

Wrap the dynamic import and the mount call in an active span. Because the host registered the context manager, the span you start here becomes the parent of any fetch the remote fires during load and mount — automatically linking the network calls to this UI action.

// host/src/RemoteSlot.tsx
import { useEffect } from 'react';
import { context, trace, SpanStatusCode } from '@opentelemetry/api';
import { getTracer } from './tracing-singleton';

export function RemoteSlot({ remote }: { remote: string }) {
  useEffect(() => {
    const tracer = getTracer('host-shell');
    const span = tracer.startSpan(`remote.load:${remote}`);
    context.with(trace.setSpan(context.active(), span), async () => {
      try {
        const mod = await import(/* webpackIgnore: false */ `${remote}/Widget`);
        await mod.mount(document.getElementById(remote)!);
        span.setStatus({ code: SpanStatusCode.OK });
      } catch (err) {
        span.recordException(err as Error);
        span.setStatus({ code: SpanStatusCode.ERROR });
      } finally {
        span.end();
      }
    });
  }, [remote]);

  return <div id={remote} />;
}

Inside the remote, open a child span using the same shared tracer — it picks up the active host span as its parent with no manual context plumbing:

// remote/src/mount.ts  (depends ONLY on the host's getTracer accessor)
import { getTracer } from 'host/tracing-singleton';

export async function mount(el: HTMLElement) {
  const span = getTracer('checkout-remote').startSpan('remote.mount:checkout');
  try {
    el.innerHTML = '<checkout-widget></checkout-widget>';
    await loadCheckoutData(); // its fetch inherits the same trace id
  } finally {
    span.end();
  }
}

Step 5 — Export over OTLP to a collector #

The BatchSpanProcessor from Step 1 already streams spans to http://localhost:4318/v1/traces. Run a collector that accepts OTLP/HTTP from the browser and forwards to your backend:

# otel-collector-config.yaml
receivers:
  otlp:
    protocols:
      http:
        endpoint: 0.0.0.0:4318
        cors:
          allowed_origins:
            - http://localhost:3000   # host origin
            - https://app.example.com
exporters:
  debug:
    verbosity: detailed
service:
  pipelines:
    traces:
      receivers: [otlp]
      exporters: [debug]
# run the collector locally
otelcol --config otel-collector-config.yaml

The cors.allowed_origins block is mandatory — browser OTLP exports are subject to CORS, and omitting your host origin is the most common reason spans never arrive.

Verification #

Confirm the trace is connected, not fragmented:

  1. Inspect the header in DevTools. Open the Network tab, trigger the remote load, and select the request to the remote or its API. Under Request Headers you should see one traceparent like 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01. The middle segment (the trace id) must be identical across the host request and the remote’s downstream requests.

  2. Check span count comes from one provider. In the console, confirm there is exactly one registered provider:

import { trace } from '@opentelemetry/api';
// Both the host and a remote module should log the SAME object reference.
console.log(trace.getTracerProvider());
  1. View connected spans in the collector. The debug exporter prints spans to the collector’s stdout. A correct trace shows the remote spans nested under the host root span, all sharing one trace_id and the child spans referencing the host span’s span_id as their parent.

Troubleshooting #

Symptom: two root spans per click, each with a different trace id. Diagnosis: a remote re-initialized its own WebTracerProvider instead of reusing the host’s. Fix: ensure the remote imports only getTracer from the host (Step 2) and never imports tracing.ts or sdk-trace-web directly. The __MFE_OTEL__ guard prevents a second register(); if you still see duplicates, the remote is bundling its own copy of @opentelemetry/api — share it as a singleton the same way you would share React across remotes.

Symptom: spans exist but parent-child links are broken (everything is a root). Diagnosis: the active context was lost across an await or event handler, so child spans could not find their parent. Fix: confirm ZoneContextManager is passed to provider.register() (Step 1), and always start child work inside context.with(trace.setSpan(...), fn) as in Step 4 rather than calling startSpan in a detached callback.

Symptom: no spans in the collector and a CORS error on /v1/traces. Diagnosis: the browser’s OTLP POST is blocked by CORS, or the collector receiver is not listening on HTTP. Fix: add your host origin to cors.allowed_origins (Step 5), verify the exporter URL ends in /v1/traces, and confirm the collector exposes the http protocol on 4318. For tracking the errors these failures produce in the UI, pair this with error boundary telemetry for remote apps.

Symptom: traceparent is present on host requests but missing on remote API calls. Diagnosis: the remote’s API origin is not in propagateTraceHeaderCorsUrls. Fix: add a matching regex for that origin (Step 3). The propagator only injects the header into allow-listed URLs to avoid leaking trace context to third parties.