Skip to content

4. The browser SDK

@ouo11y/browser is built on the OpenTelemetry JavaScript SDK. One initRUM call starts a session, records errors and Web Vitals, traces every fetch, and records a replay. The part that ties this page to the last one is propagateTo: calls to your API carry the browser’s trace context, so the API’s spans join the browser’s trace.

Stop Vite with Ctrl+C in its terminal tab (you are in ~/Desktop/tiny-shop/frontend), then:

Terminal window
npm install @ouo11y/browser@0.2.1

Create frontend/src/rum.js:

frontend/src/rum.js
frontend/src/rum.js
// ouo browser SDK init: sessions, errors, fetch tracing, Web Vitals and replay.
import { initRUM } from "@ouo11y/browser";
// The tiny-shop API (backend/). Moved it? VITE_API_URL in .env.local.
export const API = import.meta.env.VITE_API_URL || "http://localhost:5001";
export const rum = initRUM({
// The ouo collector. Moved ports? Set VITE_OUO_ENDPOINT and VITE_OUO_REPLAY_ENDPOINT in .env.local
// (the replay URL is derived only when the endpoint uses port 4318).
endpoint: import.meta.env.VITE_OUO_ENDPOINT || "http://localhost:4318",
replayEndpoint: import.meta.env.VITE_OUO_REPLAY_ENDPOINT || undefined,
projectId: "demo",
projectToken: "pt_dev_demo_0000000000000000", // public by design, ships in the bundle
service: { name: "tiny-shop-frontend", version: "1.0.0", environment: "local" },
// Calls to the API get `traceparent` + `baggage`, so its spans join the browser's trace.
// Use a RegExp: OpenTelemetry compares a string entry with the whole request URL.
tracing: { propagateTo: [new RegExp(`^${API}/`)] },
replay: { enabled: true, maskAllInputs: true },
debug: true,
});
  • The package is @ouo11y/browser. Importing @ouo/browser (its old name) fails with “could not be resolved”.
  • propagateTo lists the URLs that get the traceparent and baggage headers. That is what joins the API’s spans to the browser’s trace and carries session.id to the backend. Use a RegExp: OpenTelemetry compares a plain string with the whole request URL, so "http://localhost:5001" matches no call at all and the backend quietly starts its own trace.
  • The project token is public by design: it ships in every page, like any RUM key.

Collector moved to 14318 in step 4c? Create frontend/.env.local; the replay URL is only derived on its own when the endpoint is on 4318:

frontend/.env.local
VITE_OUO_ENDPOINT=http://localhost:14318
VITE_OUO_REPLAY_ENDPOINT=http://localhost:14319

Replace frontend/src/main.jsx with:

frontend/src/main.jsx
frontend/src/main.jsx
// Initialise ouo before React renders anything.
import "./rum";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.jsx";
createRoot(document.getElementById("root")).render(
<StrictMode>
<App />
</StrictMode>,
);

Replace frontend/src/App.jsx with:

frontend/src/App.jsx
frontend/src/App.jsx
import { useEffect, useState } from "react";
import { API, rum } from "./rum";
export default function App() {
const [products, setProducts] = useState([]);
const [cart, setCart] = useState([]);
const [message, setMessage] = useState("");
useEffect(() => {
fetch(`${API}/api/products`).then((r) => r.json()).then(setProducts);
}, []);
function addToCart(p) {
setCart([...cart, p]);
// A custom event: a log record with event.name "cart.added" in Loki.
rum.addEvent("cart.added", { "product.id": p.id, "product.name": p.name });
}
async function checkout() {
const res = await fetch(`${API}/api/checkout`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ items: cart.map((p) => p.id) }),
});
const data = await res.json();
setMessage(`Order #${data.orderId} placed`);
setCart([]);
}
async function callBrokenApi() {
// A 500: recorded as http.request.failed in the browser and as an error span in the API.
const res = await fetch(`${API}/api/fail`);
setMessage(`API answered ${res.status}`);
}
function crash() {
// An uncaught error: an exception event, and the session is marked crashed.
throw new Error("Button exploded");
}
return (
<main style={{ fontFamily: "sans-serif", maxWidth: 480, margin: "40px auto" }}>
<h1>Tiny Shop</h1>
<ul>
{products.map((p) => (
<li key={p.id}>
{p.name} (${p.price}) <button onClick={() => addToCart(p)}>Add</button>
</li>
))}
</ul>
<p>Cart: {cart.length} item(s)</p>
<button onClick={checkout} disabled={!cart.length}>Checkout</button>{" "}
<button onClick={callBrokenApi}>Call broken API</button>{" "}
<button onClick={crash}>Throw error</button>
<p>{message}</p>
</main>
);
}

Compared with page 1: the API URL now comes from rum.js (the same value propagateTo uses), and Add records a custom cart.added event.

The other files Vite created stay as they were. For comparison, the finished ones (your version numbers may be newer):

frontend/package.json
frontend/package.json
{
"name": "tiny-shop-frontend",
"private": true,
"version": "1.0.0",
"description": "ouo quickstart: React app instrumented with @ouo11y/browser",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"engines": {
"node": ">=24"
},
"dependencies": {
"@ouo11y/browser": "^0.2.1",
"react": "^19.3.1",
"react-dom": "^19.3.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^6.1.2",
"vite": "^8.3.4"
}
}
frontend/vite.config.js
frontend/vite.config.js
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [react()],
});
frontend/index.html
frontend/index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tiny Shop</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
Terminal window
npm run dev

Reload http://localhost:5173 and open DevTools → Console: [ouo] @ouo11y/browser@0.2.1 initialised {...}.

In the Network tab, each call to the API now has an OPTIONS preflight before it: the browser is asking the API to accept traceparent and baggage, and cors() in server.js says yes.

Page
Browser
API
  1. 1 fetch /api/products · SDK adds traceparent, baggage
  2. 2 OPTIONS /api/products · Access-Control-Request-Headers: baggage, traceparent
  3. 3 204 · cors(): these headers are allowed
  4. 4 GET /api/products · traceparent: 00-<trace id>-<span id>-01
  5. 5 200 [products]
  6. 6 the three products
Why the OPTIONS requests appear: the trace headers are not on the browser's list of simple headers, so it asks the API first.

You also see the SDK sending its own telemetry to the collector: spans to localhost:4318/v1/traces, events to localhost:4318/v1/logs, and replay chunks to localhost:4319/v1/replay/chunks.

Both apps are instrumented. Next, use the shop and look at the result.