The browser: RUM events
6b. Send the RUM events
Section titled “6b. Send the RUM events”Spans alone are not a session for ouo11y: it builds sessions from log records. Each RUM event is one OTLP log record whose event.name says what happened, with session.id and user.id on it like on the spans. This is the what happened row of the contract.
The events and their attributes
Section titled “The events and their attributes”Every event also carries event.name, session.id and user.id.
event.name |
Attributes | Sent by |
|---|---|---|
session.start |
ouo.session.started_at, device.id, and session.previous_id when it replaced an expired session |
session.js, when a session starts |
session.end |
ouo.session.end_reason (inactivity or max_duration), ouo.session.duration_ms, device.id |
session.js, when it replaces an expired session |
exception |
exception.type, exception.message, exception.stacktrace, ouo.exception.fatal, ouo.exception.handled |
uncaught errors and unhandled rejections |
http.request.failed |
http.request.method, url.full, http.response.status_code |
the fetch instrumentation, for every 4xx and 5xx |
navigation |
url.path, ouo.navigation.from |
the first load and every client-side route change |
web_vital |
ouo.web_vital.name, ouo.web_vital.value, ouo.web_vital.rating |
web-vitals, usually when the page is hidden |
| anything else | yours | addEvent(), step 6d |
The exception.*, http.* and url.* names are OpenTelemetry semantic conventions. The ouo. names wait for one: see Semantic conventions.
The code
Section titled “The code”Create frontend/src/telemetry/events.js:
frontend/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);}emitis the one function that makes a RUM event: an OTLP log record witheventNameand the same name as theevent.nameattribute, plussession.idanduser.id. Itsbodyis a one-line summary (GET http://localhost:5001/api/fail 500,LCP 176 ms good), which is the log line Loki shows; ouo11y reads only the attributes.session.start/session.endcome fromsession.jswhen it starts or replaces a session.session.endis a courtesy: ouo11y also ends a session on its own after 30 minutes of silence.exceptionwithouo.exception.fatal = truemarks the sessioncrashed. For a caught error you report yourself, sendfatal: falseandhandled: true: the session iserrored.http.request.failedcomes from the fetch instrumentation’sapplyCustomAttributesOnSpanhook inotel.js, for every 4xx and 5xx answer.navigationis a page view;trackNavigationalso wrapshistory.pushState, which is what client-side routers call.web_vitalvalues come fromweb-vitals. Most of them are only final when the page is hidden, which is why step 6c flushes when that happens.
Start the events
Section titled “Start the events”Replace frontend/src/telemetry/index.js with a second version:
// RUM without the ouo SDK. Import this first, before React.import { captureErrors, trackNavigation, trackWebVitals } from "./events.js";import { flushTelemetry } from "./otel.js";import { getSession, touch } from "./session.js";
getSession(); // resume the session, or start one (session.start)captureErrors();trackNavigation();trackWebVitals();
// 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") flushTelemetry();});
console.log("[telemetry] session", getSession().id);
export { API } from "./config.js";export { addEvent } from "./events.js";Your session started in step 6a, before session.start existed. Start a fresh one: in the DevTools Console run
localStorage.removeItem("tiny-shop.session")and reload. Click Add twice, Checkout, Call broken API and Throw error, then switch to another browser tab for a moment (the page is hidden: the Web Vitals are sent).
What you should see
Section titled “What you should see”In Grafana, Explore, Loki:
{service_name="tiny-shop-otel-frontend"}session.start, navigation, web_vital, http.request.failed and exception, each with session_id. There is no cart.added yet: Add sends it from step 6d on.
Then More apps → ouo11y RUM → Sessions: your session is there, crashed, with 1 failed request and its traces. The replay panel is still empty: that is the next step.
Next: record the replay.