Instrument
Two changes. The API sends traces with the OpenTelemetry Node SDK. The shop loads @ouo11y/browser, which records every page change by itself, and calls rum.addEvent at three moments. Those page views and events are what a funnel counts.
The API
Section titled “The API”Stop the API (Ctrl+C in its terminal). Replace backend/package.json: it adds the OpenTelemetry packages, and start now loads instrumentation.js first.
backend/package.json
{ "name": "tiny-shop-funnels-backend", "private": true, "version": "1.0.0", "description": "ouo quickstart: Express API with OpenTelemetry traces", "type": "commonjs", "scripts": { "start": "node --require ./instrumentation.js server.js" }, "engines": { "node": ">=24" }, "dependencies": { "@opentelemetry/api": "^1.10.0", "@opentelemetry/baggage-span-processor": "^0.6.0", "@opentelemetry/exporter-trace-otlp-http": "^0.300.0", "@opentelemetry/instrumentation-express": "^0.72.0", "@opentelemetry/instrumentation-http": "^0.300.0", "@opentelemetry/resources": "^3.0.0", "@opentelemetry/sdk-node": "^0.300.0", "@opentelemetry/sdk-trace": "^3.0.0", "@opentelemetry/semantic-conventions": "^1.43.0", "cors": "^2.8.6", "express": "^5.3.0" }}Create backend/instrumentation.js. It sends traces to the collector with the project credential, and copies the browser’s baggage (session.id, user.id) onto every span, so backend spans can be found from the RUM session.
backend/instrumentation.js
// OpenTelemetry SDK init: traces to the ouo collector over OTLP/HTTP.// Loaded before the app with `node --require ./instrumentation.js` so the http and express// instrumentations can patch those modules before server.js loads them.const { NodeSDK } = require("@opentelemetry/sdk-node");const { BatchSpanProcessor } = require("@opentelemetry/sdk-trace");const { OTLPTraceExporter } = require("@opentelemetry/exporter-trace-otlp-http");const { HttpInstrumentation } = require("@opentelemetry/instrumentation-http");const { ExpressInstrumentation } = require("@opentelemetry/instrumentation-express");const { BaggageSpanProcessor, ALLOW_ALL_BAGGAGE_KEYS } = require("@opentelemetry/baggage-span-processor");const { resourceFromAttributes } = require("@opentelemetry/resources");const { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } = require("@opentelemetry/semantic-conventions");
// The ouo collector, the only ingest. Moved ports? OUO_OTLP_ENDPOINT=http://localhost:14318const endpoint = process.env.OUO_OTLP_ENDPOINT || "http://localhost:4318";
// Every request needs the project credential: Basic base64("<project id>:<project token>").// This is the quickstart's `demo` project: base64("demo:pt_dev_demo_0000000000000000").const headers = { Authorization: "Basic ZGVtbzpwdF9kZXZfZGVtb18wMDAwMDAwMDAwMDAwMDAw" };
const sdk = new NodeSDK({ resource: resourceFromAttributes({ [ATTR_SERVICE_NAME]: "tiny-shop-funnels-backend", [ATTR_SERVICE_VERSION]: "1.0.0", "deployment.environment.name": "local", }), spanProcessors: [ // The browser SDK sends `baggage: session.id=...,user.id=...`; copy it onto every span so // backend spans can be searched by the RUM session. new BaggageSpanProcessor(ALLOW_ALL_BAGGAGE_KEYS), new BatchSpanProcessor({ exporter: new OTLPTraceExporter({ url: `${endpoint}/v1/traces`, headers }) }), ], instrumentations: [new HttpInstrumentation(), new ExpressInstrumentation()],});
sdk.start();console.log(`[otel] traces to ${endpoint}`);
const shutdown = () => sdk.shutdown().finally(() => process.exit(0));process.on("SIGINT", shutdown);process.on("SIGTERM", shutdown);Replace backend/server.js: the same API, plus a checkout span with a charge card child that records the declined card as an error.
backend/server.js
// The API. HTTP server spans come from the auto-instrumentation; the spans below are manual.const express = require("express");const cors = require("cors");const { trace, SpanStatusCode } = require("@opentelemetry/api");
const tracer = trace.getTracer("tiny-shop-funnels-backend");
const app = express();// The browser calls this API from another origin and adds `traceparent` and `baggage`// headers; cors() answers the preflight for them.app.use(cors());app.use(express.json());
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));const products = [ { id: 1, name: "Coffee", price: 4, blurb: "Single origin, roasted on Tuesdays." }, { id: 2, name: "Tea", price: 3, blurb: "Loose leaf green tea." }, { id: 3, name: "Cake", price: 6, blurb: "Lemon drizzle, one slice." },];
app.get("/api/products", async (req, res) => { await sleep(50 + Math.random() * 100); res.json(products);});
app.post("/api/checkout", async (req, res) => { const items = req.body.items || []; // The failing path, on demand: a card ending in 0002 is declined (a common test card), so you // know which checkout to look for in the replay. Any other card succeeds. const declined = String(req.body.cardLast4 || "") === "0002"; await tracer.startActiveSpan("checkout", async (span) => { span.setAttribute("app.cart.items", items.length); await tracer.startActiveSpan("charge card", async (child) => { await sleep(120); if (declined) { const err = new Error("card declined by payment provider"); child.recordException(err); child.setStatus({ code: SpanStatusCode.ERROR, message: err.message }); } child.end(); }); if (declined) span.setStatus({ code: SpanStatusCode.ERROR, message: "card declined" }); span.end(); }); if (declined) return res.status(500).json({ error: "card declined" }); const total = items.reduce((sum, id) => sum + (products.find((p) => p.id === id)?.price || 0), 0); res.json({ ok: true, orderId: Math.floor(Math.random() * 100000), total });});
const port = Number(process.env.PORT || 5001);app.listen(port, () => console.log(`tiny-shop-funnels API on http://localhost:${port}`));cd ~/Desktop/tiny-shop-funnels/backendnpm installnpm startCheck: the first line it prints is [otel] traces to http://localhost:4318.
The shop
Section titled “The shop”Stop Vite (Ctrl+C in its terminal). Replace frontend/package.json; it adds @ouo11y/browser 0.2.1:
frontend/package.json
{ "name": "tiny-shop-funnels-frontend", "private": true, "version": "1.0.0", "description": "ouo quickstart: React shop whose pages and custom events make a funnel", "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" }}Create frontend/src/rum.js. Two settings matter for funnels:
session: { sampleRate: 1 }keeps every session. A funnel counts sessions, so sampling would scale every step down.replay: { enabled: true, sampleRate: 1, maskAllInputs: true }records every session with inputs masked, so you can watch the ones that drop. The card number is not in the replay.
frontend/src/rum.js
// ouo browser SDK init: sessions, page views and custom events (the funnel steps), errors, fetch// tracing and session replay, so a session that dropped out of the funnel can be watched.import { initRUM } from "@ouo11y/browser";
// The 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, the only thing the page talks to. Spans and events go to 4318, replay // chunks to 4319 (derived from `endpoint` when it uses port 4318). Moved ports? Set // VITE_OUO_ENDPOINT and VITE_OUO_REPLAY_ENDPOINT in .env.local. 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-funnels-frontend", version: "1.0.0", environment: "local" }, // Calls to the API get `traceparent` + `baggage`, so its spans join the browser's trace and the // replay, the click and the backend error share one trace id. tracing: { propagateTo: [new RegExp(`^${API}/`)] },
// Every session is kept: a funnel counts sessions, so sampling would scale every step down. session: { sampleRate: 1 }, // Replay of every session, inputs masked: open a dropped session from the funnel and watch it. replay: { enabled: true, sampleRate: 1, maskAllInputs: true }, debug: false,});Replace frontend/src/main.jsx, so the SDK starts before React renders:
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. Compared with the plain shop:
- Pages need nothing. The SDK records every page load and every
pushStateas anavigationevent withurl.path. Those are the funnel’s page steps. - Three custom events:
rum.addEvent("add_to_cart"),rum.addEvent("checkout_submitted")andrum.addEvent("order_paid"). Those are the event steps. Their attributes (product.id,order.total) are kept on the event and shown on the session timeline; a funnel matches the name only. - The API URL comes from
rum.js, and a declined payment is reported withrum.captureError.
frontend/src/App.jsx
import { useEffect, useState } from "react";import { API, rum } from "./rum";
// A five-page shop. Every page change goes through history.pushState, which the SDK records as a// `navigation` event with `url.path`: those are the funnel's page steps. The custom events// (rum.addEvent) are its event steps://// / -> /products/<id> -> add_to_cart -> /checkout -> order_paid//// Funnel step = a page path (exact, or prefix such as /products/*) or a custom event name.
function usePath() { const [path, setPath] = useState(location.pathname); useEffect(() => { const onPop = () => setPath(location.pathname); addEventListener("popstate", onPop); return () => removeEventListener("popstate", onPop); }, []); const go = (to) => { history.pushState(null, "", to); // recorded by the SDK as navigation (kind push) setPath(to); }; return [path, go];}
function Link({ to, go, children, ...rest }) { return ( <a href={to} onClick={(e) => { e.preventDefault(); go(to); }} {...rest} > {children} </a> );}
export default function App() { const [path, go] = usePath(); 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 funnel step by its name. Attributes are kept on the event (Loki // structured metadata) and shown on the session timeline. rum.addEvent("add_to_cart", { "product.id": p.id, "product.name": p.name }); }
async function pay(e) { e.preventDefault(); const card = new FormData(e.target).get("card") || ""; rum.addEvent("checkout_submitted", { "cart.items": cart.length }); const res = await fetch(`${API}/api/checkout`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ items: cart.map((p) => p.id), cardLast4: card.slice(-4) }), }); if (!res.ok) { rum.captureError(new Error(`checkout failed: ${(await res.json()).error}`)); setMessage("Payment failed, try another card"); return; } const data = await res.json(); rum.addEvent("order_paid", { "order.id": data.orderId, "order.total": data.total }); setCart([]); go(`/orders/${data.orderId}`); }
const product = path.startsWith("/products/") && products.find((p) => `/products/${p.id}` === path);
return ( <main style={{ fontFamily: "sans-serif", maxWidth: 520, margin: "40px auto" }}> <nav style={{ display: "flex", gap: 12 }}> <Link to="/" go={go}>Tiny Shop</Link> <Link to="/cart" go={go} data-testid="cart-link">Cart ({cart.length})</Link> </nav>
{path === "/" && ( <ul> {products.map((p) => ( <li key={p.id}> <Link to={`/products/${p.id}`} go={go}>{p.name}</Link> (${p.price}) </li> ))} </ul> )}
{product && ( <section> <h2>{product.name}</h2> <p>{product.blurb}</p> <button onClick={() => addToCart(product)}>Add to cart (${product.price})</button> </section> )}
{path === "/cart" && ( <section> <h2>Cart</h2> <ul> {cart.map((p, i) => ( <li key={i}>{p.name}</li> ))} </ul> <button disabled={!cart.length} onClick={() => go("/checkout")}>Go to checkout</button> </section> )}
{path === "/checkout" && ( <form onSubmit={pay} style={{ display: "grid", gap: 6 }}> <h2>Checkout</h2> <input name="card" placeholder="card number" autoComplete="cc-number" /> <button disabled={!cart.length}>Pay</button> </form> )}
{path.startsWith("/orders/") && <h2>Thanks! Order {path.split("/").pop()} is paid.</h2>}
<p>{message}</p> </main> );}cd ~/Desktop/tiny-shop-funnels/frontendnpm installnpm run devWhat one purchase records
Section titled “What one purchase records”Buy a coffee again, as on the plain shop page. The SDK records a row like this for each page and event, all with the same session.id:
navigation/GET /api/productsnavigation/products/1add_to_cartproduct.id 1navigation/cartnavigation/checkoutcheckout_submittedcart.items 1POST /api/checkoutcheckout · charge cardorder_paidorder.total 4navigation/orders/…The funnel you build later uses five of these rows: navigation /, navigation /products/1, add_to_cart, navigation /checkout, order_paid.
What you should see
Section titled “What you should see”In Grafana (http://localhost:3000) open More apps → ouo11y RUM → Sessions and open the newest session, yours. Its activity list has Navigation entries for /, /products/1, /cart, /checkout and /orders/..., with add_to_cart, checkout_submitted and order_paid between them. The replay plays your clicks, with the card field masked.
Next: fifty shoppers.