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.
Step 6. Instrument the web app
Section titled “Step 6. Instrument the web app”Stop Vite with Ctrl+C in its terminal tab (you are in ~/Desktop/tiny-shop/frontend), then:
npm install @ouo11y/browser@0.2.16a. Initialise the SDK
Section titled “6a. Initialise the SDK”Create 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”. propagateTolists the URLs that get thetraceparentandbaggageheaders. That is what joins the API’s spans to the browser’s trace and carriessession.idto 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:
VITE_OUO_ENDPOINT=http://localhost:14318VITE_OUO_REPLAY_ENDPOINT=http://localhost:143196b. Load it before React
Section titled “6b. Load it before React”Replace frontend/src/main.jsx with:
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>,);6c. Use it in the app
Section titled “6c. Use it in the app”Replace frontend/src/App.jsx with:
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
{ "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
import react from "@vitejs/plugin-react";import { defineConfig } from "vite";
export default defineConfig({ plugins: [react()],});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>6d. Run it
Section titled “6d. Run it”npm run devWhat you should see
Section titled “What you should see”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.
- 1 fetch /api/products · SDK adds traceparent, baggage
- 2 OPTIONS /api/products · Access-Control-Request-Headers: baggage, traceparent
- 3 204 · cors(): these headers are allowed
- 4 GET /api/products · traceparent: 00-<trace id>-<span id>-01
- 5 200 [products]
- 6 the three products
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.