Skip to content

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.

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
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
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:14318
const 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
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}`));
Terminal window
cd ~/Desktop/tiny-shop-funnels/backend
Terminal window
npm install
Terminal window
npm start

Check: the first line it prints is [otel] traces to http://localhost:4318.

Stop Vite (Ctrl+C in its terminal). Replace frontend/package.json; it adds @ouo11y/browser 0.2.1:

frontend/package.json
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
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
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 pushState as a navigation event with url.path. Those are the funnel’s page steps.
  • Three custom events: rum.addEvent("add_to_cart"), rum.addEvent("checkout_submitted") and rum.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 with rum.captureError.
frontend/src/App.jsx
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>
);
}
Terminal window
cd ~/Desktop/tiny-shop-funnels/frontend
Terminal window
npm install
Terminal window
npm run dev

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:

0s6s12s18s24s
0snavigation/
1sGET /api/products
4snavigation/products/1
7sadd_to_cartproduct.id 1
9snavigation/cart
11snavigation/checkout
17scheckout_submittedcart.items 1
17sPOST /api/checkoutcheckout · charge card
18sorder_paidorder.total 4
18.5snavigation/orders/…
One purchase: five navigation events, three custom events, and the checkout call traced into the API.

The funnel you build later uses five of these rows: navigation /, navigation /products/1, add_to_cart, navigation /checkout, order_paid.

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.