Skip to content

Bring your own OpenTelemetry

ouo11y reads OpenTelemetry. If your browser app sends the signals on this page, you get the same sessions, health percentages, timelines, traces and replays as with our SDK, with nothing but upstream open-source packages in your bundle: the OpenTelemetry JS SDK, rrweb and web-vitals.

This is a first-class path, not a fallback. Our SDK produces exactly this contract and nothing more, so you can start on either side and move later without touching the server.

Your page
OpenTelemetry JSspans · log records
rrwebreplay events
Collector
:4318 /v1/tracesspans with session.id
:4318 /v1/logsevents by event.name
:4319 /v1/replay/chunksrrweb chunks
ouo11y
The sessionstatus · timeline · traces · replay
Two OTLP signals and one replay upload. Everything else is built on the server from session.id.

This is the whole contract.

What How
Where OTLP/HTTP to the collector: :4318/v1/traces and :4318/v1/logs. JSON or protobuf, gzip allowed.
Credential Every request carries Authorization: Basic base64("<project id>:<project token>"). The token is public: it ships in your bundle and only allows writing into one project.
Who Resource attributes service.name, service.version, deployment.environment.name, and ouo.platform = "web". Optionally device.id.
Which session session.id and user.id on every span and log record, as attributes, not resource attributes (the session can change while the page lives). Also in the baggage header to your API.
What happened OTel log records whose event.name is session.start, session.end, exception, http.request.failed, navigation or web_vital. Any other name is shown as a custom event on the timeline.
Replay rrweb events in chunks { session_id, seq, started_at, ended_at, events }, POST to :4319/v1/replay/chunks with the same credential and the headers x-ouo-session-id and x-ouo-seq.

From these, ouo11y builds the session, its timeline, its traces and its replay. The session’s status is the worst thing that happened in it:

Status When
crashed an exception with ouo.exception.fatal = true
errored a non-fatal exception, or an http.request.failed
healthy neither

A session with no session.end is treated as ended 30 minutes after its last signal, so a page closed without warning is not a problem.

Every event also carries session.id and user.id.

event.name Required attributes Optional
session.start ouo.session.started_at (epoch ms) session.previous_id when it replaced an expired session, device.id
session.end ouo.session.end_reason, ouo.session.duration_ms ouo.session.started_at, ouo.session.ended_at, device.id
exception exception.type, exception.message, exception.stacktrace, ouo.exception.fatal, ouo.exception.handled ouo.exception.kind
http.request.failed http.request.method, url.full, http.response.status_code
navigation url.path, ouo.navigation.from url.full, ouo.navigation.kind
web_vital ouo.web_vital.name, ouo.web_vital.value, ouo.web_vital.rating ouo.web_vital.id, ouo.web_vital.delta, url.path

ouo.session.end_reason is one of inactivity, max_duration, user_change or manual on the web. The log record’s body is free text: ouo11y reads only the attributes, and the body is what you see as the line in Loki.

Every ouo.-prefixed name here is waiting for an OpenTelemetry convention: see Semantic conventions. When OpenTelemetry defines one, we move to it with a migration window, and this table changes with it.

The six files below are the browser side of Quickstart 2: Tiny Shop with plain OpenTelemetry, about 400 lines in all. That quickstart builds the whole app, the API and the stack from an empty folder; this section is the part you would copy into an app you already have. They are written for OpenTelemetry JS 3.0 and a Vite app.

  1. Install the packages.

    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
  2. Say where the data goes and who sends it. The credential here is the local stack’s demo project.

    src/telemetry/config.js
    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",
    };
  3. Keep the session. The session is the only state you keep yourself: a UUID in localStorage, renewed after 30 minutes of inactivity or 4 hours in total, shared by every tab of the origin, with device.id kept apart from it.

    src/telemetry/session.js
    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;
    }
  4. Set up OpenTelemetry. Two pieces are specific to RUM. A SpanProcessor whose onStart puts session.id and user.id on every span. And a propagator that adds them to baggage at inject time: the browser has no async context, so baggage set with context.with() is gone by the time fetch sends.

    src/telemetry/otel.js
    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();
    }
  5. Emit the events. One small emit function writes log records with event.name and the session ids; the rest wires errors, failed requests, navigations and Web Vitals to it.

    src/telemetry/events.js
    src/telemetry/events.js
    // RUM events as OpenTelemetry log records. ouo reads them by event.name
    // (session.start, exception, http.request.failed, navigation, web_vital); any other name is a
    // custom event on the session timeline. The body is a one-line summary for the Loki log line;
    // ouo itself reads only the attributes.
    import { logs, SeverityNumber } from "@opentelemetry/api";
    import { onCLS, onFCP, onINP, onLCP, onTTFB } from "web-vitals";
    import { setFailedFetchHandler } from "./otel.js";
    import { deviceId, getSession, onSessionChange } from "./session.js";
    const logger = logs.getLogger("tiny-shop-rum");
    export function emit(name, attributes = {}, { severity = "INFO", session = getSession(), body = name } = {}) {
    logger.emit({
    eventName: name,
    severityNumber: SeverityNumber[severity],
    severityText: severity,
    body,
    attributes: { "event.name": name, "session.id": session.id, "user.id": session.userId, ...attributes },
    });
    }
    // A custom event, e.g. addEvent("cart.added", { "product.id": 1 }).
    export function addEvent(name, attributes = {}) {
    emit(name, attributes, { body: `${name} ${JSON.stringify(attributes)}` });
    }
    // session.start and session.end, with the ids of the session they are about.
    onSessionChange((type, session, detail) => {
    if (type === "start") {
    emit(
    "session.start",
    {
    "ouo.session.started_at": session.startedAt,
    "device.id": deviceId,
    ...(detail ? { "session.previous_id": detail.id } : {}),
    },
    { session, body: `session.start ${session.id}` },
    );
    } else {
    emit(
    "session.end",
    {
    "ouo.session.end_reason": detail,
    "ouo.session.duration_ms": session.lastActivity - session.startedAt,
    "device.id": deviceId,
    },
    { session, body: `session.end ${session.id} (${detail})` },
    );
    }
    });
    // Uncaught errors and unhandled rejections. fatal = true marks the session "crashed"; the
    // page keeps running, so the session goes on.
    export function captureErrors() {
    const report = (error) => {
    const err = error instanceof Error ? error : new Error(String(error));
    emit(
    "exception",
    {
    "exception.type": err.name,
    "exception.message": err.message,
    "exception.stacktrace": err.stack ?? "",
    "ouo.exception.fatal": true,
    "ouo.exception.handled": false,
    },
    { severity: "ERROR", body: `${err.name}: ${err.message}` },
    );
    };
    addEventListener("error", (event) => report(event.error ?? event.message));
    addEventListener("unhandledrejection", (event) => report(event.reason));
    }
    // A 4xx or 5xx answer marks the session "errored". The fetch span has the details.
    setFailedFetchHandler((method, url, status) => {
    emit(
    "http.request.failed",
    { "http.request.method": method, "url.full": url, "http.response.status_code": status },
    { severity: "WARN", body: `${method} ${url} ${status}` },
    );
    });
    // Page views: the first load and every client-side route change.
    export function trackNavigation() {
    let from = document.referrer ? new URL(document.referrer).pathname : "";
    const report = () => {
    if (location.pathname === from) return;
    emit(
    "navigation",
    { "url.path": location.pathname, "ouo.navigation.from": from },
    { body: `navigation ${from || "(entry)"} -> ${location.pathname}` },
    );
    from = location.pathname;
    };
    report();
    const pushState = history.pushState;
    history.pushState = function (...args) {
    pushState.apply(this, args);
    report();
    };
    addEventListener("popstate", report);
    }
    // Core Web Vitals from Google's web-vitals library. Most are final only when the page is hidden.
    export function trackWebVitals() {
    // CLS has no unit; the others are milliseconds.
    const shown = (metric) => (metric.name === "CLS" ? metric.value.toFixed(3) : `${Math.round(metric.value)} ms`);
    const report = (metric) =>
    emit(
    "web_vital",
    {
    "ouo.web_vital.name": metric.name,
    "ouo.web_vital.value": metric.value,
    "ouo.web_vital.rating": metric.rating,
    },
    { body: `${metric.name} ${shown(metric)} ${metric.rating}` },
    );
    onCLS(report);
    onFCP(report);
    onINP(report);
    onLCP(report);
    onTTFB(report);
    }

    This file emits http.request.failed for every 4xx and 5xx. Our SDK only does it for 5xx and network failures, because a 404 is often expected. Choose what fits your app.

  6. Record the replay. rrweb with inputs masked and the ouo-block and ouo-mask classes, uploaded in chunks every 5 seconds or at 256 KB.

    src/telemetry/replay.js
    src/telemetry/replay.js
    // Session replay: rrweb records the page, and the events go to ouo in chunks
    // (docs/adr/0003-replay-model.md): { session_id, seq, started_at, ended_at, events },
    // POST /v1/replay/chunks on the collector's replay port, every 5 s or at 256 KB.
    import { record } from "rrweb";
    import { AUTHORIZATION, REPLAY_ENDPOINT } from "./config.js";
    import { getSession, nextReplaySeq, onSessionChange } from "./session.js";
    const FLUSH_INTERVAL_MS = 5000;
    const MAX_CHUNK_BYTES = 256 * 1024;
    const KEEPALIVE_MAX_BYTES = 60 * 1024; // browsers cap keepalive request bodies at 64 KiB in total
    let buffer = [];
    let bufferBytes = 0;
    let chunk = null; // { session_id, seq, started_at } of the events in the buffer
    let timer = null;
    function push(event) {
    if (buffer.length === 0) {
    // The chunk belongs to the session that was current when its first event happened.
    chunk = { session_id: getSession().id, seq: nextReplaySeq(), started_at: event.timestamp };
    timer = setTimeout(flushReplay, FLUSH_INTERVAL_MS);
    }
    buffer.push(event);
    bufferBytes += JSON.stringify(event).length;
    if (bufferBytes >= MAX_CHUNK_BYTES) flushReplay();
    }
    // Sends the buffered events as one chunk. pageHidden: the page may be gone after this.
    export function flushReplay(pageHidden = false) {
    if (buffer.length === 0) return;
    clearTimeout(timer);
    const body = JSON.stringify({ ...chunk, ended_at: buffer[buffer.length - 1].timestamp, events: buffer });
    const headers = {
    "content-type": "application/json",
    Authorization: AUTHORIZATION,
    "x-ouo-session-id": chunk.session_id,
    "x-ouo-seq": String(chunk.seq),
    };
    buffer = [];
    bufferBytes = 0;
    fetch(`${REPLAY_ENDPOINT}/v1/replay/chunks`, {
    method: "POST",
    headers,
    body,
    // keepalive lets the request outlive the page, but only for small bodies.
    keepalive: pageHidden && body.length < KEEPALIVE_MAX_BYTES,
    }).catch((err) => console.warn("[replay] chunk upload failed", err));
    }
    export function startReplay() {
    record({
    emit: push,
    maskAllInputs: true, // what users type never leaves the browser
    blockClass: "ouo-block", // elements with this class are recorded as an empty box
    maskTextClass: "ouo-mask", // text inside elements with this class is masked
    checkoutEveryNms: 5 * 60 * 1000, // a full snapshot every 5 minutes keeps seeking fast
    });
    // A new session needs its own full snapshot: send the old session's events first.
    onSessionChange((type, session, previous) => {
    if (type !== "start" || !previous) return;
    flushReplay(document.visibilityState === "hidden");
    record.takeFullSnapshot();
    });
    }
  7. Start it before your app renders.

    src/telemetry/index.js
    src/telemetry/index.js
    // RUM without the ouo SDK: OpenTelemetry + rrweb + web-vitals. Import this first, before React.
    import { captureErrors, trackNavigation, trackWebVitals } from "./events.js";
    import { flushTelemetry } from "./otel.js";
    import { flushReplay, startReplay } from "./replay.js";
    import { getSession, touch } from "./session.js";
    getSession(); // resume the session, or start one (session.start)
    captureErrors();
    trackNavigation();
    trackWebVitals();
    startReplay();
    // User activity keeps the session alive.
    for (const type of ["click", "keydown", "scroll", "touchstart"]) {
    addEventListener(type, touch, { capture: true, passive: true });
    }
    // The tab is hidden: it may be closed next, so send everything now.
    addEventListener("visibilitychange", () => {
    if (document.visibilityState !== "hidden") return;
    flushReplay(true);
    flushTelemetry();
    });
    console.log("[telemetry] session", getSession().id);
    export { API } from "./config.js";
    export { addEvent } from "./events.js";
    // src/main.jsx: the first import
    import "./telemetry/index.js";

Open the app and the console prints [telemetry] session <uuid>. In the network panel you see POST /v1/logs, /v1/traces and /v1/replay/chunks answered 200. In Grafana the session appears in the ouo11y app with its timeline and replay. Then set up your backend so its spans join the browser’s traces.

  • OpenTelemetry JS 3.0 in the browser: @opentelemetry/sdk-trace-web is deprecated. The TracerProvider and the StackContextManager are in @opentelemetry/sdk-trace, and there is no provider.register(): set the context manager, propagator and providers on the API globals, as otel.js does.
  • @opentelemetry/instrumentation-fetch is deprecated in its README, though npm does not flag it. Use @opentelemetry/browser-instrumentation/experimental/fetch.
  • propagateTraceHeaderCorsUrls strings must equal the whole URL. Use a RegExp.
  • Event names are the contract. OpenTelemetry’s own browser instrumentations emit browser.navigation and browser.web_vital, which ouo11y shows as custom events today. See Semantic conventions for where this is going.
  • LCP and FCP need a visible page. web-vitals reports nothing for a page loaded in a background tab.
  • CORS on your API. It must allow the traceparent and baggage request headers, or the browser drops them silently. See Your backend.
  • Collector CORS. Posting from the page to a collector on another origin needs the collector to allow your origin and the Authorization, Content-Type, X-Ouo-Session-Id and X-Ouo-Seq headers. A first-party endpoint avoids CORS altogether; it works the same without our SDK.