Skip to content

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.

Stop Vite with Ctrl+C in its terminal tab (still in frontend/), then install what the browser needs:

Terminal window
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:

Terminal window
mkdir src/telemetry

Three files: where the data goes, the session id, and OpenTelemetry itself.

Create frontend/src/telemetry/config.js:

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.

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
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 from localStorage first, 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 to localStorage at most once a second.
  • onSessionChange() lets the next files react when a session starts or ends: step 6b sends session.start and session.end, step 6c starts a new replay.
  • user.id is anonymous here: a random id kept with the browser across sessions. device.id is random too, one per browser profile, and never a fingerprint.

Create frontend/src/telemetry/otel.js:

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.version is the app version that session health is broken down by.
  • SessionSpanProcessor puts session.id and user.id on every span when it starts. That is how ouo11y lists a session’s traces.
  • SessionPropagator sends traceparent (one trace from the click to the API) and baggage with the session (the API’s BaggageSpanProcessor copies it onto its spans). It adds the baggage itself on every request: the browser has no async context, so baggage set with context.with() is gone by the time fetch sends the request.
  • propagateTraceHeaderCorsUrls must 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.
  • ignoreUrls keeps the exporters’ own requests to the collector out of the traces.
  • FetchInstrumentation comes from @opentelemetry/browser-instrumentation/experimental/fetch, the replacement for the deprecated @opentelemetry/instrumentation-fetch (same options). Pass sanitizeUrl if your URLs carry tokens: it rewrites url.full before the span records it.
  • The logger provider is set up now and used in step 6b.

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.

session.js
otel.js
API
Collector
  1. 1 fetch span starts · FetchInstrumentation
  2. 2 getSession() · SessionSpanProcessor.onStart
  3. 3 session.id, user.id · set on the span
  4. 4 inject headers · SessionPropagator adds baggage
  5. 5 POST /api/checkout · traceparent + baggage
  6. 6 copy baggage · BaggageSpanProcessor, every span
  7. 7 200 OK
  8. 8 browser span · /v1/traces
  9. 9 API spans · /v1/traces
session.id goes on the browser span when it starts, into the baggage header when fetch sends the request, and onto every API span from that header.

The request (row 5) carries two headers:

traceparent: 00-<trace id>-<fetch span id>-01
baggage: 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.

Create frontend/src/telemetry/index.js, a first version that only starts the session:

frontend/src/telemetry/index.js
// 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
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:

frontend/.env.local
VITE_OTLP_ENDPOINT=http://localhost:14318
VITE_REPLAY_ENDPOINT=http://localhost:14319

Start Vite:

Terminal window
npm run dev

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:

  1. tiny-shop-otel-frontendPOSTfetch span · session.id, user.id
    190 ms
  2. tiny-shop-otel-backendPOST /api/checkoutsession.id, user.id from baggage
    158 ms
  3. middleware - corsMiddleware
    1 ms
  4. middleware - jsonParser
    2 ms
  5. request handler - /api/checkout
    1 ms
  6. checkout
    151 ms
  7. validate cart
    30 ms
  8. charge card
    121 ms
One trace from the browser's fetch to the API's charge card. Every span has the same session.id.

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.