💪Exercise💪 — browser telemetry: follow a click from your browser to the failing service
The OTel Demo shop already runs OpenTelemetry JS in the browser. Its spans report as
frontend-web. The load generator sends plain HTTP, not browser traffic, so everyfrontend-webspan comes from a participant. Filter by your own session.
Goal
Open the shop in your own browser, find your session in Tempo, and follow a failed page request from the browser span to the backend span that caused it. Then block the export and watch the browser spans disappear while the backend spans keep arriving.
Prerequisites
kubectlaccess to the workshop cluster (namespaceotel).- A Chromium-based browser (Chrome, Edge) with DevTools. Firefox works, but menu names and error messages differ.
- Grafana on the workshop cluster, Tempo data source.
💪Exercise💪 — steps
1. Open the shop and read the exporter config
kubectl -n otel port-forward svc/frontend-proxy 8080:8080
Open http://localhost:8080. In DevTools → Console run:
window.ENV.NEXT_PUBLIC_OTEL_EXPORTER_OTLP_TRACES_ENDPOINT
JSON.parse(localStorage.session).userId
- The first value is
/otlp-http/v1/traces. The server renders it into the page (Helm valuePUBLIC_OTEL_EXPORTER_OTLP_TRACES_ENDPOINT). The path is relative, so the browser posts to the origin that served the page. Envoy infrontend-proxyroutes/otlp-http/to Alloy. Page and endpoint always share an origin, so no CORS is involved. - The second value is your session ID. Write it down.
2. Generate traffic and watch it leave
DevTools → Network, filter otlp-http. Then in the shop:
- Click a product.
- Click Add To Cart.
- Open the product National Park Foundation Explorascope.
POST /otlp-http/v1/traces requests appear in batches, a few seconds after each action, with status 200.
Filter the Network tab by api/ and open any request → Headers → Request Headers. You will find traceparent: 00-<trace-id>-<span-id>-01. The fetch instrumentation injected it.
3. Find your session in Tempo
Grafana → Explore → Tempo → TraceQL (replace the ID):
{span.session.id="<your-session-id>"}
The session ID is an attribute, not a trace ID: {trace:id="<your-session-id>"} returns nothing. Trace IDs are 32 hex characters without dashes.
Every browser span carries session.id, so this query returns only your traffic. The rest of the group uses the same shop.
Group the results by span name and instrumentation scope:
| Span name | Scope | What it is |
|---|---|---|
documentLoad → documentFetch, resourceFetch |
instrumentation-document-load |
Full page load, one child per CSS/JS/font |
HTTP GET, HTTP POST |
instrumentation-fetch |
fetch() calls to /api/* |
click |
instrumentation-user-interaction |
DOM click, with target_xpath |
4. Follow one request across the tiers
Open an HTTP GET trace for /api/cart. Expected waterfall:
frontend-web HTTP GET (browser, CLIENT, root)
frontend-proxy ingress → router frontend egress (Envoy)
frontend GET → executing api route /api/cart
frontend grpc.oteldemo.CartService/GetCart
cart POST /oteldemo.CartService/GetCart
cart HGET (Valkey)
Now open a documentLoad trace. It has only frontend-web spans. Confirm it:
{resource.service.name="frontend-web" && name="documentLoad"} >> {resource.service.name="frontend"}
No results. The browser requests the HTML document before any JavaScript runs, so nothing injects traceparent into it. The server-side render of that page is a separate trace rooted at frontend-proxy.
Clicks are disconnected too. Every click trace is a single span, and the fetches the click caused are separate traces:
{resource.service.name="frontend-web" && name="click"} >> {resource.service.name="frontend"}
No results. The shop keeps the active span in zone.js (ZoneContextManager). Zone.js follows callbacks and timers, but it loses context at a native await. A click on a product changes the route, React re-renders, and the data loads from effects in later tasks. When fetch() runs, the click span has already ended, so each request starts its own trace. Only session.id and timing link a click to its requests.
The instrumentation itself works. Paste this into the Console and click the button that appears at the bottom of the page:
const b = document.body.appendChild(Object.assign(document.createElement('button'), { textContent: 'probe' }));
b.addEventListener('click', () => fetch('/api/currency'));
The fetch() runs synchronously inside the click handler, while the click span is still active. Re-run the query above: it now returns one trace, click → HTTP GET → frontend-proxy → frontend → currency. A click connects to the backend only if the app starts the request inside the click span’s context, for example with a span per navigation that parents its fetches.
5. Find the failure the user saw
The Explorascope product page loads with missing pieces (the baseline fault set breaks one product in product-catalog). Look for it from the browser side first:
{resource.service.name="frontend-web" && status=error}
Result: empty, even though requests failed. The fetch instrumentation records the HTTP code, but it does not set the span status. Search by status code instead:
{resource.service.name="frontend-web" && span.http.status_code >= 500}
Open one /api/products/OLJCESPC7Z trace. Follow the spans marked as errors down to product-catalog → oteldemo.ProductCatalogService/GetProduct. The span’s status message gives the cause.
The same question in a single query, browser to root cause:
{resource.service.name="frontend-web"} >> {resource.service.name="product-catalog" && status=error}
Before you move on, look at http.url on a /api/recommendations span. The session ID sits in the query string. That URL is personal data, and it is stored as an attribute.
6. Block the export
Do what an ad-blocker does. In DevTools → Network, right-click a otlp-http/v1/traces request → Block request URL. Reload the page and click around for a minute. The shop works.
- Network:
otlp-httprequests show(blocked:devtools). - Tempo: re-run the session query from step 3 for the last 5 minutes. No new browser spans.
- Backend spans for your
/api/*calls keep arriving, because the fetch instrumentation still creates the browser span and sendstraceparent. Only the export is blocked. The backend spans’ parent is the browser span that never reached Tempo, so the trace list shows the root as<root span not yet received>.
Remove the block when you are done (DevTools → Network request blocking panel).
Success criteria
- You found your own traces with a
session.idquery and named the three browser span types. - You showed one trace that runs from
frontend-webtocart, and onedocumentLoadtrace with no backend spans, and explained why they differ. You explained why shop clicks stay single-span traces, and your probe button produced aclicktrace that reachescurrency. - You found the failing product from the browser side and explained why
status=erroronfrontend-webreturns nothing. - With the export blocked, you confirmed in Tempo that backend spans arrived with a missing root while browser spans did not.
⭐Stretch: why the endpoint is relative (failure mode)
Open the shop through its public ingress, https://otel.workshop2.indexoutofrange.com, read your new session ID and find it in Tempo. Browser spans arrive with http.scheme=https. The same relative path works from localhost:8080 and from the public host.
An absolute endpoint works from one origin only. Try two alternatives with a hand-made export in the Console:
const probe = url => fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{"resourceSpans":[]}' })
.then(r => r.status, e => e.message);
| Page you run it on | probe(...) target |
Result | Why |
|---|---|---|---|
https://otel.workshop2.indexoutofrange.com |
'http://otel.workshop2.indexoutofrange.com/otlp-http/v1/traces' |
Failed to fetch, Console: Mixed Content |
HTTPS page, HTTP endpoint |
http://localhost:8080 |
'https://otel.workshop2.indexoutofrange.com/otlp-http/v1/traces' |
Failed to fetch, Console: blocked by CORS policy |
Cross-origin JSON triggers a preflight. Envoy answers OPTIONS with 405 |
| either | '/otlp-http/v1/traces' |
200 |
Same origin |
A third variant, http://localhost:8080/... loaded from the public page, would send each visitor’s spans to their own machine. Chrome blocks a public page’s request into the loopback address space, or asks the visitor for local-network permission. Every participant on the public URL would produce backend traces with a missing root and no browser spans, on a cluster where every pod is healthy.