From e689795fcfedb9ee8daa10000b0916178cdd48ce Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Mon, 28 Sep 2026 10:32:49 +0200 Subject: [PATCH 01/28] docs(blog): frontend tracing guide (draft) --- .../components/blog/demos/FrontendTrace.astro | 227 ++++++++++++++ .../blog/frontend-tracing-opentelemetry.mdx | 277 ++++++++++++++++++ 2 files changed, 504 insertions(+) create mode 100644 apps/landing/src/components/blog/demos/FrontendTrace.astro create mode 100644 apps/landing/src/content/blog/frontend-tracing-opentelemetry.mdx diff --git a/apps/landing/src/components/blog/demos/FrontendTrace.astro b/apps/landing/src/components/blog/demos/FrontendTrace.astro new file mode 100644 index 0000000000..9596e11d16 --- /dev/null +++ b/apps/landing/src/components/blog/demos/FrontendTrace.astro @@ -0,0 +1,227 @@ +--- +/** + * FrontendTrace — one client-side navigation traced end to end: the router + * span, the loader, the fetch it makes, and the backend spans that joined the + * same trace through `traceparent`. Static; styles follow TraceComparison. + */ + +interface SpanRow { + service: string; + op: string; + startMs: number; + durationMs: number; + depth: number; + note?: string; +} + +const TOTAL_MS = 820; +const INDENT_PX = 14; + +const rows: SpanRow[] = [ + { service: "acme-web", op: "navigate /projects/$projectId", startMs: 0, durationMs: 820, depth: 0 }, + { service: "acme-web", op: "loader /projects/$projectId", startMs: 6, durationMs: 634, depth: 1 }, + { service: "acme-web", op: "GET", startMs: 14, durationMs: 612, depth: 2 }, + { service: "acme-api", op: "GET /api/projects/{id}", startMs: 62, durationMs: 518, depth: 3 }, + { service: "postgres", op: "SELECT projects", startMs: 70, durationMs: 11, depth: 4 }, + { service: "postgres", op: "SELECT project_members", startMs: 88, durationMs: 471, depth: 4, note: "the slow part" }, +]; + +const orderedServices = Array.from(new Set(rows.map((r) => r.service))); +function serviceColor(name: string): string { + const idx = (orderedServices.indexOf(name) % 16) + 1; + return `var(--service-${idx})`; +} +--- + +
+
+
+ One click on a project link · 820ms · browser to database +
+
+
+
+ {rows.map((r, i) => { + const startPct = (r.startMs / TOTAL_MS) * 100; + const widthPct = (r.durationMs / TOTAL_MS) * 100; + return ( +
+ + {Array.from({ length: r.depth }).map((_, d) => ( + + {r.op} + + + {r.note && {r.note}} + + {r.durationMs}ms +
+ ); + })} +
+
+

+ The fetch took 612ms and the API spent 518ms of it, so about 90ms was network. The last 180ms after the + loader is React rendering the page. +

+
+
+
+ The first three rows come from the browser, the rest from the backend. They share a trace because the fetch + sent a traceparent header. +
+
+ + diff --git a/apps/landing/src/content/blog/frontend-tracing-opentelemetry.mdx b/apps/landing/src/content/blog/frontend-tracing-opentelemetry.mdx new file mode 100644 index 0000000000..b29e844fda --- /dev/null +++ b/apps/landing/src/content/blog/frontend-tracing-opentelemetry.mdx @@ -0,0 +1,277 @@ +--- +title: "Frontend tracing with OpenTelemetry: follow one click from the browser to the database" +description: "Your backend traces start when the request reaches your server. Here's how to trace the part before that: browser spans, route navigations and loaders, frontend errors, and session replay, all in the same trace as your API." +date: 2026-09-28 +author: "Jeremy Funk" +category: "guides" +draft: true +--- + +import FrontendTrace from "../../components/blog/demos/FrontendTrace.astro" + +A user tells you the project page is slow. You open your API dashboard, and `GET /api/projects/{id}` has a p95 of 120ms. Both of those are true, and your backend traces can't explain the gap, because they start when the request reaches your server. + +Everything before that is invisible from the backend: the route change, the loader that fired three requests one after another, the network, the render. That's often where the time goes. + +This guide covers tracing that part. We'll set up browser tracing, connect it to your backend traces, instrument route navigations and data loaders in a TanStack Router app, and catch the errors your framework swallows. The examples use Maple's browser SDK, but everything underneath is OpenTelemetry, and the problems in the first half apply whichever backend you send spans to. + +## What frontend observability covers + +Frontend observability is the same idea as backend observability, pointed at the code running in your users' browsers. In practice it's four signals: + +- **Traces.** Spans for navigations, data loading, and network calls, linked to the backend spans they caused. +- **Errors.** Uncaught exceptions, rejected promises, and the errors your framework catches before they ever reach a global handler. +- **Session replay.** A recording of what the user saw and did, so a trace or an error comes with the clicks that led to it. +- **Web Vitals.** Browser-reported page quality metrics like LCP, INP, and CLS. + +Traces are the one that ties the others together. An error without a trace tells you what broke; with a trace, you also see which navigation it happened in and what the backend returned. + +## Why browser tracing is harder than backend tracing + +If you've instrumented a Node or Go service, most of the browser will feel familiar. A few things are different, and they're the reason most frontend tracing setups end up with disconnected, half-empty traces. + +**There's no async context in the browser.** On the server, Node's `AsyncLocalStorage` carries the active span across `await`. Browsers have nothing equivalent yet. OpenTelemetry's default web context manager only tracks the active span synchronously, so a `fetch()` called after an `await` loses its parent and starts a new trace. The alternative, `ZoneContextManager`, needs zone.js and doesn't work with native `async`/`await` unless you transpile it away. + +**Cross-origin requests don't carry trace context by default.** The browser only adds the `traceparent` header to requests you opt in, and your API's CORS preflight has to allow that header. Get one side wrong and either the browser and backend traces never connect, or the request fails outright. + +**Tabs close without warning.** Spans are exported in batches. If the user closes the tab before the batch goes out, the last few seconds of spans are gone, and those are usually the seconds that made the user leave. You need to flush on `visibilitychange` and `pagehide`, since mobile browsers often never fire `unload`. + +**A single-page app doesn't have page loads after the first one.** The browser's navigation timing covers the initial document. Every route change after that is JavaScript, and nothing traces it unless you hook into your router. + +**Errors get caught before you can see them.** React error boundaries and router error components catch exceptions to render a fallback. That's good for the user, but it means the error never reaches `window.onerror`, so a global handler alone misses most rendering and data-loading errors. + +**The data is noisier and more sensitive.** Browser extensions throw errors into your page. Ad blockers block telemetry endpoints. URLs carry tokens and email addresses in query strings. Stack traces are minified. None of this is a reason to skip frontend tracing, but you'll want to plan for it. + +## Step 1: install the browser SDK + +Start with the automatic part. Maple's browser SDK sets up the OpenTelemetry web tracer, instruments `fetch`, captures uncaught errors, and records session replay: + +```bash +npm install @maple-dev/browser +``` + +Initialize it once, before your app renders: + +```ts +// src/maple.ts +import { MapleBrowser } from "@maple-dev/browser" + +MapleBrowser.init({ + ingestKey: import.meta.env.VITE_MAPLE_INGEST_KEY, // public key, maple_pk_... + serviceName: "acme-web", + serviceVersion: import.meta.env.VITE_COMMIT_SHA, + environment: import.meta.env.MODE, +}) +``` + +```tsx +// src/main.tsx +import "./maple" // first, before anything renders +``` + +That covers a few of the problems above without any extra work from you: + +- Every `fetch()` gets a client span. +- Uncaught errors and unhandled promise rejections become error spans, which show up as issues on the Errors page. +- Spans are exported every 2 seconds instead of OpenTelemetry's default 5, and flushed on `visibilitychange` and `pagehide`. +- Credential-looking query parameters (`token`, `code`, `password` and similar) are redacted from URLs before they leave the page. You can add your own rules with `privacy.sanitizeUrl`. +- Every span and every replay event carries the same `session.id`, so a trace links to the recording of the session that produced it. + +The ingest key is a public key, so it's safe in browser code. It can only write telemetry. + +## Step 2: connect browser traces to backend traces + +This is the step that makes frontend tracing worth doing. Once the browser's `fetch` span sends a `traceparent` header and your backend reads it, the browser and backend spans land in one trace: + + + +For requests to the same origin as the page, the header is sent automatically. If your API lives on another origin, list it: + +```ts +MapleBrowser.init({ + // ... + tracing: { + propagateTraceHeaderCorsUrls: [/^https:\/\/api\.acme\.com\//], + }, +}) +``` + +Then allow the header in your API's CORS configuration. The browser sends a preflight request, and if `traceparent` isn't in `Access-Control-Allow-Headers`, it blocks the real request: + +```http +Access-Control-Allow-Headers: content-type, authorization, traceparent, tracestate +``` + +Only list your own APIs. Sending `traceparent` to a third party leaks your trace ids, and many third-party APIs will reject the preflight. + +Your backend has to be instrumented with OpenTelemetry too. Every OpenTelemetry HTTP server instrumentation reads `traceparent` out of the box. If you haven't set one up yet, the [Node.js guide](/docs/guides/instrumentation-nodejs) covers it. + +One thing to expect in the waterfall: browser and server clocks disagree. The browser's timestamps come from the user's machine, so a server span can appear to start a few milliseconds before the fetch that caused it. The durations are right; the offsets between the two sides are approximate. + +## Step 3: trace route navigations and data loaders + +After step 2 you have `fetch` spans, but each one is its own trace. A navigation that fires three requests shows up as three unrelated traces, and nothing tells you they belonged to one click. + +The fix is a span per navigation that the loader and fetch spans nest under. In a TanStack Router app, the router emits lifecycle events you can subscribe to: `onBeforeNavigate` when a navigation starts, and `onResolved` once every loader has finished and the new route is committed. + +```ts +// src/tracing.ts +import { context, type Span, SpanStatusCode, trace } from "@opentelemetry/api" +import { type AnyRouter, isNotFound, isRedirect } from "@tanstack/react-router" + +const tracer = trace.getTracer("acme-web") + +let navigation: { span: Span; kind: "pageload" | "navigate" } | undefined + +export function traceNavigations(router: AnyRouter) { + router.subscribe("onBeforeNavigate", ({ fromLocation, toLocation, hrefChanged }) => { + // router.invalidate() reruns loaders without going anywhere + if (fromLocation && !hrefChanged) return + + // A click before the last navigation finished replaces it + navigation?.span.setAttribute("app.navigation.interrupted", true) + navigation?.span.end() + + const kind = fromLocation ? "navigate" : "pageload" + const span = tracer.startSpan(kind, { attributes: { "url.path": toLocation.pathname } }) + navigation = { span, kind } + }) + + router.subscribe("onResolved", () => { + if (!navigation) return + // Name the span after the route template, not the concrete URL + const leaf = router.state.matches.at(-1) + if (leaf) navigation.span.updateName(`${navigation.kind} ${leaf.fullPath}`) + navigation.span.end() + navigation = undefined + }) +} +``` + +The `updateName` call matters more than it looks. A span name is what every view groups by, so `navigate /projects/$projectId` gives you one row with a real p95, while `navigate /projects/8f2a-4c11` gives you one row per project that never aggregates. The concrete path is still there as the `url.path` attribute when you need to find one specific navigation. + +Loaders get their own span, parented to the current navigation: + +```ts +// src/tracing.ts, continued +const recorded = new WeakSet() + +export function traced(name: string, fn: () => Promise): Promise { + const parent = navigation ? trace.setSpan(context.active(), navigation.span) : context.active() + + return tracer.startActiveSpan(name, {}, parent, async (span) => { + try { + return await fn() + } catch (error) { + // redirect() and notFound() are thrown, but they aren't failures + if (!isRedirect(error) && !isNotFound(error)) { + span.recordException(error instanceof Error ? error : String(error)) + span.setStatus({ code: SpanStatusCode.ERROR }) + if (typeof error === "object" && error !== null) recorded.add(error) + } + throw error + } finally { + span.end() + } + }) +} + +export const alreadyRecorded = (error: unknown) => + typeof error === "object" && error !== null && recorded.has(error) +``` + +```ts +// src/routes/projects.$projectId.tsx +import { createFileRoute } from "@tanstack/react-router" +import { traced } from "../tracing" + +export const Route = createFileRoute("/projects/$projectId")({ + loader: ({ params }) => traced("loader /projects/$projectId", () => fetchProject(params.projectId)), +}) +``` + +Here's where the missing async context from earlier comes back. `startActiveSpan` makes the loader span active only until the first `await`. A `fetch()` called before that point nests under the loader. A `fetch()` called after it starts a new trace: + +```ts +// Both requests nest under the loader span +loader: ({ params }) => + traced("loader /projects/$projectId", () => + Promise.all([fetchProject(params.projectId), fetchMembers(params.projectId)]), + ) + +// The second request loses its parent +loader: ({ params }) => + traced("loader /projects/$projectId", async () => { + const project = await fetchProject(params.projectId) + const members = await fetchMembers(project.id) // new trace + return { project, members } + }) +``` + +If a request really does depend on the previous one, capture the context up front and pass it back in with `context.with(ctx, () => fetchMembers(project.id))`. But look at the loader first. Two sequential awaits in a loader is a request waterfall, and it's often exactly the slowness you were trying to find. + +Two more things to know. Loaders also run when TanStack Router preloads a route on hover. There's no navigation in progress then, so those loader spans show up as their own traces, and that's accurate. And the same pattern works in React Router, Vue Router, or anything else that tells you when a navigation starts and ends; only the event names change. + +## Step 4: report the errors your framework catches + +The SDK's global handlers see errors that nothing caught. TanStack Router wraps every route in an error boundary, so an exception thrown while rendering a route never gets that far. Report it from the router's `defaultOnCatch`: + +```ts +// src/router.tsx +import { MapleBrowser } from "@maple-dev/browser" +import { createRouter } from "@tanstack/react-router" +import { routeTree } from "./routeTree.gen" +import { alreadyRecorded, traceNavigations } from "./tracing" + +export const router = createRouter({ + routeTree, + defaultOnCatch: (error) => { + // Loader errors are already on the loader span + if (!alreadyRecorded(error)) MapleBrowser.captureException(error, { name: "react.render_error" }) + }, +}) + +traceNavigations(router) +``` + +The `alreadyRecorded` check is there because a loader that throws ends up in the same error boundary. Without it, one failed loader would show up as two errors. + +If you use React error boundaries of your own, do the same in their `componentDidCatch`. `captureException` records each error object once, so reporting and rethrowing is safe. + +## Step 5: trace server-side rendering (TanStack Start) + +TODO(research): SSR section. + +## What this setup doesn't cover + +A few gaps worth knowing about before you rely on this: + +- **Only `fetch` is instrumented.** Requests made with `XMLHttpRequest`, including older versions of axios, don't get spans or `traceparent` headers. You can register OpenTelemetry's `XMLHttpRequestInstrumentation` alongside the SDK if you need them. +- **No Web Vitals.** The SDK doesn't record LCP, INP, or CLS today. The `web-vitals` library reports them; note that LCP and CLS are only final when the page is hidden, long after the `pageload` span has ended, so record them as their own spans or events. +- **Stack traces are minified.** Maple strips bundle hashes and line numbers when grouping errors, so the same bug groups into one issue across deploys. The stack itself still shows minified names, since source maps aren't uploaded. +- **Ad blockers.** Some block requests to telemetry domains. If that matters for your users, point `endpoint` at a proxy on your own domain. +- **Only replay is sampled.** `replay.sampleRate` records a fraction of sessions. Browser traces are all sent. + +## FAQ + +### What is frontend observability? + +Collecting traces, errors, session replays, and performance metrics from the code running in users' browsers, so you can see what a user actually experienced rather than only what your servers did. The most useful part is linking browser traces to backend traces, so one click can be followed from the UI to the database. + +### Can OpenTelemetry trace a browser application? + +Yes. OpenTelemetry's JavaScript SDK has a web tracer and instrumentations for `fetch`, `XMLHttpRequest`, and document load. Browser support is younger than the server SDKs, and the biggest gap is async context: the default context manager loses the active span after an `await`. + +### Why are my browser and backend spans in separate traces? + +Almost always one of three things: the API's origin isn't in `propagateTraceHeaderCorsUrls`, the API's CORS preflight doesn't allow the `traceparent` header, or the backend isn't instrumented to read it. Check the request headers in your browser's network tab first. If there's no `traceparent`, it's the frontend config; if it's there, it's the backend. + +### How do I trace React Router or TanStack Router navigations? + +Subscribe to the router's navigation events. Start a span when a navigation begins, rename it to the matched route template, and end it when the router has resolved. Wrap loaders in child spans so the requests they make nest under the navigation. + +## Next steps + +- [Browser SDK reference](/docs/session-replay/browser-sdk): every option, including consent, masking, and URL redaction. +- [Session replays](/docs/session-replay/replays): jump from a trace to the recording of the session that produced it. +- [Errors and issues](/docs/errors/overview): how error spans are grouped into issues. From 9a1d38044a73edc0af9c8bb3e1496b07d3932130 Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Mon, 28 Sep 2026 10:40:01 +0200 Subject: [PATCH 02/28] docs(blog): SSR tracing section, figure matches the intro --- .../components/blog/demos/FrontendTrace.astro | 28 ++++--- .../blog/frontend-tracing-opentelemetry.mdx | 81 +++++++++++++++++-- 2 files changed, 88 insertions(+), 21 deletions(-) diff --git a/apps/landing/src/components/blog/demos/FrontendTrace.astro b/apps/landing/src/components/blog/demos/FrontendTrace.astro index 9596e11d16..104608c0fe 100644 --- a/apps/landing/src/components/blog/demos/FrontendTrace.astro +++ b/apps/landing/src/components/blog/demos/FrontendTrace.astro @@ -1,7 +1,7 @@ --- /** * FrontendTrace — one client-side navigation traced end to end: the router - * span, the loader, the fetch it makes, and the backend spans that joined the + * span, the loader, the fetches it makes, and the backend spans that joined the * same trace through `traceparent`. Static; styles follow TraceComparison. */ @@ -14,16 +14,18 @@ interface SpanRow { note?: string; } -const TOTAL_MS = 820; +const TOTAL_MS = 840; const INDENT_PX = 14; const rows: SpanRow[] = [ - { service: "acme-web", op: "navigate /projects/$projectId", startMs: 0, durationMs: 820, depth: 0 }, - { service: "acme-web", op: "loader /projects/$projectId", startMs: 6, durationMs: 634, depth: 1 }, - { service: "acme-web", op: "GET", startMs: 14, durationMs: 612, depth: 2 }, - { service: "acme-api", op: "GET /api/projects/{id}", startMs: 62, durationMs: 518, depth: 3 }, - { service: "postgres", op: "SELECT projects", startMs: 70, durationMs: 11, depth: 4 }, - { service: "postgres", op: "SELECT project_members", startMs: 88, durationMs: 471, depth: 4, note: "the slow part" }, + { service: "acme-web", op: "navigate /projects/$projectId", startMs: 0, durationMs: 840, depth: 0 }, + { service: "acme-web", op: "loader /projects/$projectId", startMs: 4, durationMs: 590, depth: 1 }, + { service: "acme-web", op: "GET", startMs: 10, durationMs: 180, depth: 2 }, + { service: "acme-api", op: "GET /api/projects/{id}", startMs: 52, durationMs: 118, depth: 3 }, + { service: "acme-web", op: "GET", startMs: 196, durationMs: 190, depth: 2 }, + { service: "acme-api", op: "GET /api/projects/{id}/members", startMs: 240, durationMs: 122, depth: 3 }, + { service: "acme-web", op: "GET", startMs: 392, durationMs: 196, depth: 2 }, + { service: "acme-api", op: "GET /api/projects/{id}/activity", startMs: 436, durationMs: 124, depth: 3, note: "one after another" }, ]; const orderedServices = Array.from(new Set(rows.map((r) => r.service))); @@ -36,7 +38,7 @@ function serviceColor(name: string): string {
- One click on a project link · 820ms · browser to database + One click on a project link · 840ms · browser to API
@@ -67,14 +69,14 @@ function serviceColor(name: string): string {

- The fetch took 612ms and the API spent 518ms of it, so about 90ms was network. The last 180ms after the - loader is React rendering the page. + Each API call takes about 120ms, so the backend looks healthy. The loader makes three of them one after + another, each with about 60ms of network on top, and React needs another 250ms to render the result.

- The first three rows come from the browser, the rest from the backend. They share a trace because the fetch - sent a traceparent header. + The acme-web rows come from the browser, the acme-api rows from the backend. They are + one trace because every fetch sent a traceparent header.
diff --git a/apps/landing/src/content/blog/frontend-tracing-opentelemetry.mdx b/apps/landing/src/content/blog/frontend-tracing-opentelemetry.mdx index b29e844fda..431140a4c9 100644 --- a/apps/landing/src/content/blog/frontend-tracing-opentelemetry.mdx +++ b/apps/landing/src/content/blog/frontend-tracing-opentelemetry.mdx @@ -11,7 +11,9 @@ import FrontendTrace from "../../components/blog/demos/FrontendTrace.astro" A user tells you the project page is slow. You open your API dashboard, and `GET /api/projects/{id}` has a p95 of 120ms. Both of those are true, and your backend traces can't explain the gap, because they start when the request reaches your server. -Everything before that is invisible from the backend: the route change, the loader that fired three requests one after another, the network, the render. That's often where the time goes. +Everything before that is invisible from the backend: the route change, the loader that fired three requests one after another, the network, the render. That's often where the time goes. Here's what the same complaint looks like once the browser is traced too: + + This guide covers tracing that part. We'll set up browser tracing, connect it to your backend traces, instrument route navigations and data loaders in a TanStack Router app, and catch the errors your framework swallows. The examples use Maple's browser SDK, but everything underneath is OpenTelemetry, and the problems in the first half apply whichever backend you send spans to. @@ -81,9 +83,7 @@ The ingest key is a public key, so it's safe in browser code. It can only write ## Step 2: connect browser traces to backend traces -This is the step that makes frontend tracing worth doing. Once the browser's `fetch` span sends a `traceparent` header and your backend reads it, the browser and backend spans land in one trace: - - +This is the step that makes frontend tracing worth doing. Once the browser's `fetch` span sends a `traceparent` header and your backend reads it, the browser and backend spans land in one trace, like the one at the top of this guide. For requests to the same origin as the page, the header is sent automatically. If your API lives on another origin, list it: @@ -106,7 +106,7 @@ Only list your own APIs. Sending `traceparent` to a third party leaks your trace Your backend has to be instrumented with OpenTelemetry too. Every OpenTelemetry HTTP server instrumentation reads `traceparent` out of the box. If you haven't set one up yet, the [Node.js guide](/docs/guides/instrumentation-nodejs) covers it. -One thing to expect in the waterfall: browser and server clocks disagree. The browser's timestamps come from the user's machine, so a server span can appear to start a few milliseconds before the fetch that caused it. The durations are right; the offsets between the two sides are approximate. +One thing to expect in the waterfall: browser and server clocks disagree. The browser's timestamps come from the user's machine, so a server span can appear to start a few milliseconds before the fetch that caused it. It gets worse with laptops, because the browser's high-resolution clock pauses while the machine sleeps, and a tab that was asleep can report timestamps that are minutes off. The durations are right; the offsets between the two sides are approximate. ## Step 3: trace route navigations and data loaders @@ -124,6 +124,9 @@ const tracer = trace.getTracer("acme-web") let navigation: { span: Span; kind: "pageload" | "navigate" } | undefined export function traceNavigations(router: AnyRouter) { + // The router also emits navigation events while rendering on the server + if (typeof window === "undefined") return + router.subscribe("onBeforeNavigate", ({ fromLocation, toLocation, hrefChanged }) => { // router.invalidate() reruns loaders without going anywhere if (fromLocation && !hrefChanged) return @@ -208,7 +211,7 @@ loader: ({ params }) => }) ``` -If a request really does depend on the previous one, capture the context up front and pass it back in with `context.with(ctx, () => fetchMembers(project.id))`. But look at the loader first. Two sequential awaits in a loader is a request waterfall, and it's often exactly the slowness you were trying to find. +If a request really does depend on the previous one, capture the context up front with `const ctx = context.active()` and call it as `context.with(ctx, () => fetchMembers(project.id))`. But look at the loader first. Sequential awaits in a loader are a request waterfall, and that's often exactly the slowness you were trying to find. The trace at the top of this guide is one: three healthy API calls, made one after another. Two more things to know. Loaders also run when TanStack Router preloads a route on hover. There's no navigation in progress then, so those loader spans show up as their own traces, and that's accurate. And the same pattern works in React Router, Vue Router, or anything else that tells you when a navigation starts and ends; only the event names change. @@ -240,7 +243,69 @@ If you use React error boundaries of your own, do the same in their `componentDi ## Step 5: trace server-side rendering (TanStack Start) -TODO(research): SSR section. +If you use TanStack Start, the first page load is rendered on the server, and that render is part of what the user waits for. Tracing it closes the last gap: a slow first load could be a slow loader on the server, a slow render, or slow JavaScript in the browser, and without server spans you can only see the last one. + +It also helps anyone debugging with an AI agent. An agent that can see "the server render of `/projects/$projectId` took 900ms, 700ms of it in one loader" has something concrete to fix; an agent that only sees a slow page load has to guess. + +Start with the OpenTelemetry Node SDK on the server, set up as in the [Node.js guide](/docs/guides/instrumentation-nodejs) and imported before anything else in your server entry. That gives you a span for every incoming request, plus spans for database and HTTP calls your loaders make. Then add a span around the render itself in `src/server.ts`: + +```ts +// src/server.ts +import "./instrumentation" // starts the OpenTelemetry Node SDK; must load first +import { context, propagation, trace } from "@opentelemetry/api" +import { createStartHandler, defaultStreamHandler, defineHandlerCallback } from "@tanstack/react-start/server" +import { createServerEntry } from "@tanstack/react-start/server-entry" + +const tracer = trace.getTracer("acme-web") + +const handler = defineHandlerCallback((ctx) => { + // Loaders have already run by now, so the matched route is known + const leaf = ctx.router.state.matches.at(-1) + + return tracer.startActiveSpan(`ssr ${leaf?.fullPath ?? "unknown"}`, async (span) => { + // Hand this trace to the browser so its pageload span can join it + const carrier: Record = {} + propagation.inject(context.active(), carrier) + if (carrier.traceparent) { + ctx.responseHeaders.append("server-timing", `traceparent;desc="${carrier.traceparent}"`) + } + + try { + return await defaultStreamHandler(ctx) + } finally { + span.end() + } + }) +}) + +export default createServerEntry({ fetch: createStartHandler(handler) }) +``` + +The `traced()` helper from step 3 works on the server as well, and it works better there: Node has `AsyncLocalStorage`, so fetches after an `await` keep their parent. Your loader spans show up under the request span without any changes. + +The `server-timing` header connects the two halves of a page load. The server writes its trace context into the response, and the browser reads it back from the Navigation Timing API to parent its `pageload` span: + +```ts +// src/tracing.ts +function serverContext() { + const [page] = performance.getEntriesByType("navigation") as PerformanceNavigationTiming[] + const traceparent = page?.serverTiming.find((entry) => entry.name === "traceparent")?.description + return traceparent ? propagation.extract(context.active(), { traceparent }) : context.active() +} + +// in onBeforeNavigate, parent only the first page load to the server +const span = tracer.startSpan( + kind, + { attributes: { "url.path": toLocation.pathname } }, + kind === "pageload" ? serverContext() : context.active(), +) +``` + +You'll often see this done with a `` tag instead, which is what OpenTelemetry's document-load instrumentation reads. Both work. The header keeps it out of the rendered HTML, so the server and client versions of your `` can't disagree during hydration. + +Two caveats. Only parent the `pageload` span this way; later navigations are new work and belong in their own traces, and attaching minutes of browsing to one server request makes that trace meaningless. And if a CDN caches your HTML, it caches the header too, so every visitor would join the same old trace. Skip the header on cached responses. + +One more thing to know about the `ssr` span: `defaultStreamHandler` returns as soon as React starts streaming, so the span measures the time to the first byte of HTML, not the time to the last. That's usually the number you want. ## What this setup doesn't cover @@ -260,7 +325,7 @@ Collecting traces, errors, session replays, and performance metrics from the cod ### Can OpenTelemetry trace a browser application? -Yes. OpenTelemetry's JavaScript SDK has a web tracer and instrumentations for `fetch`, `XMLHttpRequest`, and document load. Browser support is younger than the server SDKs, and the biggest gap is async context: the default context manager loses the active span after an `await`. +Yes. OpenTelemetry's JavaScript SDK has a web tracer and instrumentations for `fetch`, `XMLHttpRequest`, and document load. Browser support is still marked experimental, and the biggest gap is async context: the default context manager loses the active span after an `await`. A dedicated browser SIG is working on sessions, navigation events, and Web Vitals conventions. ### Why are my browser and backend spans in separate traces? From 0ed45e3c304845b030745b39c5667c19597b42e5 Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Mon, 28 Sep 2026 17:08:18 +0200 Subject: [PATCH 03/28] docs(blog): split frontend tracing guide into a hub and a TanStack page --- .../blog/frontend-tracing-opentelemetry.mdx | 220 +++++++----------- .../blog/frontend-tracing-tanstack.mdx | 184 +++++++++++++++ 2 files changed, 272 insertions(+), 132 deletions(-) create mode 100644 apps/landing/src/content/blog/frontend-tracing-tanstack.mdx diff --git a/apps/landing/src/content/blog/frontend-tracing-opentelemetry.mdx b/apps/landing/src/content/blog/frontend-tracing-opentelemetry.mdx index 431140a4c9..a1af966aef 100644 --- a/apps/landing/src/content/blog/frontend-tracing-opentelemetry.mdx +++ b/apps/landing/src/content/blog/frontend-tracing-opentelemetry.mdx @@ -1,5 +1,5 @@ --- -title: "Frontend tracing with OpenTelemetry: follow one click from the browser to the database" +title: "Frontend tracing with OpenTelemetry: follow one click from the browser to your API" description: "Your backend traces start when the request reaches your server. Here's how to trace the part before that: browser spans, route navigations and loaders, frontend errors, and session replay, all in the same trace as your API." date: 2026-09-28 author: "Jeremy Funk" @@ -15,7 +15,7 @@ Everything before that is invisible from the backend: the route change, the load -This guide covers tracing that part. We'll set up browser tracing, connect it to your backend traces, instrument route navigations and data loaders in a TanStack Router app, and catch the errors your framework swallows. The examples use Maple's browser SDK, but everything underneath is OpenTelemetry, and the problems in the first half apply whichever backend you send spans to. +This guide covers tracing that part. We'll set up browser tracing, connect it to your backend traces, trace route navigations and data loading, and catch the errors your framework swallows. The setup here works with any framework, and there's a [follow-up guide for each popular one](#framework-guides). The examples use Maple's browser SDK, but everything underneath is OpenTelemetry, and the problems in the first half apply whichever backend you send spans to. ## What frontend observability covers @@ -108,66 +108,58 @@ Your backend has to be instrumented with OpenTelemetry too. Every OpenTelemetry One thing to expect in the waterfall: browser and server clocks disagree. The browser's timestamps come from the user's machine, so a server span can appear to start a few milliseconds before the fetch that caused it. It gets worse with laptops, because the browser's high-resolution clock pauses while the machine sleeps, and a tab that was asleep can report timestamps that are minutes off. The durations are right; the offsets between the two sides are approximate. -## Step 3: trace route navigations and data loaders +## Step 3: give every navigation its own span After step 2 you have `fetch` spans, but each one is its own trace. A navigation that fires three requests shows up as three unrelated traces, and nothing tells you they belonged to one click. -The fix is a span per navigation that the loader and fetch spans nest under. In a TanStack Router app, the router emits lifecycle events you can subscribe to: `onBeforeNavigate` when a navigation starts, and `onResolved` once every loader has finished and the new route is committed. +The fix is a span per navigation that the data-loading and fetch spans nest under. Every router can tell you when a navigation starts and when the new route is ready, so the tracing code itself doesn't need to know which router you use. Here's the helper the framework guides below build on: ```ts // src/tracing.ts -import { context, type Span, SpanStatusCode, trace } from "@opentelemetry/api" -import { type AnyRouter, isNotFound, isRedirect } from "@tanstack/react-router" +import { context, propagation, type Span, SpanStatusCode, trace } from "@opentelemetry/api" const tracer = trace.getTracer("acme-web") let navigation: { span: Span; kind: "pageload" | "navigate" } | undefined +let firstLoad = true -export function traceNavigations(router: AnyRouter) { - // The router also emits navigation events while rendering on the server - if (typeof window === "undefined") return +/** Call when the router starts a navigation. */ +export function startNavigation(path: string) { + // A click before the last navigation finished replaces it + navigation?.span.setAttribute("app.navigation.interrupted", true) + navigation?.span.end() - router.subscribe("onBeforeNavigate", ({ fromLocation, toLocation, hrefChanged }) => { - // router.invalidate() reruns loaders without going anywhere - if (fromLocation && !hrefChanged) return + const kind = firstLoad ? "pageload" : "navigate" + // Only the first page load belongs to the server's trace, if there was one + const parent = firstLoad ? serverContext() : context.active() + firstLoad = false - // A click before the last navigation finished replaces it - navigation?.span.setAttribute("app.navigation.interrupted", true) - navigation?.span.end() - - const kind = fromLocation ? "navigate" : "pageload" - const span = tracer.startSpan(kind, { attributes: { "url.path": toLocation.pathname } }) - navigation = { span, kind } - }) - - router.subscribe("onResolved", () => { - if (!navigation) return - // Name the span after the route template, not the concrete URL - const leaf = router.state.matches.at(-1) - if (leaf) navigation.span.updateName(`${navigation.kind} ${leaf.fullPath}`) - navigation.span.end() - navigation = undefined - }) + navigation = { kind, span: tracer.startSpan(kind, { attributes: { "url.path": path } }, parent) } } -``` - -The `updateName` call matters more than it looks. A span name is what every view groups by, so `navigate /projects/$projectId` gives you one row with a real p95, while `navigate /projects/8f2a-4c11` gives you one row per project that never aggregates. The concrete path is still there as the `url.path` attribute when you need to find one specific navigation. -Loaders get their own span, parented to the current navigation: +/** Call when the new route is ready. `route` is its template, like `/projects/:id`. */ +export function endNavigation(route?: string) { + if (!navigation) return + if (route) navigation.span.updateName(`${navigation.kind} ${route}`) + navigation.span.end() + navigation = undefined +} -```ts -// src/tracing.ts, continued const recorded = new WeakSet() -export function traced(name: string, fn: () => Promise): Promise { +/** Run `fn` in a span under the current navigation. */ +export function traced( + name: string, + fn: () => Promise, + isFailure: (error: unknown) => boolean = () => true, +): Promise { const parent = navigation ? trace.setSpan(context.active(), navigation.span) : context.active() return tracer.startActiveSpan(name, {}, parent, async (span) => { try { return await fn() } catch (error) { - // redirect() and notFound() are thrown, but they aren't failures - if (!isRedirect(error) && !isNotFound(error)) { + if (isFailure(error)) { span.recordException(error instanceof Error ? error : String(error)) span.setStatus({ code: SpanStatusCode.ERROR }) if (typeof error === "object" && error !== null) recorded.add(error) @@ -179,133 +171,97 @@ export function traced(name: string, fn: () => Promise): Promise { }) } +/** Whether `traced` already recorded this error on a span. */ export const alreadyRecorded = (error: unknown) => typeof error === "object" && error !== null && recorded.has(error) + +/** The trace the server rendered this page under, sent in a `Server-Timing` header. */ +function serverContext() { + if (typeof performance === "undefined") return context.active() + const [page] = performance.getEntriesByType("navigation") as PerformanceNavigationTiming[] + const traceparent = page?.serverTiming?.find((entry) => entry.name === "traceparent")?.description + return traceparent ? propagation.extract(context.active(), { traceparent }) : context.active() +} ``` -```ts -// src/routes/projects.$projectId.tsx -import { createFileRoute } from "@tanstack/react-router" -import { traced } from "../tracing" +It does four things: -export const Route = createFileRoute("/projects/$projectId")({ - loader: ({ params }) => traced("loader /projects/$projectId", () => fetchProject(params.projectId)), -}) -``` +- `startNavigation` opens a `pageload` span for the first route and a `navigate` span for every one after it. If the user clicks again before the last navigation finished, the old span ends and is marked as interrupted. +- `endNavigation` renames the span after the route template and ends it. +- `traced` wraps data loading in a child span of the current navigation, and marks it as failed when it throws. +- `serverContext` joins the first page load to the server's trace, if the page was server-rendered. More on that in step 5. In a client-only app it does nothing. -Here's where the missing async context from earlier comes back. `startActiveSpan` makes the loader span active only until the first `await`. A `fetch()` called before that point nests under the loader. A `fetch()` called after it starts a new trace: +The rename in `endNavigation` matters more than it looks. A span name is what every view groups by, so `navigate /projects/:id` gives you one row with a real p95, while `navigate /projects/8f2a-4c11` gives you one row per project that never aggregates. The concrete path is still there as the `url.path` attribute when you need to find one specific navigation. + +### The `await` problem + +Here's where the missing async context from earlier comes back. `traced` makes its span active only until the first `await`. A `fetch()` called before that point nests under it. A `fetch()` called after it starts a new trace: ```ts -// Both requests nest under the loader span -loader: ({ params }) => - traced("loader /projects/$projectId", () => - Promise.all([fetchProject(params.projectId), fetchMembers(params.projectId)]), - ) +// Both requests nest under the span +traced("load project", () => Promise.all([fetchProject(id), fetchMembers(id)])) // The second request loses its parent -loader: ({ params }) => - traced("loader /projects/$projectId", async () => { - const project = await fetchProject(params.projectId) - const members = await fetchMembers(project.id) // new trace - return { project, members } - }) +traced("load project", async () => { + const project = await fetchProject(id) + const members = await fetchMembers(project.id) // new trace + return { project, members } +}) ``` -If a request really does depend on the previous one, capture the context up front with `const ctx = context.active()` and call it as `context.with(ctx, () => fetchMembers(project.id))`. But look at the loader first. Sequential awaits in a loader are a request waterfall, and that's often exactly the slowness you were trying to find. The trace at the top of this guide is one: three healthy API calls, made one after another. - -Two more things to know. Loaders also run when TanStack Router preloads a route on hover. There's no navigation in progress then, so those loader spans show up as their own traces, and that's accurate. And the same pattern works in React Router, Vue Router, or anything else that tells you when a navigation starts and ends; only the event names change. +If a request really does depend on the previous one, capture the context up front with `const ctx = context.active()` and call it as `context.with(ctx, () => fetchMembers(project.id))`. But look at the code first. Sequential awaits while loading a page are a request waterfall, and that's often exactly the slowness you were trying to find. The trace at the top of this guide is one: three healthy API calls, made one after another. ## Step 4: report the errors your framework catches -The SDK's global handlers see errors that nothing caught. TanStack Router wraps every route in an error boundary, so an exception thrown while rendering a route never gets that far. Report it from the router's `defaultOnCatch`: +The SDK's global handlers only see errors that nothing caught. Most frameworks catch rendering and data-loading errors themselves, so they can show an error page instead of a blank screen. Those errors never reach `window.onerror`. + +Every framework has one place where those errors end up: a React error boundary, Vue's `app.config.errorHandler`, Angular's `ErrorHandler`, SvelteKit's `handleError` hook. Report from there: ```ts -// src/router.tsx import { MapleBrowser } from "@maple-dev/browser" -import { createRouter } from "@tanstack/react-router" -import { routeTree } from "./routeTree.gen" -import { alreadyRecorded, traceNavigations } from "./tracing" - -export const router = createRouter({ - routeTree, - defaultOnCatch: (error) => { - // Loader errors are already on the loader span - if (!alreadyRecorded(error)) MapleBrowser.captureException(error, { name: "react.render_error" }) - }, -}) +import { alreadyRecorded } from "./tracing" -traceNavigations(router) +function reportCaughtError(error: unknown) { + // Errors thrown inside traced() are already on their span + if (!alreadyRecorded(error)) MapleBrowser.captureException(error) +} ``` -The `alreadyRecorded` check is there because a loader that throws ends up in the same error boundary. Without it, one failed loader would show up as two errors. +The `alreadyRecorded` check is there because a data loader that throws often ends up in the same error handler. Without it, one failed loader would show up as two errors. `captureException` itself also records each error object only once, so reporting and rethrowing is safe. -If you use React error boundaries of your own, do the same in their `componentDidCatch`. `captureException` records each error object once, so reporting and rethrowing is safe. +## Step 5: trace server-side rendering -## Step 5: trace server-side rendering (TanStack Start) +If your framework renders the first page on the server, that render is part of what the user waits for. Tracing it closes the last gap: a slow first load could be a slow data fetch on the server, a slow render, or slow JavaScript in the browser, and without server spans you can only see the last one. -If you use TanStack Start, the first page load is rendered on the server, and that render is part of what the user waits for. Tracing it closes the last gap: a slow first load could be a slow loader on the server, a slow render, or slow JavaScript in the browser, and without server spans you can only see the last one. +It also helps anyone debugging with an AI agent. An agent that can see "the server render of `/projects/:id` took 900ms, 700ms of it in one query" has something concrete to fix. An agent that only sees a slow page load has to guess. -It also helps anyone debugging with an AI agent. An agent that can see "the server render of `/projects/$projectId` took 900ms, 700ms of it in one loader" has something concrete to fix; an agent that only sees a slow page load has to guess. +Server rendering happens in Node (or another server runtime), so the server side is ordinary backend tracing: start the OpenTelemetry Node SDK as in the [Node.js guide](/docs/guides/instrumentation-nodejs), and add a span around the render. Node has `AsyncLocalStorage`, so the `await` problem from step 3 doesn't exist there. -Start with the OpenTelemetry Node SDK on the server, set up as in the [Node.js guide](/docs/guides/instrumentation-nodejs) and imported before anything else in your server entry. That gives you a span for every incoming request, plus spans for database and HTTP calls your loaders make. Then add a span around the render itself in `src/server.ts`: +The interesting part is connecting the server's trace to the browser's. The server writes its trace context into a `Server-Timing` response header: -```ts -// src/server.ts -import "./instrumentation" // starts the OpenTelemetry Node SDK; must load first -import { context, propagation, trace } from "@opentelemetry/api" -import { createStartHandler, defaultStreamHandler, defineHandlerCallback } from "@tanstack/react-start/server" -import { createServerEntry } from "@tanstack/react-start/server-entry" - -const tracer = trace.getTracer("acme-web") - -const handler = defineHandlerCallback((ctx) => { - // Loaders have already run by now, so the matched route is known - const leaf = ctx.router.state.matches.at(-1) - - return tracer.startActiveSpan(`ssr ${leaf?.fullPath ?? "unknown"}`, async (span) => { - // Hand this trace to the browser so its pageload span can join it - const carrier: Record = {} - propagation.inject(context.active(), carrier) - if (carrier.traceparent) { - ctx.responseHeaders.append("server-timing", `traceparent;desc="${carrier.traceparent}"`) - } - - try { - return await defaultStreamHandler(ctx) - } finally { - span.end() - } - }) -}) - -export default createServerEntry({ fetch: createStartHandler(handler) }) +```http +Server-Timing: traceparent;desc="00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01" ``` -The `traced()` helper from step 3 works on the server as well, and it works better there: Node has `AsyncLocalStorage`, so fetches after an `await` keep their parent. Your loader spans show up under the request span without any changes. +The browser can read it back from the Navigation Timing API, and `serverContext()` in the helper above uses it to parent the `pageload` span. The first page load then shows up as one trace, from the server receiving the request to the browser finishing the first route. -The `server-timing` header connects the two halves of a page load. The server writes its trace context into the response, and the browser reads it back from the Navigation Timing API to parent its `pageload` span: +You'll often see this done with a `` tag instead, which is what OpenTelemetry's document-load instrumentation reads. Both work. The header keeps it out of the rendered HTML, so the server and client versions of your `` can't disagree during hydration. -```ts -// src/tracing.ts -function serverContext() { - const [page] = performance.getEntriesByType("navigation") as PerformanceNavigationTiming[] - const traceparent = page?.serverTiming.find((entry) => entry.name === "traceparent")?.description - return traceparent ? propagation.extract(context.active(), { traceparent }) : context.active() -} +Two caveats: -// in onBeforeNavigate, parent only the first page load to the server -const span = tracer.startSpan( - kind, - { attributes: { "url.path": toLocation.pathname } }, - kind === "pageload" ? serverContext() : context.active(), -) -``` +- Only parent the `pageload` span this way. Later navigations are new work and belong in their own traces, and attaching minutes of browsing to one server request makes that trace meaningless. +- If a CDN caches your HTML, it caches the header too, so every visitor would join the same old trace. Skip the header on cached responses. -You'll often see this done with a `` tag instead, which is what OpenTelemetry's document-load instrumentation reads. Both work. The header keeps it out of the rendered HTML, so the server and client versions of your `` can't disagree during hydration. +## Framework guides -Two caveats. Only parent the `pageload` span this way; later navigations are new work and belong in their own traces, and attaching minutes of browsing to one server request makes that trace meaningless. And if a CDN caches your HTML, it caches the header too, so every visitor would join the same old trace. Skip the header on cached responses. +Steps 3 to 5 look different in every framework: where navigations start and end, what data loading looks like, where caught errors go, and how the server render is exposed. Each guide wires the helper above into one framework: -One more thing to know about the `ssr` span: `defaultStreamHandler` returns as soon as React starts streaming, so the span measures the time to the first byte of HTML, not the time to the last. That's usually the number you want. +- [TanStack Router and TanStack Start](/blog/frontend-tracing-tanstack) +- [React Router](/blog/frontend-tracing-react-router) +- [Next.js](/blog/frontend-tracing-nextjs) +- [Vue and Vue Router](/blog/frontend-tracing-vue) +- [SvelteKit](/blog/frontend-tracing-sveltekit) +- [Angular](/blog/frontend-tracing-angular) ## What this setup doesn't cover @@ -331,9 +287,9 @@ Yes. OpenTelemetry's JavaScript SDK has a web tracer and instrumentations for `f Almost always one of three things: the API's origin isn't in `propagateTraceHeaderCorsUrls`, the API's CORS preflight doesn't allow the `traceparent` header, or the backend isn't instrumented to read it. Check the request headers in your browser's network tab first. If there's no `traceparent`, it's the frontend config; if it's there, it's the backend. -### How do I trace React Router or TanStack Router navigations? +### How do I trace route changes in a single-page app? -Subscribe to the router's navigation events. Start a span when a navigation begins, rename it to the matched route template, and end it when the router has resolved. Wrap loaders in child spans so the requests they make nest under the navigation. +Hook into your router's navigation events. Start a span when a navigation begins, rename it to the matched route template, and end it when the new route is ready. Wrap data loading in child spans so the requests it makes nest under the navigation. The [framework guides](#framework-guides) show where those hooks are in each router. ## Next steps diff --git a/apps/landing/src/content/blog/frontend-tracing-tanstack.mdx b/apps/landing/src/content/blog/frontend-tracing-tanstack.mdx new file mode 100644 index 0000000000..085594f875 --- /dev/null +++ b/apps/landing/src/content/blog/frontend-tracing-tanstack.mdx @@ -0,0 +1,184 @@ +--- +title: "Tracing TanStack Router and TanStack Start with OpenTelemetry" +description: "Trace every TanStack Router navigation, loader, and server render as one OpenTelemetry trace, linked to your backend and to session replay." +date: 2026-09-28 +author: "Jeremy Funk" +category: "guides" +draft: true +--- + +TanStack Router tells you a lot about a navigation before it renders anything: which route matched, which loaders ran, and whether one of them redirected. That makes it one of the easier routers to trace well. By the end of this guide, a click on a link produces one trace with a span for the navigation, a span per loader, the fetches those loaders made, and the backend spans behind them. With TanStack Start, the first page load also includes the server render. + +This is part of the [frontend tracing guide](/blog/frontend-tracing-opentelemetry). It assumes you've done steps 1 and 2 there (the browser SDK is installed and `traceparent` reaches your API), and that you've added the `src/tracing.ts` helper from step 3. Everything below plugs the router into that helper. + +## Trace TanStack Router navigations + +The router emits lifecycle events you can subscribe to with `router.subscribe`. Two of them are enough: + +- `onBeforeNavigate` fires when a navigation starts, before any loader runs. +- `onResolved` fires once every loader has finished and the new route is committed. + +```ts +// src/router-tracing.ts +import { type AnyRouter, isNotFound, isRedirect } from "@tanstack/react-router" +import { endNavigation, startNavigation, traced } from "./tracing" + +export function traceRouter(router: AnyRouter) { + // The router also emits these events while rendering on the server + if (typeof window === "undefined") return + + router.subscribe("onBeforeNavigate", ({ fromLocation, toLocation, hrefChanged }) => { + // router.invalidate() reruns loaders without going anywhere + if (fromLocation && !hrefChanged) return + startNavigation(toLocation.pathname) + }) + + router.subscribe("onResolved", () => { + endNavigation(router.state.matches.at(-1)?.fullPath) + }) +} +``` + +Call it once, right after you create the router: + +```ts +// src/router.tsx +import { createRouter } from "@tanstack/react-router" +import { routeTree } from "./routeTree.gen" +import { traceRouter } from "./router-tracing" + +export const router = createRouter({ routeTree }) + +traceRouter(router) +``` + +A few details that are easy to get wrong: + +- **Name spans with `fullPath`, not `routeId`.** Both are templates, but `routeId` includes pathless layouts and route groups (`/_authed/(app)/projects/$projectId`). `fullPath` is the URL the user sees, with the dynamic parts left as `$projectId`. +- **Interrupted navigations never resolve.** If the user clicks a second link before the first finishes loading, TanStack Router abandons the first one and never emits `onResolved` for it. `startNavigation` handles this by ending the previous span when a new one starts. +- **Redirects stay in one span.** A loader that throws `redirect()` doesn't start a new navigation event, so the span covers both routes and gets named after the one the user lands on. + +## Trace route loaders + +Loaders are where most of the time in a TanStack Router navigation goes, so each one gets its own span. `traced` from the helper does the work. The one TanStack-specific part is that `redirect()` and `notFound()` are thrown, and they aren't failures: + +```ts +// src/router-tracing.ts, continued +export const loaderSpan = (name: string, fn: () => Promise) => + traced(name, fn, (error) => !isRedirect(error) && !isNotFound(error)) +``` + +```ts +// src/routes/projects.$projectId.tsx +import { createFileRoute } from "@tanstack/react-router" +import { loaderSpan } from "../router-tracing" + +export const Route = createFileRoute("/projects/$projectId")({ + loader: ({ params }) => + loaderSpan("loader /projects/$projectId", () => + Promise.all([fetchProject(params.projectId), fetchMembers(params.projectId)]), + ), +}) +``` + +TanStack Router runs the loaders of nested routes in parallel, so a layout loader and a page loader show up as sibling spans under the navigation. If they overlap in the waterfall, that's working as intended. If the page loader starts only after the layout loader ends, something is making it wait. + +Watch out for sequential `await`s inside a loader: only requests started before the first `await` nest under the loader span. The [main guide explains why](/blog/frontend-tracing-opentelemetry#the-await-problem), and how to pass the context along when a request really does depend on the previous one. + +Loaders also run when TanStack Router preloads a route, for example when the user hovers a link with `preload="intent"`. There's no navigation in progress then, so those loader spans become their own traces. That's accurate: the work happened before the click. If the user then clicks, the navigation span is often very short, because the data is already cached. + +## Report errors caught by TanStack Router + +TanStack Router wraps every route in an error boundary. That's why a failing loader or a component that throws shows your `errorComponent` instead of a blank page, and it's also why those errors never reach the browser SDK's global handlers. + +Every error caught by those boundaries goes through the router's `defaultOnCatch` option (or a route's own `onCatch`). Report from there: + +```ts +// src/router.tsx +import { MapleBrowser } from "@maple-dev/browser" +import { createRouter } from "@tanstack/react-router" +import { routeTree } from "./routeTree.gen" +import { traceRouter } from "./router-tracing" +import { alreadyRecorded } from "./tracing" + +export const router = createRouter({ + routeTree, + defaultOnCatch: (error) => { + // Loader errors are already on their loader span + if (!alreadyRecorded(error)) MapleBrowser.captureException(error, { name: "react.render_error" }) + }, +}) + +traceRouter(router) +``` + +A loader that throws ends up in the same boundary, which is what the `alreadyRecorded` check is for: the error is recorded once, on the loader span, where it has the navigation around it. + +## Trace server rendering in TanStack Start + +TanStack Start renders the first page on the server, which means the first page load has a server half you can trace too. Start the OpenTelemetry Node SDK as in the [Node.js guide](/docs/guides/instrumentation-nodejs) and import it before anything else in your server entry. That gives you a span for every incoming request and for the database and HTTP calls your loaders make on the server. + +Then add a span around the render, and hand its trace context to the browser in a `Server-Timing` header: + +```ts +// src/server.ts +import "./instrumentation" // starts the OpenTelemetry Node SDK; must load first +import { context, propagation, trace } from "@opentelemetry/api" +import { createStartHandler, defaultStreamHandler, defineHandlerCallback } from "@tanstack/react-start/server" +import { createServerEntry } from "@tanstack/react-start/server-entry" + +const tracer = trace.getTracer("acme-web") + +const handler = defineHandlerCallback((ctx) => { + // Loaders have already run by now, so the matched route is known + const leaf = ctx.router.state.matches.at(-1) + + return tracer.startActiveSpan(`ssr ${leaf?.fullPath ?? "unknown"}`, async (span) => { + // Hand this trace to the browser so its pageload span can join it + const carrier: Record = {} + propagation.inject(context.active(), carrier) + if (carrier.traceparent) { + ctx.responseHeaders.append("server-timing", `traceparent;desc="${carrier.traceparent}"`) + } + + try { + return await defaultStreamHandler(ctx) + } finally { + span.end() + } + }) +}) + +export default createServerEntry({ fetch: createStartHandler(handler) }) +``` + +On the browser side there's nothing to add. `startNavigation` reads the header through `serverContext()` and parents the `pageload` span to the server render, so the first page load becomes one trace from the incoming request to the first route resolving in the browser. + +`loaderSpan` works on the server too, and better than in the browser: Node has `AsyncLocalStorage`, so fetches after an `await` keep their parent. Server-side loader spans show up under the request span without any changes. + +Two things to know about the `ssr` span: + +- It starts after the loaders have run, because the handler callback only runs once the router has loaded. The loaders are its siblings under the request span, not its children. +- `defaultStreamHandler` returns as soon as React starts streaming, so the span measures the time to the first byte of HTML, not the time to the last. + +If your pages are cached by a CDN, skip the `server-timing` header on those responses, or every visitor's page load will join the same old trace. + +## FAQ + +### Does TanStack Router have built-in OpenTelemetry support? + +No, not today. The TanStack Start docs have an observability page with examples, and first-class OpenTelemetry support is on their roadmap. The router events used here are part of the stable public API, so this setup doesn't depend on internals. + +### Why are my TanStack Router loader spans not connected to the navigation? + +Either the loader ran as a preload (there's no navigation to attach to yet), or the fetch happened after an `await` inside the loader. Start the requests before the first `await`, for example with `Promise.all`, or pass the context along explicitly. + +### Can I use this with TanStack Router without TanStack Start? + +Yes. Everything up to the server rendering section works in a client-only TanStack Router app. Without a server render, `serverContext()` finds no header and the `pageload` span starts its own trace. + +## Next steps + +- [Frontend tracing with OpenTelemetry](/blog/frontend-tracing-opentelemetry): the helper, the problems, and the setup this guide builds on. +- [Browser SDK reference](/docs/session-replay/browser-sdk): consent, masking, and URL redaction. +- [Session replays](/docs/session-replay/replays): jump from a trace to the recording of the session that produced it. From e4809c1c3a9868aad7cd86eeb2498882a74029a2 Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Mon, 28 Sep 2026 17:14:13 +0200 Subject: [PATCH 04/28] feat(skills): maple-frontend-tracing skill, agent quick-setup prompts, copy buttons on blog code blocks --- .../components/blog/ArticleEnhancements.astro | 29 +++- .../blog/frontend-tracing-opentelemetry.mdx | 14 ++ .../blog/frontend-tracing-tanstack.mdx | 14 ++ .../content/docs/getting-started/ai-agents.md | 3 +- apps/landing/src/lib/telemetry.ts | 1 + skills/maple-frontend-tracing/SKILL.md | 141 ++++++++++++++++++ .../frameworks/other.md | 42 ++++++ .../frameworks/tanstack.md | 120 +++++++++++++++ skills/maple-frontend-tracing/tracing.ts | 66 ++++++++ skills/maple-onboard/SKILL.md | 1 + 10 files changed, 429 insertions(+), 2 deletions(-) create mode 100644 skills/maple-frontend-tracing/SKILL.md create mode 100644 skills/maple-frontend-tracing/frameworks/other.md create mode 100644 skills/maple-frontend-tracing/frameworks/tanstack.md create mode 100644 skills/maple-frontend-tracing/tracing.ts diff --git a/apps/landing/src/components/blog/ArticleEnhancements.astro b/apps/landing/src/components/blog/ArticleEnhancements.astro index 6739924af7..ceeb8c7dd5 100644 --- a/apps/landing/src/components/blog/ArticleEnhancements.astro +++ b/apps/landing/src/components/blog/ArticleEnhancements.astro @@ -1,7 +1,8 @@ --- /** * Long-form article behavior shared by blog posts and customer stories: - * heading permalinks, the reading-progress bar and the copy-link button. + * heading permalinks, the reading-progress bar, the copy-link button and copy + * buttons on code blocks. * Expects the page's `[data-read-progress]`, `[data-copy-link]` and * `.docs-content` markup. The outline's scroll spy lives in TableOfContents. */ @@ -53,3 +54,29 @@ }); })(); + + diff --git a/apps/landing/src/content/blog/frontend-tracing-opentelemetry.mdx b/apps/landing/src/content/blog/frontend-tracing-opentelemetry.mdx index a1af966aef..a108595964 100644 --- a/apps/landing/src/content/blog/frontend-tracing-opentelemetry.mdx +++ b/apps/landing/src/content/blog/frontend-tracing-opentelemetry.mdx @@ -17,6 +17,20 @@ Everything before that is invisible from the backend: the route change, the load This guide covers tracing that part. We'll set up browser tracing, connect it to your backend traces, trace route navigations and data loading, and catch the errors your framework swallows. The setup here works with any framework, and there's a [follow-up guide for each popular one](#framework-guides). The examples use Maple's browser SDK, but everything underneath is OpenTelemetry, and the problems in the first half apply whichever backend you send spans to. +## Quick setup with a coding agent + +If you'd rather have your coding agent do this, copy the prompt below into Claude Code, Codex, Cursor, or any agent that can run shell commands. It installs the [maple-frontend-tracing](https://github.com/MapleTechLabs/maple/tree/main/skills/maple-frontend-tracing) skill, which contains every step of this guide plus a reference for each framework, and the agent picks it up from there. + +```text +Set up Maple frontend tracing in this project. + +Install the skill with `npx skills add MapleTechLabs/maple/skills --skill maple-frontend-tracing -y`, then follow it. + +My Maple public ingest key is maple_pk_... and my organization is in the US region. +``` + +Replace the key with your public key from **Settings → Ingestion**, or leave it out: the agent then uses a placeholder you can swap later. EU organizations should say EU region. + ## What frontend observability covers Frontend observability is the same idea as backend observability, pointed at the code running in your users' browsers. In practice it's four signals: diff --git a/apps/landing/src/content/blog/frontend-tracing-tanstack.mdx b/apps/landing/src/content/blog/frontend-tracing-tanstack.mdx index 085594f875..3666efb9de 100644 --- a/apps/landing/src/content/blog/frontend-tracing-tanstack.mdx +++ b/apps/landing/src/content/blog/frontend-tracing-tanstack.mdx @@ -11,6 +11,20 @@ TanStack Router tells you a lot about a navigation before it renders anything: w This is part of the [frontend tracing guide](/blog/frontend-tracing-opentelemetry). It assumes you've done steps 1 and 2 there (the browser SDK is installed and `traceparent` reaches your API), and that you've added the `src/tracing.ts` helper from step 3. Everything below plugs the router into that helper. +## Quick setup with a coding agent + +If you'd rather have your coding agent do this, copy the prompt below into Claude Code, Codex, Cursor, or any agent that can run shell commands. It installs the [maple-frontend-tracing](https://github.com/MapleTechLabs/maple/tree/main/skills/maple-frontend-tracing) skill, which contains every step of this guide and the general setup it builds on, and the agent picks it up from there. + +```text +Set up Maple frontend tracing in this project. + +Install the skill with `npx skills add MapleTechLabs/maple/skills --skill maple-frontend-tracing -y`, then follow it. This app uses TanStack Router (and TanStack Start, if it renders on the server). + +My Maple public ingest key is maple_pk_... and my organization is in the US region. +``` + +Replace the key with your public key from **Settings → Ingestion**, or leave it out: the agent then uses a placeholder you can swap later. EU organizations should say EU region. + ## Trace TanStack Router navigations The router emits lifecycle events you can subscribe to with `router.subscribe`. Two of them are enough: diff --git a/apps/landing/src/content/docs/getting-started/ai-agents.md b/apps/landing/src/content/docs/getting-started/ai-agents.md index 142bbfb52d..a42ab2bf7c 100644 --- a/apps/landing/src/content/docs/getting-started/ai-agents.md +++ b/apps/landing/src/content/docs/getting-started/ai-agents.md @@ -113,10 +113,11 @@ When no tool fits, `describe_warehouse_tables` lists the tables and columns, and ## Instrument with a coding agent -Two open-source skills teach a coding agent how to set up OpenTelemetry for Maple: +Open-source skills teach a coding agent how to set up OpenTelemetry for Maple: - [maple-onboard](https://github.com/MapleTechLabs/maple/tree/main/skills/maple-onboard) instruments every app and service in a repository: traces, logs and metrics, using the native OpenTelemetry SDK for each language. - [maple-audit](https://github.com/MapleTechLabs/maple/tree/main/skills/maple-audit) reviews an existing setup, reports gaps per service (missing service map edges, missing `service.version`, errors without exceptions) and fixes them. +- [maple-frontend-tracing](https://github.com/MapleTechLabs/maple/tree/main/skills/maple-frontend-tracing) traces a web frontend end to end: route navigations, data loading, caught errors, and the link to your backend and server render. It has a reference for each major framework. See the [frontend tracing guide](/blog/frontend-tracing-opentelemetry). Install them together with the per-language guides they read: diff --git a/apps/landing/src/lib/telemetry.ts b/apps/landing/src/lib/telemetry.ts index 5d6320ffb1..589c469d04 100644 --- a/apps/landing/src/lib/telemetry.ts +++ b/apps/landing/src/lib/telemetry.ts @@ -43,6 +43,7 @@ const LANDING_EVENTS = [ "install_command_copied", "docs_search", "docs_snippet_copied", + "blog_snippet_copied", "changelog_link_copied", "brand_asset_copied", "brand_asset_downloaded", diff --git a/skills/maple-frontend-tracing/SKILL.md b/skills/maple-frontend-tracing/SKILL.md new file mode 100644 index 0000000000..a1a33a24b5 --- /dev/null +++ b/skills/maple-frontend-tracing/SKILL.md @@ -0,0 +1,141 @@ +--- +name: maple-frontend-tracing +description: "Trace a web frontend with Maple: install @maple-dev/browser, link browser and backend traces, add route navigation and data-loading spans, report errors the framework catches, and join the first page load to the server render. Per-framework references for TanStack Router/Start, React Router, Next.js, Vue/Nuxt, SvelteKit and Angular. Triggers on 'trace my frontend', 'add frontend observability', 'instrument the browser with Maple', 'trace route changes', 'connect frontend and backend traces', 'add session replay'." +--- + +# Maple frontend tracing + +The goal: **one click is one trace**. A navigation span, the data-loading spans under it, the `fetch` spans they make, and the backend spans behind those. The first page load joins the server render's trace. Errors the framework catches are reported. Session replay links to the traces through a shared `session.id`. + +The human-readable version of this skill is the guide at https://maple.dev/blog/frontend-tracing-opentelemetry. Read it if you need the reasoning behind a step. + +## Step 0: Detect the framework and read its reference + +Read the `package.json` of each web frontend in the repo, then read **only** the matching reference in `frameworks/`: + +| Dependency | Reference | +| --- | --- | +| `@tanstack/react-router`, `@tanstack/react-start` | `frameworks/tanstack.md` | +| `react-router` (v7), `react-router-dom` | `frameworks/react-router.md` | +| `next` | `frameworks/nextjs.md` | +| `vue-router`, `nuxt` | `frameworks/vue.md` | +| `@sveltejs/kit` | `frameworks/sveltekit.md` | +| `@angular/router` | `frameworks/angular.md` | +| anything else (Solid, Qwik, Astro islands, plain SPA, hand-rolled router) | `frameworks/other.md` | + +The steps below are the same for every framework. The reference tells you where each one goes. If the references are not next to this file, read them from https://github.com/MapleTechLabs/maple/tree/main/skills/maple-frontend-tracing. + +Backends are out of scope for this skill. If the API the frontend calls isn't instrumented with OpenTelemetry yet, use the `maple-onboard` skill for it first, or say in the hand-off that browser traces won't connect to it until it is. + +## Step 1: Key and region + +Maple has two regions. A key only works in the region that issued it. + +- US (default): SDK option `region: "us"`, dashboard `https://app.maple.dev`. +- EU: `region: "eu"`, dashboard `https://app.eu.maple.dev`. Use it when the prompt mentions the EU or an `eu.maple.dev` URL. + +Browser code takes the **public** ingest key (`maple_pk_…`) only. It is write-only and safe to ship, like a Sentry DSN. Inline it in the init call. Never put a private key (`maple_sk_…`) in browser code: if the prompt only has a private key, use `MAPLE_TEST` in the browser and tell the user to swap in the public key. With no key at all, inline the sentinel `MAPLE_TEST` (ingest accepts it and stores nothing) and keep going. + +## Step 2: Install and initialize the browser SDK + +Install `@maple-dev/browser` with the project's package manager. Initialize it once, before the app renders, at the place the framework reference names: + +```ts +import { MapleBrowser } from "@maple-dev/browser" + +MapleBrowser.init({ + ingestKey: "MAPLE_TEST", // public key, maple_pk_… + serviceName: "acme-web", + region: "us", // "eu" for EU organizations + serviceVersion: "", + environment: import.meta.env.MODE, + tracing: { + // First-party APIs on another origin. Same-origin requests are covered already. + propagateTraceHeaderCorsUrls: [/^https:\/\/api\.acme\.com\//], + }, +}) +``` + +- `serviceName`: distinct from the backend's, usually `-web`. +- `init()` is a no-op on the server, so importing it from code that also runs during SSR is safe. +- The SDK instruments **`fetch` only**, not `XMLHttpRequest`. Find the app's HTTP client. `ky`, `ofetch`, `redaxios` and plain `fetch` are covered. axios uses XHR in browsers unless created with `adapter: "fetch"`; Angular's `HttpClient` uses XHR unless `provideHttpClient(withFetch())`. Switch those clients to fetch where it's a one-line change; otherwise register `XMLHttpRequestInstrumentation` from `@opentelemetry/instrumentation-xml-http-request` with the same `propagateTraceHeaderCorsUrls`. +- If another tracer already instruments `fetch` (for example `@maple-dev/effect-sdk/client`), set `tracing.instrumentFetch: false`. +- Keep existing error and RUM vendors (Sentry, Datadog, LogRocket…). If another tool on the page also reports global errors to Maple, set `tracing.captureErrors: false` so errors aren't counted twice. + +## Step 3: Link browser and backend traces + +1. Find every first-party API base URL the frontend calls (API client config, env vars like `VITE_API_URL`). Same-origin requests already carry `traceparent`. List each cross-origin first-party API in `propagateTraceHeaderCorsUrls` as an anchored regex. +2. **Never list third-party origins** (analytics, payment providers, CDNs). It leaks trace ids and often breaks their CORS preflight. +3. Check the API's CORS config. Many setups already echo the requested headers (the `cors` npm package default). If there's an explicit allow-list, add `traceparent` and `tracestate` and keep the existing entries. Without this, the browser blocks the request after the preflight. + +## Step 4: Navigation and data-loading spans + +Copy `tracing.ts` from this skill's directory **verbatim** into the app (for example `src/lib/tracing.ts`). It is the one helper this setup needs, because the current navigation has to be shared between router callbacks and loaders. Don't add further wrappers. + +Its API: + +- `startNavigation(path)`: call when the router starts a navigation. The first call opens a `pageload` span (parented to the server render, see Step 6), later calls open `navigate` spans. A navigation that starts before the previous one ended ends the previous one as interrupted. +- `endNavigation(routeTemplate?)`: call when the new route is ready. Renames the span to `