OpenTelemetry setup
How to configure the OpenTelemetry SDK for browser metrics in the page, so that the metrics of many browsers aggregate correctly.
Note
The monitors record into an OpenTelemetry Meter and send events to an OpenTelemetry Logger. The SDK settings decide if the metrics of many browsers are correct after aggregation. The research note OpenTelemetry metrics in the browser gives the evidence for each setting.
NoteThe version of otel-ts
This page uses the otel-ts features serviceInstanceId, createInstanceId(), histogramAggregation and onBeforeFlush. They are in otel-ts from commit a4a7722 (the main branch on 2026-10-08). The browser attributes are in otel-ts from commit 327e625.
From commit 92cd393, otel-ts flushes in the export phase of the shared lifecycle tracker (page-lifecycle-tracker 0.1.0). From that commit, cause.event of onBeforeFlush is not set, and cause.transition gives the transition. The lockfile of this repository has commit 92cd393. An earlier version puts session.id on the resource, sets no service.instance.id, uses explicit-bucket histograms and has no onBeforeFlush.
The settings
| Setting | Value | Why |
|---|---|---|
service.instance.id | A new random ID for each SDK instance (each page load and each worker) | Without a writer identity, many browsers write the same series. Then Mimir rejects samples or mixes the totals of different browsers. |
| Temporality | Cumulative | Mimir drops delta sums and delta histograms. No collector merges many writers correctly. |
| Histograms | Exponential (base 2) | Mimir stores each one as one native-histogram series. Explicit buckets need one series for each bucket. |
| Export interval | 15 s | A shorter interval adds requests and series samples. The flush at page hide sends the last values. |
| Flush | At each page hide, in the export phase of the shared lifecycle tracker, after monitors.flush() | The final values of a page view and the end of the page must be in the last export. |
session.id | On log records only, not on the resource | A resource with session.id adds one target_info series for each session, and it keeps an old ID after the session changes. |
otel-ts
otel-ts has these settings as defaults or options:
import { init } from "@mark1russell7/otel-ts";
const otel = init({
serviceName : "shop",
endpoint : "https://collector.example.com",
// Native histograms in Mimir. The Mimir tenant must accept native histograms.
histogramAggregation : "exponential",
});
// After setupAllMonitors()
otel.onBeforeFlush(() => monitors.flush());
otel-ts has these defaults:
- a new
service.instance.idfor eachinit()call - an export every 15 s, with cumulative temporality
session.idon each log record and span- a flush each time the page becomes hidden
- the browser attributes of the semantic conventions on the resource:
browser.brands,browser.platform,browser.mobile(only Chromium has these three),browser.languageanduser_agent.original. Mimir copiesbrowser.platformandbrowser.mobileonto each series. Each value is constant for one SDK instance, thus they add no series.browserAttributes: falseremoves them.
onBeforeFlush starts its listeners at the start of each flush.
Warning
With histogramAggregation: "exponential", a backend without native histograms can drop the histograms. In Mimir, set native_histograms_ingestion_enabled: true for the tenant.
Another OpenTelemetry SDK
The monitors work with each OpenTelemetry SDK for JavaScript. Make sure that your setup obeys these rules:
- Set
service.instance.idto a new random value for eachMeterProvider. Do not store the value. Do not make it from the session ID. - Use cumulative temporality.
- Use the exponential histogram aggregation, if the backend has native histograms.
- Flush when the page becomes hidden or frozen, and at the end of the page. Subscribe to the shared tracker in the
exportphase:getPageLifecycle().subscribe(listener, { phase: "export" })frompage-lifecycle-tracker. - Before each flush, use
monitors.flush(). If the exporter listens tovisibilitychangeorpagehideitself, give it the event that started the flush:monitors.flush(event). - Put the session ID on log records, not on the resource.
Spans
The monitors can also send spans: the page view, the hangs, the stalls, the long animation frames and the hidden periods (spans for the periods). Give a span sink to createBrowserDeps(). createOtelSpanSink() sends each span through an OpenTelemetry tracer:
import * as api from "@opentelemetry/api";
import { init } from "@mark1russell7/otel-ts";
import { createBrowserDeps, createOtelSpanSink, setupAllMonitors } from "@mark1russell7/lag";
// otel-ts registers its tracer provider as the global provider
const otel = init({ serviceName : "shop", endpoint : "https://collector.example.com" });
const spans = createOtelSpanSink(api.trace.getTracer("@mark1russell7/lag"), api);
const monitors = setupAllMonitors(createBrowserDeps(window, { logger, meter, events, spans }));
The adapter needs the module @opentelemetry/api itself, because it gives each span its parent with trace.setSpanContext(). Each span has the time of the absolute clock of the page, as the events.
Without spans, the monitors make no spans. The events and the metrics do not change.
The hang reports of the worker
During a hang, the main thread cannot send anything. Thus the worker sends its hang reports itself, as OTLP/HTTP JSON log records, to workerHangReport.url:
const serviceInstanceId = createInstanceId();
const otel = init({ serviceName : "shop", serviceInstanceId, endpoint : "https://collector.example.com" });
createBrowserDeps(window, {
// ...
worker,
workerHangReport : {
url : "https://collector.example.com/v1/logs",
resource : { "service.name" : "shop", "service.instance.id" : serviceInstanceId },
},
pageContext : () => ({ "session.id" : otel.getSessionId() }),
});
Give the hang reports the same service.instance.id as the SDK of the page. Then a query can join them with the metrics of the page. Also, the Page load variable of the Lag Monitor dashboard then shows them with the other events of the page. The worker adds the attributes of pageContext, for example the session ID, and the ID of the current page view to each report.
The worker uses fetch with keepalive, because workers do not have sendBeacon. The collector must accept cross-origin requests from the page (CORS) for POST with the Content-Type: application/json header. In WebKit and Safari, the request of a worker completes only when the main thread operates again. Thus a report comes only after the hang (worker lag).
The number of series
Each SDK instance writes its own series. Thus the number of active series depends on the number of page loads. The catalog has 42 metrics. With native histograms, one page load can write at most 271 series. Usually it writes much fewer, because a page uses only some of the attribute values.
R1 gives a rough rule for the Mimir limit. Multiply the page loads in one hour by 2.5, and multiply the result by the series of one page load. If the number is too high, increase the limits, then sample the sessions.