The browser: spans and the session
From here on you write the browser side yourself. This page covers the first three rows of the contract: where the data goes, who sent it, and which session it belongs to.
Step 6. Instrument the web app by hand
Section titled “Step 6. Instrument the web app by hand”Stop Vite with Ctrl+C in its terminal tab (still in frontend/), then install what the browser needs:
npm install @opentelemetry/api @opentelemetry/core @opentelemetry/resources @opentelemetry/sdk-trace @opentelemetry/sdk-logs @opentelemetry/exporter-trace-otlp-http @opentelemetry/exporter-logs-otlp-http @opentelemetry/instrumentation @opentelemetry/browser-instrumentation rrweb web-vitals| Package | What it does here |
|---|---|
@opentelemetry/api |
what your code calls: trace, logs, propagation, context |
@opentelemetry/sdk-trace |
the tracer provider, span processors, and StackContextManager for the browser (OpenTelemetry JS 3.0 merged sdk-trace-web into it) |
@opentelemetry/sdk-logs |
the logger provider: RUM events are OTLP log records |
@opentelemetry/exporter-trace-otlp-http, exporter-logs-otlp-http |
send spans and logs to the collector over OTLP/HTTP |
@opentelemetry/instrumentation |
registerInstrumentations(), which turns instrumentations on; it records nothing itself |
@opentelemetry/browser-instrumentation |
only its experimental/fetch part here: a span for every fetch, with traceparent on calls to your API |
@opentelemetry/core, resources |
the W3C trace-context and baggage propagators; the resource (who sent the data) |
rrweb |
records the DOM for session replay |
web-vitals |
LCP, INP, CLS, FCP, TTFB, measured the way Chrome measures them |
Which OpenTelemetry browser package is which?
The names are close and several are on their way out. As of OpenTelemetry JS 3.0:
| Package | Status | What it is |
|---|---|---|
@opentelemetry/sdk-trace |
current | The tracer provider. Since 3.0 it also has the browser’s StackContextManager. |
@opentelemetry/sdk-trace-web |
deprecated (“Use @opentelemetry/sdk-trace”) | The old browser tracer provider, with register(). Its last version is 2.x. |
@opentelemetry/instrumentation |
current | The base class every instrumentation extends, and registerInstrumentations(). Collects nothing on its own. |
@opentelemetry/instrumentation-fetch, instrumentation-xml-http-request |
deprecated in their READMEs (npm does not flag them) | The old fetch and XHR span instrumentations from the opentelemetry-js repo. |
@opentelemetry/instrumentation-document-load, instrumentation-user-interaction |
contrib, older | Page-load spans and click spans from opentelemetry-js-contrib. |
@opentelemetry/browser-instrumentation |
experimental, from the new opentelemetry-browser repo | The replacements, one import path each under experimental/: fetch, xhr, navigation, navigation-timing, resource-timing, user-action, web-vitals, console, errors. |
@opentelemetry/browser-sdk |
experimental, same repo | A one-call setup of the providers and those instrumentations. |
@opentelemetry/instrumentation-web |
does not exist | Often what people mean by one of the above. |
This quickstart takes only fetch from @opentelemetry/browser-instrumentation. Spans are matched to the session by their session.id attribute, so the span shape does not matter to ouo11y. Its other instrumentations emit events with their own names and attributes: browser.navigation, browser.web_vital and browser.user_action.click would be custom events to ouo11y, not page views or vitals, and their exception has no ouo.exception.fatal, so a crash would count as errored. Step 6b writes those events itself.
browser-instrumentation 0.9 is still built on the 0.223 (2.x) line of @opentelemetry/instrumentation; npm installs that copy next to yours, and it works with the 3.0 SDK because both use the one global API.
All the telemetry code goes in one folder, frontend/src/telemetry/. Create it:
mkdir src/telemetry6a. Send spans to ouo11y
Section titled “6a. Send spans to ouo11y”Three files: where the data goes, the session id, and OpenTelemetry itself.
Where the data goes
Section titled “Where the data goes”Create frontend/src/telemetry/config.js:
frontend/src/telemetry/config.js
// Where the telemetry goes and who this app is. Moved ports? Override in .env.local.
// The tiny-shop API (backend/).export const API = import.meta.env.VITE_API_URL || "http://localhost:5001";
// The ouo collector: OTLP/HTTP for spans and logs, and the replay receiver for rrweb chunks.export const OTLP_ENDPOINT = import.meta.env.VITE_OTLP_ENDPOINT || "http://localhost:4318";export const REPLAY_ENDPOINT = import.meta.env.VITE_REPLAY_ENDPOINT || "http://localhost:4319";
// Every request to the collector carries the project credential:// Basic base64("<project id>:<project token>"). The token is public by design: it ships in the bundle.export const AUTHORIZATION = `Basic ${btoa("demo:pt_dev_demo_0000000000000000")}`;
export const SERVICE = { name: "tiny-shop-otel-frontend", version: "1.0.0", environment: "local",};The project token ships in your JavaScript bundle, so anyone can read it. That is by design: it only lets a page send data to one project, the way a public analytics key does.
The session id
Section titled “The session id”Create frontend/src/telemetry/session.js. It keeps one session id per visit in localStorage, shared by all tabs, and replaces it after 30 minutes without activity or after 4 hours. These are the rules ouo11y uses everywhere (Sessions and health):
frontend/src/telemetry/session.js
// The RUM session: one id for one visit, shared by every tab of the origin through localStorage.// Same rules as ouo's SDKs (docs/specs/session-lifecycle.md): a session ends after 30 minutes// without activity or after 4 hours, and the next signal starts a new one.const SESSION_KEY = "tiny-shop.session";const DEVICE_KEY = "tiny-shop.device";const INACTIVITY_MS = 30 * 60 * 1000;const MAX_DURATION_MS = 4 * 60 * 60 * 1000;
function read(key) { try { return JSON.parse(localStorage.getItem(key)); } catch { return null; }}
function write(key, value) { try { localStorage.setItem(key, JSON.stringify(value)); } catch { // Storage blocked (private mode, quota): the session lives as long as the page. }}
// device.id: random, one per browser profile, kept across sessions. Never a fingerprint.export const deviceId = read(DEVICE_KEY) ?? crypto.randomUUID();write(DEVICE_KEY, deviceId);
const listeners = [];let current = null;let lastWrite = 0;
// fn("start", session, previousSession | null) and fn("end", session, reason).export function onSessionChange(fn) { listeners.push(fn);}
function endReason(session, now) { if (now - session.lastActivity >= INACTIVITY_MS) return "inactivity"; if (now - session.startedAt >= MAX_DURATION_MS) return "max_duration"; return null;}
// The current session. Starts one on first use and replaces an expired one, so call it for// every signal: the id it returns is always valid.export function getSession() { const now = Date.now(); current = read(SESSION_KEY) ?? current; // another tab may have touched or replaced it if (current && !endReason(current, now)) return current;
const previous = current; current = { id: crypto.randomUUID(), // Anonymous until the app knows the user; the id stays with the browser across sessions. userId: previous?.userId ?? crypto.randomUUID(), startedAt: now, lastActivity: now, replaySeq: 0, }; write(SESSION_KEY, current); if (previous) for (const fn of listeners) fn("end", previous, endReason(previous, now)); for (const fn of listeners) fn("start", current, previous); return current;}
// A click, a key, a scroll or a request: the session is still in use.export function touch() { const session = getSession(); session.lastActivity = Date.now(); if (session.lastActivity - lastWrite >= 1000) { lastWrite = session.lastActivity; write(SESSION_KEY, session); }}
// Replay chunks are numbered per session, and a reload continues the numbering.export function nextReplaySeq() { const session = getSession(); const seq = session.replaySeq++; write(SESSION_KEY, session); return seq;}getSession()is called for every signal. It reads the session fromlocalStoragefirst, since another tab may have touched or replaced it, and starts a new one when the old one has expired. The id it returns is always valid.touch()records activity: a click, a key, a scroll or a request. It writes tolocalStorageat most once a second.onSessionChange()lets the next files react when a session starts or ends: step 6b sendssession.startandsession.end, step 6c starts a new replay.user.idis anonymous here: a random id kept with the browser across sessions.device.idis random too, one per browser profile, and never a fingerprint.
OpenTelemetry
Section titled “OpenTelemetry”Create frontend/src/telemetry/otel.js:
frontend/src/telemetry/otel.js
// OpenTelemetry for the browser: spans and logs to the ouo collector over OTLP/HTTP.import { context, logs, propagation, trace } from "@opentelemetry/api";import { CompositePropagator, W3CBaggagePropagator, W3CTraceContextPropagator } from "@opentelemetry/core";import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http";import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";import { registerInstrumentations } from "@opentelemetry/instrumentation";// The fetch instrumentation from the opentelemetry-browser repo; @opentelemetry/instrumentation-fetch// is deprecated in its favour. The package is experimental, hence the path.import { FetchInstrumentation } from "@opentelemetry/browser-instrumentation/experimental/fetch";import { resourceFromAttributes } from "@opentelemetry/resources";import { BatchLogRecordProcessor, LoggerProvider } from "@opentelemetry/sdk-logs";import { BatchSpanProcessor, StackContextManager, TracerProvider } from "@opentelemetry/sdk-trace";import { API, AUTHORIZATION, OTLP_ENDPOINT, REPLAY_ENDPOINT, SERVICE } from "./config.js";import { deviceId, getSession, touch } from "./session.js";
const headers = { Authorization: AUTHORIZATION };
// Who sent the data. ouo.platform = "web" tells ouo this is a browser app.const resource = resourceFromAttributes({ "service.name": SERVICE.name, "service.version": SERVICE.version, "deployment.environment.name": SERVICE.environment, "ouo.platform": "web", "device.id": deviceId,});
// session.id and user.id go on every span, not on the resource: the session can change while// the page stays open.class SessionSpanProcessor { onStart(span) { const session = getSession(); span.setAttribute("session.id", session.id); span.setAttribute("user.id", session.userId); } onEnd() {} forceFlush() { return Promise.resolve(); } shutdown() { return Promise.resolve(); }}
// traceparent joins the API's spans to the browser's trace; baggage carries session.id and// user.id to the API. The baggage is added at inject time because the browser has no async// context: baggage set with context.with() is gone by the time fetch sends the request.class SessionPropagator { inner = new CompositePropagator({ propagators: [new W3CTraceContextPropagator(), new W3CBaggagePropagator()], }); inject(ctx, carrier, setter) { const session = getSession(); const baggage = (propagation.getBaggage(ctx) ?? propagation.createBaggage()) .setEntry("session.id", { value: session.id }) .setEntry("user.id", { value: session.userId }); this.inner.inject(propagation.setBaggage(ctx, baggage), carrier, setter); } extract(ctx, carrier, getter) { return this.inner.extract(ctx, carrier, getter); } fields() { return this.inner.fields(); }}
export const tracerProvider = new TracerProvider({ resource, spanProcessors: [ new SessionSpanProcessor(), new BatchSpanProcessor({ exporter: new OTLPTraceExporter({ url: `${OTLP_ENDPOINT}/v1/traces`, headers }) }), ],});
export const loggerProvider = new LoggerProvider({ resource, processors: [ new BatchLogRecordProcessor({ exporter: new OTLPLogExporter({ url: `${OTLP_ENDPOINT}/v1/logs`, headers }) }), ],});
context.setGlobalContextManager(new StackContextManager().enable());propagation.setGlobalPropagator(new SessionPropagator());trace.setGlobalTracerProvider(tracerProvider);logs.setGlobalLoggerProvider(loggerProvider);
// Called for every failed fetch; set by events.js (it emits http.request.failed).let onFailedFetch = () => {};export function setFailedFetchHandler(fn) { onFailedFetch = fn;}
registerInstrumentations({ instrumentations: [ new FetchInstrumentation({ // Only calls to our API get the trace headers. A RegExp: a plain string must equal the whole URL. propagateTraceHeaderCorsUrls: [new RegExp(`^${API}/`)], // Never trace the telemetry itself. ignoreUrls: [new RegExp(`^${OTLP_ENDPOINT}/`), new RegExp(`^${REPLAY_ENDPOINT}/`)], applyCustomAttributesOnSpan(span, request, result) { touch(); if (result.status >= 400) onFailedFetch(request.method ?? "GET", result.url, result.status); }, }), ],});
// Send what is batched now (the page is being hidden and may never come back).export function flushTelemetry() { void tracerProvider.forceFlush(); void loggerProvider.forceFlush();}What each part is for:
- The resource says who sent the data.
ouo.platform = "web"is how ouo11y knows a browser sent it;service.versionis the app version that session health is broken down by. SessionSpanProcessorputssession.idanduser.idon every span when it starts. That is how ouo11y lists a session’s traces.SessionPropagatorsendstraceparent(one trace from the click to the API) andbaggagewith the session (the API’sBaggageSpanProcessorcopies it onto its spans). It adds the baggage itself on every request: the browser has no async context, so baggage set withcontext.with()is gone by the timefetchsends the request.propagateTraceHeaderCorsUrlsmust be a RegExp: OpenTelemetry compares a plain string with the whole request URL, so"http://localhost:5001"matches no call and the API starts its own trace.ignoreUrlskeeps the exporters’ own requests to the collector out of the traces.FetchInstrumentationcomes from@opentelemetry/browser-instrumentation/experimental/fetch, the replacement for the deprecated@opentelemetry/instrumentation-fetch(same options). PasssanitizeUrlif your URLs carry tokens: it rewritesurl.fullbefore the span records it.- The logger provider is set up now and used in step 6b.
How session.id travels
Section titled “How session.id travels”The session id is set in three places, by three pieces of code, so that every span of the trace carries it, the API’s too. Press Play to follow one checkout.
- 1 fetch span starts · FetchInstrumentation
- 2 getSession() · SessionSpanProcessor.onStart
- 3 session.id, user.id · set on the span
- 4 inject headers · SessionPropagator adds baggage
- 5 POST /api/checkout · traceparent + baggage
- 6 copy baggage · BaggageSpanProcessor, every span
- 7 200 OK
- 8 browser span · /v1/traces
- 9 API spans · /v1/traces
The request (row 5) carries two headers:
traceparent: 00-<trace id>-<fetch span id>-01baggage: session.id=<uuid>,user.id=<uuid>traceparent makes the API’s spans children of the browser’s fetch span: one trace. baggage carries the session, which the API’s spans do not know otherwise.
Start it
Section titled “Start it”Create frontend/src/telemetry/index.js, a first version that only starts the session:
// RUM without the ouo SDK. Import this first, before React.import "./otel.js";import { getSession } from "./session.js";
console.log("[telemetry] session", getSession().id);
export { API } from "./config.js";export function addEvent() {}It also exports an empty addEvent, so App.jsx can import it now. Step 6d uses the real one.
Replace frontend/src/main.jsx with:
frontend/src/main.jsx
// Start the telemetry before React renders anything.import "./telemetry";import { StrictMode } from "react";import { createRoot } from "react-dom/client";import App from "./App.jsx";
createRoot(document.getElementById("root")).render( <StrictMode> <App /> </StrictMode>,);In frontend/src/App.jsx, replace the line const API = "http://localhost:5001"; with:
import { API, addEvent } from "./telemetry";Collector moved to 14318 in step 4c? Create frontend/.env.local:
VITE_OTLP_ENDPOINT=http://localhost:14318VITE_REPLAY_ENDPOINT=http://localhost:14319Start Vite:
npm run devWhat you should see
Section titled “What you should see”Reload http://localhost:5173 with DevTools open. The Console prints [telemetry] session <uuid>. In the Network tab, the API calls have an OPTIONS preflight (the browser asks the API to accept traceparent and baggage) and a few seconds later a POST /v1/traces to the collector answers 200.
Click Add, then Checkout, wait up to a minute, then in Grafana: Explore, Tempo, TraceQL:
{ resource.service.name = "tiny-shop-otel-backend" && name = "charge card" }Open the newest trace. Its root is now the browser:
- tiny-shop-otel-frontend
POSTfetch span · session.id, user.id190 ms - tiny-shop-otel-backend
POST /api/checkoutsession.id, user.id from baggage158 ms middleware - corsMiddleware1 msmiddleware - jsonParser2 msrequest handler - /api/checkout1 mscheckout151 msvalidate cart30 mscharge card121 ms
Click any span, the browser’s and the API’s: session.id and user.id are on all of them, and they are the id the Console printed.
Next: send the RUM events.