Skip to content

The browser: 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.

Event
exceptionouo.exception.fatal = true
exceptionouo.exception.fatal = false
http.request.faileda 4xx or 5xx answer
session.start · navigation · web_vital · cart.addednothing went wrong
Session status
crashed
erroredunless it also crashed
healthyno exception, no failed request
The worst event of a session sets its status. Any other event name is a custom event: shown on the timeline, never counted in health.

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.

Create frontend/src/telemetry/events.js:

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);
}
  • emit is the one function that makes a RUM event: an OTLP log record with eventName and the same name as the event.name attribute, plus session.id and user.id. Its body is 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.end come from session.js when it starts or replaces a session. session.end is a courtesy: ouo11y also ends a session on its own after 30 minutes of silence.
  • exception with ouo.exception.fatal = true marks the session crashed. For a caught error you report yourself, send fatal: false and handled: true: the session is errored.
  • http.request.failed comes from the fetch instrumentation’s applyCustomAttributesOnSpan hook in otel.js, for every 4xx and 5xx answer.
  • navigation is a page view; trackNavigation also wraps history.pushState, which is what client-side routers call.
  • web_vital values come from web-vitals. Most of them are only final when the page is hidden, which is why step 6c flushes when that happens.

Replace frontend/src/telemetry/index.js with a second version:

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

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.