For AI agents: the complete documentation index is available at https://mfeflow.github.io/mfeflow/llms.txt, the full documentation bundle is available at https://mfeflow.github.io/mfeflow/llms-full.txt, and this page is available as Markdown at https://mfeflow.github.io/mfeflow/features/telemetry-otel.md.

telemetry-otel

This feature shows you what your micro-frontends are doing in real browsers. Spans, fetch requests, Core Web Vitals and remote load failures go to an OTLP endpoint, and each one says which remote it came from. The OpenTelemetry SDK loads after the page has painted, so it stays off the path to first render.

What you get

A shell that starts telemetry from its runtime config, and a tracer per remote that tags every span with mfe.name. Failed remote loads and render errors are reported without any wiring in your remotes. It is off by default, and with no endpoint set the SDK is never downloaded.

Add it

pnpm mfeflow add telemetry-otel

What changed:

apps/
├── shell/src/telemetry/
│   └── init.ts       # boot step: reads runtime config, loads the SDK when idle
└── <remote>/src/
    └── telemetry.ts  # exports the remote's tracer

The @mfeflow/telemetry package is added to the shell and every remote. Three keys are added to the runtime config, described under "Configure it" below. If you have the testing feature, each file gets a unit test next to it.

Use it

Turn it on

Point the shell at an OTLP/HTTP traces URL in the environment that should send data:

apps/shell/public/config/runtime-config.prod.json
{
  "environment": "prod",
  "otlpEndpoint": "https://collector.example.com/v1/traces",
  "traceSampleRatio": 0.1,
  "propagateTraceHeaderCorsUrls": ["https://api.example.com"]
}

Start a trace from a click

The generated file in each remote exports a tracer, named after the remote:

apps/cart/src/telemetry.ts
import { getTracer } from '@mfeflow/telemetry';

export const tracer = getTracer('cart');

Use it where something worth timing happens:

import { tracer } from '../telemetry';

function onClick() {
  tracer.startActiveSpan('checkout.click', (span) => {
    void fetch('/api/orders').finally(() => span.end());
  });
}

Start the fetch inside the callback, before any await. The reason is in "How it works" below.

Know when a remote fails

You do not write anything for this. When a remote fails to load or throws while rendering, @mfeflow/runtime reports it on window. Telemetry turns it into an mfe.error span tagged with the remote's name.

The span also says where it failed:

mfe.error.whereWhen
loadThe remote failed to load
renderThe remote threw while rendering
registryThe registry could not be read
errorLoadRemote:<lifecycle>A load error in that lifecycle

Failures that happened before telemetry started are replayed.

Configure it

KeyMeaningDefault
otlpEndpointFull OTLP/HTTP traces URL. Empty keeps telemetry off.""
traceSampleRatioShare of new traces recorded, 0 to 1. A trace that started elsewhere keeps its decision.1
propagateTraceHeaderCorsUrlsURL prefixes of other origins that get a traceparent header.[]

These live in the runtime config, and pnpm check:runtime-config checks the URL, the ratio and the list. The feature has no install prompts.

More options are on createOtelProvider in apps/shell/src/telemetry/init.ts:

OptionMeaningDefault
recordQueryKeep the query string on url.fullfalse
webVitalsRecord Core Web Vitals as spanstrue
exportDelayMsHow long spans wait for a batch5000
exporterReplace the OTLP exporter, for example with a console oneOTLP over HTTP

How it works

initTelemetry takes a function that returns the provider. The SDK chunk is requested after the page has loaded, the next frame has painted and the browser is idle, or after 3 seconds if the tab is hidden and never paints. Until then, error reports and web vitals are queued (the last 50) and replayed. Spans started before the provider is ready are not recorded.

Once running, getTracer(name) stamps each span with mfe.name, and with mfe.version if you pass one. Spans created without a name, such as the fetch span from the instrumentation, inherit their parent's. A trace therefore reads by remote.

Resource attributes on every span:

AttributeComes from
service.nameThe app
service.versionThe app
deployment.environment.nameenvironment in the runtime config

Core Web Vitals become web_vital.* spans tagged mfe.name = shell:

CLS  FCP  INP  LCP  TTFB

Same-origin fetch requests get a traceparent header. Other origins get one only if they match a prefix in propagateTraceHeaderCorsUrls, and that server must allow the header in CORS. Requests to the OTLP endpoint itself are never traced.

What leaves the browser

Spans are scrubbed before export, because tokens and email addresses end up in URLs.

AttributeWhat happens
url.full and http.urlCut to origin and path
http.target and url.queryThe query is dropped

Userinfo and the fragment are always removed. Each reported error sends at most 4 KB of message and 4 KB of stack.

Why the context manager is a stack

A fetch can only be a child of the click span if the SDK knows which span is active when it runs. That is the context manager's job.

We measured it on 2026-11-01 in Chrome, with the fetch instrumentation and these versions:

@opentelemetry/sdk-trace      3.0.0
@opentelemetry/context-zone   3.0.0
zone.js                       0.16.3
Where the fetch is calledStackZoneZone, async downleveled to ES2016
synchronously in startActiveSpan's callbackchildchildchild
in a .then()lostchildchild
in a setTimeoutlostchildchild
after a native awaitlostlostchild
a second fetch after awaiting the firstlostlostchild
Added to the bundle, gzipped0.4 KB13.6 KB13.6 KB + the downleveling

The default is the stack manager. zone.js has to patch promises and timers before application code runs, so it would load on the critical path.

Without downleveling, zone fixes .then() and timers but not native await, which modern code uses. Downleveling to ES2016 is the only setup where zone is right everywhere, and it changes how every app compiles. Starting the fetch inside the callback works with both and costs nothing.

Gotchas

Tip

If you need a span to survive an await, capture the context before it and run the call in it with context.with(context.active(), ...) from @opentelemetry/api in the shell. Then hand your remotes a function that does it.

  • Keeping the query string. Turn on recordQuery only for a backend you trust with whatever your URLs carry. Userinfo and the fragment are still removed.

  • Adding env-t3 later. The keys move into the schema and the environment files. Delete the old shared file, or doctor reports it as missing environment:

    apps/shell/public/config/runtime-config.json
  • Versions. The OpenTelemetry JavaScript SDK split into two lines in October 2026. This feature uses the 3.x packages with the API at 1.10. The exporter and the fetch instrumentation (0.300.0) are experimental packages.

    3.x   sdk-trace, core, resources
    1.10  @opentelemetry/api

    Only @mfeflow/telemetry/otel imports the experimental ones.

Reference