diff --git a/apps/landing/src/components/docs/DocsCategoryIcon.astro b/apps/landing/src/components/docs/DocsCategoryIcon.astro index f9a509ba2b..8edbaa3284 100644 --- a/apps/landing/src/components/docs/DocsCategoryIcon.astro +++ b/apps/landing/src/components/docs/DocsCategoryIcon.astro @@ -75,6 +75,17 @@ const base = { )} +{name === "Frontend" && ( + + + + + + + + +)} + {name === "Session Replay" && ( diff --git a/apps/landing/src/components/docs/DocsSidebar.astro b/apps/landing/src/components/docs/DocsSidebar.astro index ae51b5b460..8d28c97b71 100644 --- a/apps/landing/src/components/docs/DocsSidebar.astro +++ b/apps/landing/src/components/docs/DocsSidebar.astro @@ -5,6 +5,7 @@ // Language rows carry their logo; Effect's platform pages nest under Effect. import { getCollection } from "astro:content"; import DocsCategoryIcon from "./DocsCategoryIcon.astro"; +import BrandMarkIcon from "../BrandMarkIcon.astro"; import LanguageLogo from "./LanguageLogo.astro"; import { groupRank, isDocGroup, sectionForGroup } from "../../lib/docs-nav"; import { getDocSections } from "../../lib/docs-order"; @@ -73,8 +74,10 @@ const rowClass = (active: boolean) => aria-current={active ? "page" : undefined} class:list={["flex items-center gap-2 px-2.5 py-1.5 text-[13px] leading-5 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-primary", rowClass(active)]} > - {doc.data.sdk && ( + {doc.data.sdk ? ( + ) : ( + doc.data.icon && )} {label(doc)} diff --git a/apps/landing/src/components/docs/FrontendTrace.astro b/apps/landing/src/components/docs/FrontendTrace.astro new file mode 100644 index 0000000000..104608c0fe --- /dev/null +++ b/apps/landing/src/components/docs/FrontendTrace.astro @@ -0,0 +1,229 @@ +--- +/** + * FrontendTrace — one client-side navigation traced end to end: the router + * span, the loader, the fetches 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 = 840; +const INDENT_PX = 14; + +const rows: SpanRow[] = [ + { 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))); +function serviceColor(name: string): string { + const idx = (orderedServices.indexOf(name) % 16) + 1; + return `var(--service-${idx})`; +} +--- + +
+
+
+ One click on a project link · 840ms · browser to API +
+
+
+
+ {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 +
+ ); + })} +
+
+

+ 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 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/components/docs/GuideGrid.astro b/apps/landing/src/components/docs/GuideGrid.astro index 393057ec48..32feee9721 100644 --- a/apps/landing/src/components/docs/GuideGrid.astro +++ b/apps/landing/src/components/docs/GuideGrid.astro @@ -1,17 +1,19 @@ --- -// One ecosystem's cards on /docs/instrumentation. Styled with scoped CSS on +// One ecosystem's cards on /docs/instrumentation (or a given card list, as on /docs/frontend). Styled with scoped CSS on // purpose: the page sits inside `.docs-content`, whose descendant rules for // `a`/`p` beat plain Tailwind utilities (see `local/InstallTabs.astro`). -import { GUIDE_SECTIONS } from "../../lib/instrumentation-guides"; +import { type GuideCard, GUIDE_SECTIONS } from "../../lib/instrumentation-guides"; import BrandMarkIcon from "../BrandMarkIcon.astro"; import LanguageLogo from "./LanguageLogo.astro"; interface Props { - section: string; + /** A section of GUIDE_SECTIONS, or `cards` for a list shown elsewhere. */ + section?: string; + cards?: readonly GuideCard[]; } -const { section } = Astro.props; -const data = GUIDE_SECTIONS.find((s) => s.id === section); +const { section, cards } = Astro.props; +const data = cards ? { cards } : GUIDE_SECTIONS.find((s) => s.id === section); if (!data) throw new Error(`Unknown guide section: ${section}`); --- diff --git a/apps/landing/src/content.config.ts b/apps/landing/src/content.config.ts index 961b5ef88b..cba585c4bd 100644 --- a/apps/landing/src/content.config.ts +++ b/apps/landing/src/content.config.ts @@ -1,5 +1,6 @@ import { defineCollection, reference, z } from "astro:content" import { glob } from "astro/loaders" +import { BRAND_MARKS, type BrandMarkId } from "./lib/brand-marks" import { LANGUAGE_IDS } from "./lib/docs-languages" import { CHANGELOG_CATEGORIES, CONTRIBUTOR_IDS } from "./lib/changelog-meta" @@ -28,6 +29,9 @@ const docs = defineCollection({ // ("Node.js" for "Node.js Instrumentation"). navLabel: z.string().optional(), sdk: z.enum(LANGUAGE_IDS).optional(), + // Brand mark on the sidebar row, for guides that aren't a language (`sdk`), + // like the frontend framework guides. + icon: z.enum(Object.keys(BRAND_MARKS) as [BrandMarkId, ...BrandMarkId[]]).optional(), }), }) diff --git a/apps/landing/src/content/docs/frontend.mdx b/apps/landing/src/content/docs/frontend.mdx new file mode 100644 index 0000000000..8658712c31 --- /dev/null +++ b/apps/landing/src/content/docs/frontend.mdx @@ -0,0 +1,63 @@ +--- +title: "Frontend tracing" +description: "Trace what happens in the browser before a request reaches your backend: route navigations, data loading, caught errors and the first page load, in the same OpenTelemetry trace as your API." +group: "Frontend" +order: 0 +navLabel: "Overview" +--- + +import FrontendTrace from "../../components/docs/FrontendTrace.astro" +import GuideGrid from "../../components/docs/GuideGrid.astro" +import { FRONTEND_GUIDES } from "../../lib/instrumentation-guides" + +Backend traces start when a request reaches your server. Everything before that, the route change, the loader that fires three requests one after another, the network and the render, is invisible from the backend, and it's often where a slow page spends its time. + +Frontend tracing covers that part. Each click becomes one trace: a span for the navigation, spans for the data it loads, the `fetch` calls those make, and your backend's spans behind them. Here's one navigation where every API call is fast and the page is still slow: + + + +## Choose your framework + +Each guide is self-contained: it installs the [browser SDK](/docs/session-replay/browser-sdk), connects browser traces to your backend, and wires navigation, data-loading and error reporting into that framework's own hooks. For Next.js, TanStack Router and Start, React Router, Vue and Nuxt, SvelteKit, Angular and Astro, the SDK ships an integration for the framework, like `@maple-dev/browser/nextjs` or `@maple-dev/browser/sveltekit`, so the setup is a few imports. Other frameworks call the SDK's navigation methods from their router's hooks. + + + +## Quick setup with a coding agent + +Copy this prompt into Claude Code, Codex, Cursor or another 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 detects your framework and follows the matching guide. + +```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. +``` + +Use your public key from **Settings → Ingestion**. Without one, the agent uses a placeholder you can replace later. EU organizations should say EU region. + +## What you get + +- **Navigation traces.** A `pageload` span for the first route and a `navigate` span for each one after it, named after the route template, like `navigate /projects/:id`. The framework integrations find the template for you. +- **Data-loading spans.** Loaders, `load` functions or resolvers as children of the navigation, with the requests they make under them. +- **Backend spans in the same trace.** Browser requests carry a `traceparent` header, so your API's spans join the click that caused them. +- **Caught errors.** Errors your framework's error boundaries catch are reported, not only the ones that reach `window.onerror`, and grouped on the [Errors](/docs/errors/overview) page. +- **The first page load, end to end.** With server rendering, the browser's `pageload` span joins the server render's trace. +- **Session replay.** Every span carries the session id, so a trace links to the [recording](/docs/session-replay/replays) of what the user saw. + +## Why browser tracing is different + +If you've instrumented a backend service, most of this will look familiar. A few things are different, and each guide handles them: + +- **No async context.** On the server, Node's `AsyncLocalStorage` carries the active span across `await`. Browsers have nothing equivalent yet, so a `fetch()` after an `await` loses its parent and starts a new trace unless you pass the context along. +- **Cross-origin requests don't carry trace context by default.** The browser only adds `traceparent` to requests you opt in, and your API's CORS preflight has to allow the header. +- **Tabs close without warning.** Spans are exported in batches, so the SDK flushes when the tab is hidden or closed. Mobile browsers often never fire `unload`. +- **Single-page apps have one page load.** Every route change after the first is JavaScript, and nothing traces it unless you hook into the router. +- **Frameworks catch errors before you see them.** Error boundaries show a fallback instead of a blank page, which means those errors never reach a global error handler. +- **The data is noisier and more sensitive.** Browser extensions throw errors into your page, ad blockers block telemetry requests, URLs carry tokens, and stack traces are minified. + +## Related + +- [Browser SDK reference](/docs/session-replay/browser-sdk): every option, including consent, masking and URL redaction. +- [Instrument your application](/docs/instrumentation): backend guides, so browser traces continue into your services. +- [Session replays](/docs/session-replay/replays): open the recording behind a trace. diff --git a/apps/landing/src/content/docs/frontend/angular.md b/apps/landing/src/content/docs/frontend/angular.md new file mode 100644 index 0000000000..c63df1b656 --- /dev/null +++ b/apps/landing/src/content/docs/frontend/angular.md @@ -0,0 +1,373 @@ +--- +title: "Frontend tracing for Angular" +description: "Trace every Angular Router navigation, resolver and HttpClient request as one OpenTelemetry trace, linked to your backend, your server render and session replay." +group: "Frontend" +order: 6 +navLabel: "Angular" +icon: "angular" +--- + +Angular's router reports every step of a navigation on one observable, `Router.events`, so you can see when a navigation starts, redirects, fails or finishes without patching anything. This guide turns a click on a `routerLink` into one trace with a span for the navigation, a span per resolver, the `HttpClient` requests those resolvers made, and the backend spans behind them. With `@angular/ssr`, the first page load also includes the server render. The integration needs Angular 19 or later, and the code was checked against Angular 22 with standalone APIs. + +## Quick setup with a coding agent + +Copy this prompt into Claude Code, Codex, Cursor or another 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. + +```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 Angular. + +My Maple public ingest key is maple_pk_... and my organization is in the US region. +``` + +Use your public key from **Settings → Ingestion**. Without one, the agent uses a placeholder you can replace later. EU organizations should say EU region. + +## Install the browser SDK + +```bash +npm install @maple-dev/browser +``` + +This guide needs `@maple-dev/browser` 0.10.0 or later. + +Initialize the SDK in its own file and import it first in `src/main.ts`, so it's running before Angular creates anything: + +```ts +// src/maple.ts +import { isDevMode } from "@angular/core" +import { MapleBrowser } from "@maple-dev/browser" + +MapleBrowser.init({ + ingestKey: "maple_pk_...", // public key, safe to ship in the bundle + serviceName: "acme-web", + environment: isDevMode() ? "development" : "production", +}) +``` + +```ts +// src/main.ts +import "./maple" // first, so the SDK is running before Angular bootstraps +import { bootstrapApplication } from "@angular/platform-browser" +import { App } from "./app/app" +import { appConfig } from "./app/app.config" + +bootstrapApplication(App, appConfig).catch((err) => console.error(err)) +``` + +The Angular CLI has no `import.meta.env`, so this file doesn't use it. The key is public, so a literal is fine, or use the CLI's `define` option. With `@angular/ssr`, `main.ts` is the browser entry only, so this never runs on the server. + +`init()` sets up: + +- a span for every `fetch()` call; +- error spans for uncaught errors and unhandled promise rejections, which show up on the [Errors](/docs/errors/overview) page; +- session replay, with the same `session.id` on every span and replay event, so a trace links to the recording of the session that produced it; +- export every 2 seconds, plus a flush when the tab is hidden or closed, so the spans from the last moments of a visit aren't lost; +- redaction of credential-looking query parameters (`token`, `code`, `password` and similar) in every URL it sends. + +Use the public ingest key (`maple_pk_…`) from **Settings → Ingestion**. It can only write telemetry, so it's safe in browser code. For an EU organization, add `region: "eu"`. Every option is in the [Browser SDK reference](/docs/session-replay/browser-sdk). + +### Make sure HttpClient uses fetch + +This decides whether you see any network spans at all. The browser SDK instruments `fetch`, not `XMLHttpRequest`, and `HttpClient` can use either. + +Since Angular 22, `provideHttpClient()` uses `fetch` by default. On Angular 21 and older, the default is `XMLHttpRequest`, so every `HttpClient` request is invisible to the SDK: no span, and no `traceparent` header for your backend. Switch it to `fetch`: + +```ts +// src/app/app.config.ts (Angular 21 and older) +import { provideHttpClient, withFetch } from "@angular/common/http" +import type { ApplicationConfig } from "@angular/core" + +export const appConfig: ApplicationConfig = { + providers: [ + // Angular 21 and older default to XMLHttpRequest, which the SDK doesn't trace + provideHttpClient(withFetch()), + ], +} +``` + +On Angular 22, the same goes for `withXhr()`: if you added it for upload progress events, those requests aren't traced. + +## Connect browser traces to your backend + +Each `fetch()` span sends a W3C `traceparent` header, and your backend's span joins the same trace. For requests to the page's own origin this happens automatically. For an API on another origin, list it: + +```ts +MapleBrowser.init({ + // ... + tracing: { + propagateTraceHeaderCorsUrls: [/^https:\/\/api\.acme\.com\//], + }, +}) +``` + +Then allow the header in the API's CORS configuration. Without it, the browser blocks the request after the preflight: + +```http +Access-Control-Allow-Headers: content-type, authorization, traceparent, tracestate +``` + +Only list your own APIs. Sending `traceparent` to third parties leaks your trace ids, and many of them reject the preflight. + +Your backend needs OpenTelemetry to read the header; every OpenTelemetry HTTP server instrumentation does. See [Instrument your application](/docs/instrumentation) for your backend's language or framework. + +Browser and server clocks disagree, so a server span can appear to start slightly before the `fetch` that caused it, and a laptop that slept can be minutes off. Durations are accurate; the offsets between browser and server spans are approximate. + +## Navigation and data-loading spans + +Out of the box, every `fetch()` is its own trace, so a navigation that makes three requests shows up as three unrelated traces. The SDK fixes that with a span per navigation, and the data-loading and `fetch` spans nested under it. Three calls do the work: + +- `MapleBrowser.startNavigation(path)` opens a `pageload` span for the first route and a `navigate` span for each one after it. If a navigation starts before the previous one ended, the previous span ends and is marked `app.navigation.interrupted`. +- `MapleBrowser.endNavigation(route)` names the span after the route template and ends it. +- `MapleBrowser.traced(name, fn, { isFailure })` runs data loading in a child span of the current navigation, and marks the span failed when `fn` throws, unless `isFailure` returns `false`. An error it recorded isn't reported a second time by `captureException` or the SDK's global error handlers. + +The Angular integration below connects them to the Angular Router and your resolvers. + +Span names use the route template, like `navigate /projects/:id`, never the concrete URL. Maple groups by span name, so a template gives you one row with a real p95, while concrete URLs give you one row per project. The concrete path is still on the span as `url.path`. + +The first page load joins the server render's trace without any browser code: when the server sends its trace context in a `Server-Timing` header or a `` tag, the `pageload` span becomes part of that trace, and follows its sampling decision: a page load under a trace the server didn't sample isn't recorded. In a client-only app, it starts a trace of its own. The [Browser SDK reference](/docs/session-replay/browser-sdk#navigation-and-data-loading-spans) has the details. + +### The await problem + +In the browser, a span only stays active until the first `await` inside it. A request that starts after an `await` loses its parent and shows up as a separate trace. + +```ts +// ❌ fetchMembers starts after an await, so it becomes its own trace +MapleBrowser.traced("load project", async () => { + const project = await fetchProject(id) + const members = await fetchMembers(project.id) + return { project, members } +}) +``` + +```ts +// ✅ Save the context before the first await, and run later requests inside it +import { context } from "@opentelemetry/api" + +MapleBrowser.traced("load project", async () => { + const ctx = context.active() + const project = await fetchProject(id) + const members = await context.with(ctx, () => fetchMembers(project.id)) + return { project, members } +}) +``` + +`context` comes from `@opentelemetry/api`, so add it with `npm install @opentelemetry/api` if you use this pattern. The SDK already depends on it, but strict package managers like pnpm only resolve packages you list yourself. + +If the requests don't depend on each other, start them together with `Promise.all` instead. Both nest under the span, and the page stops waiting on one request before starting the next. + +This happens because browsers have no equivalent of Node's `AsyncLocalStorage`, which is what carries the active span across `await` on the server. + +## Trace Angular Router navigations + +Every navigation emits a `NavigationStart`. When it's over, it emits `NavigationEnd` if the new route's components were created, `NavigationCancel` if a guard said no, something redirected, or a newer navigation replaced it, and `NavigationError` if something threw. `provideMapleTracing()` turns those events into a span per navigation. Add it next to the router, together with the error handler from [the errors section](#report-errors-caught-by-angulars-errorhandler): + +```ts +// src/app/app.config.ts +import { provideHttpClient } from "@angular/common/http" +import { type ApplicationConfig, ErrorHandler, provideBrowserGlobalErrorListeners } from "@angular/core" +import { provideRouter } from "@angular/router" +import { MapleErrorHandler, provideMapleTracing } from "@maple-dev/browser/angular" +import { routes } from "./app.routes" + +export const appConfig: ApplicationConfig = { + providers: [ + provideBrowserGlobalErrorListeners(), + provideRouter(routes), + provideHttpClient(), + provideMapleTracing(), + { provide: ErrorHandler, useClass: MapleErrorHandler }, + ], +} +``` + +`provideMapleTracing()` subscribes to `Router.events` from an environment initializer. Those run before every app initializer, so it also sees a navigation that an app initializer starts, like the first one with `withEnabledBlockingInitialNavigation()`. During the server render it does nothing, since the render gets [a span of its own](#trace-server-rendering-with-angularssr). + +How navigations turn into spans: + +- **The span name comes from `routeConfig.path`, not from the URL.** Angular has no single "full path" property, so the integration walks the activated routes from the root and joins their configured paths. Nested routes like `{ path: "projects", children: [{ path: ":id" }] }` come out as `/projects/:id`, layout routes with an empty path add nothing, and your wildcard route is `/**`. A route matched by a `matcher` function has no `path` either, so it adds nothing to the name. +- **Redirects from guards and resolvers stay in one span.** A guard that returns a `UrlTree`, or a resolver that returns a `RedirectCommand`, cancels the navigation with the code `Redirect` and immediately starts a new one. Both are one span, named after the route the user lands on: a click on `/old` that a guard sends to `/projects/1` is one `navigate /projects/:id` span with `url.path` set to `/old`. A redirect to the URL already on screen ends the span, named after that route. A `redirectTo` in the route config doesn't cancel anything; it's resolved while matching. With SSR, a full page load of a redirecting URL is an HTTP 302 from the server, and the `pageload` span joins the render of the page it lands on. +- **Late events never end a newer span.** Every event carries its navigation's `id`, and only the latest navigation ends the span. +- **A second click interrupts the first navigation.** When the user clicks a link before the previous navigation finished loading, Angular cancels the old one with the code `SupersededByNewNavigation`, and its span ends with `app.navigation.interrupted` set. That includes a click on a link to the URL on screen. The old span keeps the bare name `navigate`, since its route never activated, and its resolver span can outlive it: Angular doesn't stop a running resolver, it ignores the result. +- **Query-only changes, fragments and back/forward are full navigations.** Going from `/projects/42` to `/projects/42?tab=members`, following a `routerLink` with a `fragment`, or pressing the back button each gets its own `navigate` span named after the route. By default, resolvers only rerun when path or matrix params change, so a query-only change is a short span with nothing under it. +- **Navigating to the URL you're already on starts no span.** Angular emits `NavigationSkipped` without a `NavigationStart`. +- **Failed navigations aren't marked as errors.** A navigation that ends in `NavigationError` is named after the route it tried to reach, but its span isn't marked `Error`. The error itself is on the resolver's span, or on an `angular.error` span from the `ErrorHandler`. +- **`url.path` is the router's URL.** It leaves out your `` and keeps matrix params like `;tab=members`. + +## Trace Angular route resolvers + +Resolvers are Angular's data loading step: the router waits for them before it activates the route, so their time is navigation time. `tracedResolver` runs a resolver's data loading in a span under the current navigation: + +```ts +// src/app/projects/project.resolver.ts +import { HttpClient } from "@angular/common/http" +import { inject } from "@angular/core" +import type { ResolveFn } from "@angular/router" +import { tracedResolver } from "@maple-dev/browser/angular" +import { firstValueFrom } from "rxjs" +import type { Member, Project } from "./project" + +export const projectResolver: ResolveFn<[Project, Member[]]> = (route) => { + // inject() only works synchronously, before the first await + const http = inject(HttpClient) + const id = route.paramMap.get("id") + + return tracedResolver("loader /projects/:id", () => + Promise.all([ + firstValueFrom(http.get(`/api/projects/${id}`)), + firstValueFrom(http.get(`/api/projects/${id}/members`)), + ]), + ) +} +``` + +`tracedResolver` works with promises, so `firstValueFrom` turns each `HttpClient` observable into one. This also nests correctly: `firstValueFrom` subscribes right away, and Angular's fetch backend calls `fetch()` synchronously during that subscribe, while the resolver span is still active. Sequential `await`s are a different story. Only requests started before the first `await` nest under the span; see [the await problem](#the-await-problem). + +It's `MapleBrowser.traced` with one addition, for redirects. A resolver usually redirects by returning a `RedirectCommand`, which is a normal result. The router also accepts a thrown one, handy from inside a helper function, and `tracedResolver` doesn't mark the span as failed for it either. + +A missing record is a redirect too. Catch the 404 inside the function you pass to `tracedResolver` and return a `RedirectCommand` to your not-found route: + +```ts +// src/app/projects/project.resolver.ts +import { HttpClient, HttpErrorResponse } from "@angular/common/http" +import { inject } from "@angular/core" +import { RedirectCommand, type ResolveFn, Router } from "@angular/router" +import { tracedResolver } from "@maple-dev/browser/angular" +import { firstValueFrom } from "rxjs" +import type { Member, Project } from "./project" + +export const projectResolver: ResolveFn<[Project, Member[]]> = (route) => { + const http = inject(HttpClient) + const router = inject(Router) + const id = route.paramMap.get("id") + + return tracedResolver("loader /projects/:id", async () => { + try { + return await Promise.all([ + firstValueFrom(http.get(`/api/projects/${id}`)), + firstValueFrom(http.get(`/api/projects/${id}/members`)), + ]) + } catch (error) { + if (error instanceof HttpErrorResponse && error.status === 404) { + return new RedirectCommand(router.parseUrl("/not-found"), { skipLocationChange: true }) + } + throw error + } + }) +} +``` + +The loader span stays Ok and the navigation span is named after the not-found route, `navigate /not-found`, with the requested path in `url.path`. The `fetch` span for the 404 is still marked `Error`, as OpenTelemetry marks every 4xx client span, but Maple doesn't open an issue for it. A URL no route matches lands on your `**` route: `navigate /**`. + +One thing the waterfall will show you: the router runs the resolvers of nested routes one level at a time. A parent route's resolvers finish before its child's start, and only the resolvers within one route run in parallel. If a layout resolver and a page resolver don't depend on each other, that's a waterfall you can remove by moving both into one route's `resolve` map. + +## Report errors caught by Angular's ErrorHandler + +Angular catches errors thrown in templates, lifecycle hooks, and template event listeners, and hands them to the `ErrorHandler` service. That's why a broken `(click)` handler logs `ERROR` in the console instead of reaching `window.onerror`, where the SDK would see it. `RouterLink` does the same with a failed navigation. + +`MapleErrorHandler`, which the app config above provides, reports each of those errors as an `angular.error` span, then logs it to the console like Angular's own handler: + +- **A `(click)` handler that throws is reported once**, as an `angular.error` span. It never becomes a `browser.uncaught_error`, since the error doesn't reach `window.onerror`. +- **A failed resolver is one error.** A resolver that throws fails the navigation, and `RouterLink` passes the same error object to the `ErrorHandler`. `tracedResolver` already recorded it on the resolver's span, so `MapleErrorHandler` skips it. +- **Global errors are reported once.** `provideBrowserGlobalErrorListeners()`, which new CLI projects include, sends uncaught errors and unhandled rejections to the `ErrorHandler` too. Those also reach the SDK's own global handlers, but you still get one issue per error: `captureException` records each error object once, whichever handler sees it first. An `error` event without an error object, like a cross-origin `Script error.`, reaches the `ErrorHandler` wrapped in a new `Error`, which `MapleErrorHandler` skips, since the SDK's own handler already recorded the event. + +`provideMapleTracing()` leaves the `ErrorHandler` to you on purpose. An app has one `ErrorHandler`, and the last provider for it wins, so one hidden inside `provideMapleTracing()` would replace a handler your app already provides, or be replaced by it, depending on the order of the providers. If your app has its own, keep it and call `reportAngularError` from it instead of providing `MapleErrorHandler`: + +```ts +// src/app/error-handler.ts +import { ErrorHandler, Injectable } from "@angular/core" +import { reportAngularError } from "@maple-dev/browser/angular" + +@Injectable() +export class AppErrorHandler extends ErrorHandler { + override handleError(error: unknown) { + reportAngularError(error) + // ...your own handling + super.handleError(error) + } +} +``` + +`reportAngularError` skips the same errors `MapleErrorHandler` does. Two cases to watch for: + +- **`withNavigationErrorHandler` can hide errors.** If your handler returns a `RedirectCommand` to show an error page, the navigation becomes a redirect and the error never reaches `ErrorHandler`. Errors from `tracedResolver` are still on their span; report anything else with `reportAngularError(event.error)` inside that handler. +- **`onViewError` replaces `handleError` for `@boundary` blocks.** Angular 22's `ErrorHandler` has an optional `onViewError` method. If you implement it, Angular calls it instead of `handleError` for errors caught by a `@boundary` block, so call `reportAngularError(error)` there too. `MapleErrorHandler` doesn't implement it, so those errors land in `handleError`. + +## Trace server rendering with @angular/ssr + +With `@angular/ssr`, the first page load starts on your Node server. Start the OpenTelemetry Node SDK as in the [Node.js guide](/docs/guides/instrumentation-nodejs), in a plain JavaScript file like `telemetry.mjs` at the project root, preloaded with `node --import ./telemetry.mjs dist/acme-web/server/server.mjs` so it runs before the server bundle. Keep that guide's `register()` call for OpenTelemetry's ES module hook, since the server build is an ES module. That gives you a span for every incoming request. + +The Angular CLI bundles Express into `server.mjs`, so the Express instrumentation has nothing to patch. The HTTP instrumentation still creates a server span per request (named after the method, like `GET`), and the undici instrumentation traces the `HttpClient` requests your resolvers make during the render. Those two are the instrumentations that matter here. + +Then wrap the render in the middleware at the bottom of the generated `src/server.ts` with `tracedRender`. It runs the render in a span and hands its trace to the browser in a `Server-Timing` header: + +```ts +// src/server.ts +import { AngularNodeAppEngine, writeResponseToNodeResponse } from "@angular/ssr/node" +import { tracedRender } from "@maple-dev/browser/angular/server" +import express from "express" + +const app = express() +const angularApp = new AngularNodeAppEngine() + +// ...express.static() and your API routes, as generated + +app.use((req, res, next) => { + tracedRender(req, () => angularApp.handle(req)) + .then((response) => (response ? writeResponseToNodeResponse(response, res) : next())) + .catch(next) +}) +``` + +On the browser side there's nothing to add. The `pageload` span reads the header and becomes a child of the render's span, so the first load is one trace from the incoming request to the first `NavigationEnd` in the browser. + +A few things to know about the `ssr` span: + +- **It sits under the request span.** The HTTP instrumentation's `GET` span is its parent, and the browser's `pageload` span is its child. +- **It has a fixed name.** Express doesn't know which Angular route matched, so `url.path` carries the path, and the browser's `pageload /projects/:id` span in the same trace carries the template. +- **It ends when `handle()` resolves**, once the app is stable: after the server-side navigation, its resolvers, and pending `HttpClient` requests. Writing the HTML out happens after it, inside the request span. +- **Render errors are recorded.** If `handle()` throws, the `ssr` span records the error and is marked `Error`, and the error goes on to Express through `next`. +- **Resolvers nest under it on the server.** `tracedResolver` works there too, and Node's `AsyncLocalStorage` keeps the request's context across `await`s, so server-side resolver spans need no changes. +- **`ErrorHandler` runs on the server too.** `MapleErrorHandler` is part of the app config, and it records into the server's trace during the render. A component that throws while rendering is reported twice on a full page load: once in the `ssr` trace and once when the browser hydrates. +- **A resolver that throws during the render gets no page.** `handle()` resolves `null`, Express answers with its own 404, and the browser never starts Angular, so there's no `pageload` span. The error is on the server's resolver span, under an `ssr` span: `tracedRender` opens one for every request that reaches it, including the ones Angular doesn't render. +- **The browser's first resolvers look suspiciously fast.** `provideClientHydration()` replays the `HttpClient` GET requests made during the server render (except ones with auth headers or credentials), so the browser's first resolver spans have no fetch spans under them. The real requests are in the same trace, under the `ssr` span. + +If a CDN caches your HTML, render those pages with `angularApp.handle(req)` directly, without `tracedRender`, or every visitor's page load will join the same old trace. `@angular/ssr` sets no `ETag` on rendered pages, so a reload always gets a fresh render and a new trace. + +## Angular-specific gotchas + +- **`ZoneContextManager` doesn't help here.** It's OpenTelemetry's answer to losing context after `await`, and it needs zone.js. New Angular apps are zoneless by default. And even with zone.js, Angular's fetch backend calls `fetch()` inside `NgZone.runOutsideAngular`, which leaves the zone that holds the active span. The context is gone by the time the request starts. +- **With zone.js, resolvers delay stability.** `provideMapleTracing()` handles router events outside Angular's zone, but a resolver's span ends inside it, and ending a span starts the exporter's 2-second timer as a zone task. After a navigation with resolvers, `ApplicationRef.isStable` stays `false` until the next export, which delays anything that waits for it, like the service worker's default registration. Zoneless apps aren't affected. +- **Data loaded in components starts its own traces.** `httpResource` and requests in `ngOnInit` run after the component is created, which is after `NavigationEnd`. They're fetch spans without a parent. If they're part of what the user waits for, a resolver puts them in the navigation. +- **Lazy routes show up as gaps.** Loading a `loadComponent` or `loadChildren` chunk happens inside the navigation span, but dynamic `import()` isn't a `fetch`, so there's no child span. A gap at the start of a navigation with nothing under it is often a chunk download. +- **`HttpErrorResponse` isn't an `Error`.** When a resolver's request fails, the resolver span records its message, and the fetch span under it has the status code and URL. + +## What this setup doesn't cover + +- **`XMLHttpRequest`.** Only `fetch` is instrumented. Clients built on XHR, like axios by default, need `adapter: "fetch"` or OpenTelemetry's `XMLHttpRequestInstrumentation`. +- **Web Vitals.** The SDK doesn't record LCP, INP or CLS. +- **Readable stack traces.** Errors are grouped without bundle hashes and line numbers, so one bug stays one issue across deploys, but stacks show minified names. +- **Ad blockers.** Some block telemetry requests. If that matters for your users, point `endpoint` at a proxy on your own domain. +- **Trace sampling.** `replay.sampleRate` samples session recordings; browser traces are all sent. + +## FAQ + +### Does Angular have built-in OpenTelemetry support? + +No, Angular 22 doesn't ship an OpenTelemetry integration. `@maple-dev/browser/angular` uses public APIs only: `Router.events`, resolvers and `ErrorHandler` in the browser, and `AngularNodeAppEngine` on the server. It's plain functions and a class without decorators, so it needs no Angular compiler step. + +### Why don't my Angular HttpClient requests show up as spans? + +On Angular 21 and older, `HttpClient` uses `XMLHttpRequest` unless you add `withFetch()`, and the browser SDK only instruments `fetch`. On Angular 22, check for `withXhr()`. If the requests show up but aren't connected to your backend, check the [cross-origin setup](#connect-browser-traces-to-your-backend). + +### Does this work with NgModule-based Angular apps? + +Yes. Add `provideMapleTracing()` and the `ErrorHandler` provider to your root module's `providers`, which accept the same providers, and import `./maple` before calling `bootstrapModule`. + +## Next steps + +- [Frontend tracing overview](/docs/frontend): every framework guide. +- [Browser SDK reference](/docs/session-replay/browser-sdk): consent, masking and URL redaction. +- [Session replays](/docs/session-replay/replays): open the recording behind a trace. +- [Errors and issues](/docs/errors/overview): how reported errors are grouped into issues. +- [Instrument your application](/docs/instrumentation): backend guides, so browser traces continue into your services. diff --git a/apps/landing/src/content/docs/frontend/astro.md b/apps/landing/src/content/docs/frontend/astro.md new file mode 100644 index 0000000000..c12fccde09 --- /dev/null +++ b/apps/landing/src/content/docs/frontend/astro.md @@ -0,0 +1,445 @@ +--- +title: "Frontend tracing for Astro" +description: "Trace Astro page loads, ClientRouter navigations and island errors in the browser, and join the first page load to the server render of on-demand pages in one OpenTelemetry trace." +group: "Frontend" +order: 7 +navLabel: "Astro" +icon: "astro" +--- + +Astro ships HTML first and JavaScript only where you ask for it, so its frontend tracing looks different from a single-page app. By default every link loads a new document, and each page load becomes one `pageload` span named after the page's route. With ``, links are fetched and swapped in place, and each one becomes a `navigate` span with the page request and, for pages rendered on demand, the server's spans under it. This guide covers both, plus errors from islands and the server half for on-demand rendering. The integration needs Astro 5 or later, and the code was checked against Astro 7.3. + +## Quick setup with a coding agent + +Copy this prompt into Claude Code, Codex, Cursor or another 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. + +```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 Astro. + +My Maple public ingest key is maple_pk_... and my organization is in the US region. +``` + +Use your public key from **Settings → Ingestion**. Without one, the agent uses a placeholder you can replace later. EU organizations should say EU region. + +## Install the browser SDK + +```bash +npm install @maple-dev/browser +``` + +This guide needs `@maple-dev/browser` 0.10.0 or later. + +```ts +// src/maple.ts +import { MapleBrowser } from "@maple-dev/browser" + +MapleBrowser.init({ + ingestKey: import.meta.env.PUBLIC_MAPLE_INGEST_KEY, // public key, maple_pk_... + serviceName: "acme-web", + serviceVersion: import.meta.env.PUBLIC_COMMIT_SHA, + environment: import.meta.env.MODE, +}) +``` + +Astro only exposes environment variables with the `PUBLIC_` prefix to browser code, so the key and version come from `PUBLIC_MAPLE_INGEST_KEY` and `PUBLIC_COMMIT_SHA`. Import `./maple` in a ` + + + + + +``` + +That's the whole browser setup, with or without ``. The integration adds two things to every page. A middleware writes the page's route template onto its `` element as `data-route`, and a script, bundled once per page, starts and ends the navigation spans and names them after that attribute. `MapleBrowser.init()` stays in your own script because its options can hold functions and `import.meta.env` values, which the integration's config can't pass to the browser. + +Keep the ` +``` + +### Set up OpenTelemetry on the server + +The middleware uses the OpenTelemetry API, which does nothing until an SDK is running in the server process. Which one depends on the adapter. + +**Node adapter (`@astrojs/node`).** Follow the [Node.js guide](/docs/guides/instrumentation-nodejs) and preload the SDK with `--import`, so it starts before Astro: + +```bash +node --import ./instrumentation.mjs ./dist/server/entry.mjs +``` + +Three details matter for Astro: + +- Astro's server build is ES modules. Register OpenTelemetry's ES module hook before `sdk.start()`, with `register("@opentelemetry/instrumentation/hook.mjs", import.meta.url)` from `node:module`. Without it, `node:http` is never patched: there's no HTTP server span, and an incoming `traceparent` isn't continued, so the page requests `` makes don't join the click's trace. +- Ignore Astro's hashed assets in the HTTP instrumentation, or every script and stylesheet the browser downloads gets its own trace: + + ```js + getNodeAutoInstrumentations({ + // Hashed JS and CSS files: otherwise one trace per asset on every page load + "@opentelemetry/instrumentation-http": { + ignoreIncomingRequestHook: (request) => request.url?.startsWith("/_astro/") ?? false, + }, + }) + ``` + + `/_astro/` is the default `build.assets` directory; use your value if you changed it. +- Install the OpenTelemetry packages as `dependencies`. The preload file isn't part of Astro's build, so they have to be installed where the server runs. + +Prerendered pages and files from `public/` that the adapter serves still get an HTTP server span each, in a trace of their own. Their `pageload` spans don't join those traces, because the files carry no `server-timing` header. + +**Cloudflare adapter (`@astrojs/cloudflare`).** Export the Worker's traces with [Workers Observability](/docs/guides/instrumentation-cloudflare-workers). The Workers runtime records the spans itself, but your code can't read their trace ids yet, and no OpenTelemetry SDK is registered in the Worker, so the middleware finds no trace context to send. It still writes the route into your pages. The `pageload` span starts its own trace, and the Worker's request shows up as a separate trace in Maple. + +For any other adapter, see [Instrument your application](/docs/instrumentation) for its runtime. The page load joins the server's trace only if an OpenTelemetry SDK runs in the same process as Astro. + +## Worked example + +A project with a base layout, ``, prerendered blog pages, and project pages rendered on demand by the Node adapter. This is the config: + +```js +// astro.config.mjs +import node from "@astrojs/node" +import maple from "@maple-dev/browser/astro" +import { defineConfig } from "astro/config" + +export default defineConfig({ + adapter: node({ mode: "standalone" }), + integrations: [maple()], +}) +``` + +And the complete layout: + +```astro +--- +// src/layouts/Layout.astro +import { ClientRouter } from "astro:transitions" + +const { title } = Astro.props +--- + + + + + {title} + + + + + + + +``` + +Together with `src/maple.ts` and the preloaded Node SDK, you get these traces: + +- **Loading `/projects/8f2a` directly.** The HTTP server span, `ssr /projects/[id]` under it with the frontmatter's requests, and `pageload /projects/[id]` from the browser as a child of the `ssr` span. A reload starts a new trace. +- **Loading `/blog/hello` directly.** A `pageload /blog/[slug]` span with no parent. The page is a prerendered file, so there's no server span. +- **Clicking from a blog post to `/projects/8f2a`.** `navigate /projects/[id]` with `load page` under it, the `fetch` for the page's HTML under that, and the server's HTTP and `ssr /projects/[id]` spans under the `fetch`: one trace for the click, from the browser to the frontmatter's requests. +- **Clicking to a blog post.** `navigate /blog/[slug]` with `load page` and its `fetch`, and no server spans. + +## Astro tracing gotchas + +- **Every page needs the `init()` script.** A page that doesn't render your base layout records nothing when it's loaded directly, even though the integration's script still loads the SDK's code there. With ``, a navigation to such a page also falls back to a full page load, since it has no `` of its own. +- **Use `PUBLIC_` environment variables.** Astro only exposes variables with that prefix to browser code. `VITE_*` variables are `undefined` in the browser unless you change `vite.envPrefix`. +- **Check your Astro version.** The integration needs Astro 5 or later for `Astro.routePattern`: on Astro 4 the middleware passes every response through, and spans keep their generic names. Reporting islands that fail to load needs Astro 6.3, and skipping pages in the route cache needs Astro 7. +- **Hidden tabs abort view transitions.** In Chromium, a `` navigation that finishes while the tab is in the background can't run its view transition, and the browser rejects it with `InvalidStateError: Transition was aborted because of invalid state`. Astro doesn't handle that rejection, so the SDK records it as `browser.unhandled_rejection`. The navigation itself completes. +- **Test with a production build.** `astro dev` also names and traces pages, but it doesn't bundle scripts the same way, and the server spans need the OpenTelemetry SDK that only the start command preloads. Run `astro build`, then the adapter's start command, or `astro preview` for a static site. +- **Node 26 warns about `module.register()`.** The ES module hook still works. +- **Interrupted navigations need a slow page response.** To see a `navigate` span end as interrupted, use `` and click away from a page whose response is slow, like an on-demand page with slow frontmatter. A slow request in an island doesn't hold the navigation open, and without `` there's no navigation span to interrupt. + +## What this setup doesn't cover + +- **`XMLHttpRequest`.** Only `fetch` is instrumented. Clients built on XHR, like axios by default, need `adapter: "fetch"` or OpenTelemetry's `XMLHttpRequestInstrumentation`. +- **Web Vitals.** The SDK doesn't record LCP, INP or CLS. +- **Readable stack traces.** Errors are grouped without bundle hashes and line numbers, so one bug stays one issue across deploys, but stacks show minified names. +- **Ad blockers.** Some block telemetry requests. If that matters for your users, point `endpoint` at a proxy on your own domain. +- **Trace sampling.** `replay.sampleRate` samples session recordings; browser traces are all sent. + +## FAQ + +### Does Astro have built-in OpenTelemetry support? + +No. Astro doesn't create spans on the server or in the browser. On the server, the Node SDK's HTTP instrumentation gives you a span per request, and the `maple()` integration adds one named after the route. In the browser, the integration adds the page load and navigation spans. + +### Does this work with a fully static Astro site? + +Yes. Everything before the server section works without a server: each page load is a `pageload` span, and with `` each click is a `navigate` span. The integration writes the route into each page at build time. Without a `server-timing` header, the `pageload` span starts its own trace. + +### Why is my navigate span missing the server's spans? + +Three common causes. The page is prerendered, so no server renders it. The page was prefetched on hover, so the click was served from the prefetch cache and the server rendered it earlier, in its own trace. Or the server doesn't continue incoming traces: with the Node adapter, check that OpenTelemetry's ES module hook is registered, or `node:http` isn't instrumented. + +## Next steps + +- [Frontend tracing overview](/docs/frontend): every framework guide. +- [Browser SDK reference](/docs/session-replay/browser-sdk): consent, masking and URL redaction. +- [Session replays](/docs/session-replay/replays): open the recording behind a trace. +- [Errors and issues](/docs/errors/overview): how reported errors are grouped into issues. +- [Instrument your application](/docs/instrumentation): backend guides, so browser traces continue into your services. diff --git a/apps/landing/src/content/docs/frontend/nextjs.md b/apps/landing/src/content/docs/frontend/nextjs.md new file mode 100644 index 0000000000..9010e8e14f --- /dev/null +++ b/apps/landing/src/content/docs/frontend/nextjs.md @@ -0,0 +1,394 @@ +--- +title: "Frontend tracing for Next.js" +description: "Trace App Router navigations, client data fetching and error boundaries in the browser, and join the first page load to the Next.js server render in one OpenTelemetry trace." +group: "Frontend" +order: 3 +navLabel: "Next.js" +icon: "nextjs" +--- + +Next.js already traces its server: with `@vercel/otel` in `instrumentation.ts`, every request gets spans for the render and every server-side `fetch()`. What it can't see is the browser: how long a click took to show the new page, which requests client components made, and which errors your error boundaries caught. This guide adds that half. The first page load becomes one trace from the incoming request to the page hydrating, and every later click gets a `navigate` span named after the route, like `navigate /projects/[id]`. + +Set up the server side first with the [Next.js instrumentation guide](/docs/guides/instrumentation-nextjs). The examples use the App Router, a `src/` directory, and Next.js 16. + +## Quick setup with a coding agent + +Copy this prompt into Claude Code, Codex, Cursor or another 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. + +```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 Next.js. + +My Maple public ingest key is maple_pk_... and my organization is in the US region. +``` + +Use your public key from **Settings → Ingestion**. Without one, the agent uses a placeholder you can replace later. EU organizations should say EU region. + +## Install the browser SDK + +```bash +npm install @maple-dev/browser +``` + +This guide needs `@maple-dev/browser` 0.10.0 or later. + +Next.js has a file for this. `instrumentation-client.ts` runs after the HTML loads and before React hydrates, so the SDK is running before any of your components do. It's available from Next.js 15.3, and it goes next to `instrumentation.ts`: + +```ts +// src/instrumentation-client.ts +import { MapleBrowser } from "@maple-dev/browser" + +MapleBrowser.init({ + ingestKey: process.env.NEXT_PUBLIC_MAPLE_INGEST_KEY!, // public key, maple_pk_... + serviceName: "acme-web", + environment: process.env.NODE_ENV, +}) + +// Next.js only reports client-side navigations, so the first page load starts here +MapleBrowser.startNavigation(location.pathname) + +export { onRouterTransitionStart } from "@maple-dev/browser/nextjs" +``` + +Next.js inlines `NEXT_PUBLIC_*` variables at build time, so set the key where you build, not only where you run. It has to be the public `maple_pk_` key, never the private one. + +The `startNavigation` call opens the `pageload` span. It starts when this file runs, after the HTML and its JavaScript have downloaded, so it covers hydration but not the time to first byte. The server's half of the trace covers that part, once the two are linked (the last section below). The re-exported `onRouterTransitionStart` starts a span for every navigation after it. + +If you followed the [Browser SDK docs](/docs/session-replay/browser-sdk#nextjs), which initialize from a client component in the root layout, move the `init()` call here. Next.js only calls `onRouterTransitionStart` when this file exports it. + +`init()` sets up: + +- a span for every `fetch()` call; +- error spans for uncaught errors and unhandled promise rejections, which show up on the [Errors](/docs/errors/overview) page; +- session replay, with the same `session.id` on every span and replay event, so a trace links to the recording of the session that produced it; +- export every 2 seconds, plus a flush when the tab is hidden or closed, so the spans from the last moments of a visit aren't lost; +- redaction of credential-looking query parameters (`token`, `code`, `password` and similar) in every URL it sends. + +Use the public ingest key (`maple_pk_…`) from **Settings → Ingestion**. It can only write telemetry, so it's safe in browser code. For an EU organization, add `region: "eu"`. Every option is in the [Browser SDK reference](/docs/session-replay/browser-sdk). + +## Connect browser traces to your backend + +Each `fetch()` span sends a W3C `traceparent` header, and your backend's span joins the same trace. For requests to the page's own origin this happens automatically. For an API on another origin, list it: + +```ts +MapleBrowser.init({ + // ... + tracing: { + propagateTraceHeaderCorsUrls: [/^https:\/\/api\.acme\.com\//], + }, +}) +``` + +Then allow the header in the API's CORS configuration. Without it, the browser blocks the request after the preflight: + +```http +Access-Control-Allow-Headers: content-type, authorization, traceparent, tracestate +``` + +Only list your own APIs. Sending `traceparent` to third parties leaks your trace ids, and many of them reject the preflight. + +Your backend needs OpenTelemetry to read the header; every OpenTelemetry HTTP server instrumentation does. See [Instrument your application](/docs/instrumentation) for your backend's language or framework. + +Browser and server clocks disagree, so a server span can appear to start slightly before the `fetch` that caused it, and a laptop that slept can be minutes off. Durations are accurate; the offsets between browser and server spans are approximate. + +## Navigation and data-loading spans + +Out of the box, every `fetch()` is its own trace, so a navigation that makes three requests shows up as three unrelated traces. The SDK fixes that with a span per navigation, and the data-loading and `fetch` spans nested under it. Three calls do the work: + +- `MapleBrowser.startNavigation(path)` opens a `pageload` span for the first route and a `navigate` span for each one after it. If a navigation starts before the previous one ended, the previous span ends and is marked `app.navigation.interrupted`. +- `MapleBrowser.endNavigation(route)` names the span after the route template and ends it. +- `MapleBrowser.traced(name, fn, { isFailure })` runs data loading in a child span of the current navigation, and marks the span failed when `fn` throws, unless `isFailure` returns `false`. An error it recorded isn't reported a second time by `captureException` or the SDK's global error handlers. + +The Next.js integration below connects them to the App Router. + +Span names use the route template, like `navigate /projects/:id`, never the concrete URL. Maple groups by span name, so a template gives you one row with a real p95, while concrete URLs give you one row per project. The concrete path is still on the span as `url.path`. + +The first page load joins the server render's trace without any browser code: when the server sends its trace context in a `Server-Timing` header or a `` tag, the `pageload` span becomes part of that trace, and follows its sampling decision: a page load under a trace the server didn't sample isn't recorded. In a client-only app, it starts a trace of its own. The [Browser SDK reference](/docs/session-replay/browser-sdk#navigation-and-data-loading-spans) has the details. + +### The await problem + +In the browser, a span only stays active until the first `await` inside it. A request that starts after an `await` loses its parent and shows up as a separate trace. + +```ts +// ❌ fetchMembers starts after an await, so it becomes its own trace +MapleBrowser.traced("load project", async () => { + const project = await fetchProject(id) + const members = await fetchMembers(project.id) + return { project, members } +}) +``` + +```ts +// ✅ Save the context before the first await, and run later requests inside it +import { context } from "@opentelemetry/api" + +MapleBrowser.traced("load project", async () => { + const ctx = context.active() + const project = await fetchProject(id) + const members = await context.with(ctx, () => fetchMembers(project.id)) + return { project, members } +}) +``` + +`context` comes from `@opentelemetry/api`, so add it with `npm install @opentelemetry/api` if you use this pattern. The SDK already depends on it, but strict package managers like pnpm only resolve packages you list yourself. + +If the requests don't depend on each other, start them together with `Promise.all` instead. Both nest under the span, and the page stops waiting on one request before starting the next. + +This happens because browsers have no equivalent of Node's `AsyncLocalStorage`, which is what carries the active span across `await` on the server. + +## Trace App Router navigations + +The App Router reports when a navigation starts, through the `onRouterTransitionStart` export above, but not when it ends. That comes from React: `MapleNavigation` is a client component that ends the span in an effect, which runs once the new route is committed. Render it once in the root layout, above `{children}`: + +```tsx +// src/app/layout.tsx +import { MapleNavigation } from "@maple-dev/browser/nextjs" + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + + {children} + + + ) +} +``` + +`MapleNavigation` brings its own `` boundary, which Next.js requires on statically rendered pages because the component reads `useSearchParams()`. What to expect: + +- **Spans are named after the route template, rebuilt from the params.** Next.js doesn't expose the matched route pattern on the client, so each value from `useParams()` is replaced with its name. `/projects/8f2a` becomes `navigate /projects/[id]`, and the catch-all `/docs/a/b` becomes `navigate /docs/[...slug]`. +- **Hash links start no span.** Neither does a link to the pathname and query already on screen. If such a link is clicked while another navigation is still loading, that navigation ends as interrupted. +- **Query changes are navigations.** Going from `?tab=1` to `?tab=2` makes Next.js fetch new Server Component data, so it gets a span. `router.refresh()` and a server action that revalidates the page don't end a navigation that's still loading. +- **Unmatched URLs are named `/_not-found`.** A URL that no route matches has no params to replace, so without this every mistyped URL would become its own span name. A `` to such a URL makes Next.js reload the page: the old document's `navigate` span is exported as interrupted, and the new document reports `pageload /_not-found`. A route that calls `notFound()` keeps its own template, like `navigate /projects/[id]`. +- **Redirects produce two spans.** When a Server Component calls `redirect()` during a client navigation, Next.js renders the redirect first and then starts a second navigation. You'll see `navigate /old` followed by `navigate /new`. +- **The span ends at the commit, not when all data has arrived.** If the route has a `loading.tsx`, the loading state is committed first, and the span ends when the skeleton appears. Content that streams into Suspense boundaries afterwards isn't part of it. + +## Trace data loading in Server Components + +In the App Router, most data loading happens in Server Components, so it's server-side tracing, and Next.js does most of it for you. On a client navigation, the router fetches the new route's Server Component payload from the same URL with an `_rsc` query parameter. The browser SDK instruments that `fetch` and sends `traceparent` with it, because it's same-origin. On the server, Next.js continues that trace with an `RSC GET /projects/[id]` span, the render, and every `fetch()` your components make. + +That request doesn't nest under the `navigate` span. Next.js makes it from inside its router, where your code can't wrap it, and without async context in the browser the navigation span can't reach it on its own. So a click gives you two traces that overlap in time and share a session id: the `navigate` span for what the user waited for, and the `GET ...?_rsc=` trace with the server work behind it. + +Three more things to expect in the trace list: + +- **Prefetches are traced too.** `` prefetches routes as they scroll into view, and each prefetch is its own `GET ...?_rsc=` trace. That's real work your server did, just before the click. +- **A link to a missing page gives a failed span.** Its prefetch returns 404, and OpenTelemetry marks client spans with a 4xx status as errors. The span has no exception and nothing in your app failed. +- **Some navigations take a few milliseconds.** A navigation to a prefetched static route often makes no request at all. That's the prefetch working. + +### Propagate to your APIs from the server + +When a Server Component calls an API on another origin, Next.js's `fetch` span only sends `traceparent` if `@vercel/otel` is told to. By default it propagates to your own Vercel deployment URLs and nothing else, so your API's spans end up in separate traces. List your first-party APIs in `instrumentation.ts`: + +```ts +// src/instrumentation.ts +import { OTLPHttpProtoTraceExporter, registerOTel } from "@vercel/otel" + +export function register() { + registerOTel({ + serviceName: "acme-next", + traceExporter: new OTLPHttpProtoTraceExporter({ + url: "https://ingest.maple.dev/v1/traces", + headers: { authorization: `Bearer ${process.env.MAPLE_INGEST_KEY}` }, + }), + instrumentationConfig: { + fetch: { propagateContextUrls: [/^https:\/\/api\.acme\.com\//] }, + }, + }) +} +``` + +The same rule as in the browser applies: only your own APIs, never third parties. + +### Database and SDK calls + +Next.js's spans cover the render and `fetch()`, but not database queries or SDK calls. Wrap those with `traced` from `@maple-dev/browser/server` to time them. It creates the span with the OpenTelemetry setup from `instrumentation.ts`, under the active span, which is Next.js's render span. Node has `AsyncLocalStorage`, so requests after an `await` keep their parent too. + +When a Server Component throws, Next.js records the exception on its render span and marks it as an error. If the data span recorded the same error, it would count twice, so pass an `isFailure` that always returns `false`. That also keeps `redirect()` and `notFound()`, which work by throwing, from showing up as failures: + +```tsx +// src/app/projects/[id]/page.tsx +import { traced } from "@maple-dev/browser/server" +import { notFound } from "next/navigation" +import { db } from "../../../db" + +export default async function ProjectPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params + + // Next.js records a thrown error on its render span, so this span only times the call + const project = await traced("load project", () => db.project.findUnique({ where: { id } }), { + isFailure: () => false, + }) + if (!project) notFound() + + return

{project.name}

+} +``` + +If the query throws, the trace shows the `load project` span with its duration and the render span above it with the exception. + +## Trace client-side data fetching + +Requests from client components, whether in `useEffect`, SWR, or React Query, get `fetch` spans without any extra work. Wrapping the fetcher in `MapleBrowser.traced` gives the request a name you'll recognize in a list of traces: + +```tsx +// src/app/projects/[id]/members-list.tsx +"use client" + +import { MapleBrowser } from "@maple-dev/browser" +import { useQuery } from "@tanstack/react-query" + +type Member = { id: string; name: string } + +export function MembersList({ projectId }: { projectId: string }) { + const { data: members = [] } = useQuery({ + queryKey: ["members", projectId], + queryFn: () => + MapleBrowser.traced("query members", async () => { + const res = await fetch(`/api/projects/${projectId}/members`) + return (await res.json()) as Member[] + }), + }) + + return ( +
    + {members.map((member) => ( +
  • {member.name}
  • + ))} +
+ ) +} +``` + +These also end up as their own traces rather than under the `navigate` span. The new page's effects run after the commit that ends the navigation, so by the time the query starts, there's no navigation left to attach to. The `fetch` inside still carries `traceparent`, so your API's spans join the `query members` trace. + +## Report errors from error.tsx and global-error.tsx + +Every `error.tsx` is a React error boundary, and it receives the error as a prop. Report it from an effect with `reportNextError`: + +```tsx +// src/app/error.tsx +"use client" + +import { reportNextError } from "@maple-dev/browser/nextjs" +import { useEffect } from "react" + +export default function ErrorPage({ error, retry }: { error: Error & { digest?: string }; retry: () => void }) { + useEffect(() => reportNextError(error), [error]) + + return ( +
+

Something went wrong

+ +
+ ) +} +``` + +Errors thrown in the root layout skip `error.tsx` and go to `global-error.tsx`, which replaces the whole document. Report from there the same way: + +```tsx +// src/app/global-error.tsx +"use client" + +import { reportNextError } from "@maple-dev/browser/nextjs" +import { useEffect } from "react" + +export default function GlobalError({ error }: { error: Error & { digest?: string } }) { + useEffect(() => reportNextError(error), [error]) + + return ( + + +

Something went wrong

+ + + ) +} +``` + +`reportNextError` records the error as `react.render_error`, and skips two kinds: + +- **Errors with a `digest`.** In production, an error thrown in a Server Component reaches the browser as a generic React error, with the message removed and a `digest` added. Reporting that from the browser would group every server error into one meaningless issue. You don't lose anything by skipping it: Next.js records the original exception, message and stack included, on its own server span (`render route (app) /projects/[id]`, or `RSC GET /projects/[id]` on a client navigation) and marks it as an error, so it's already on the Errors page. For the same reason you don't need the `onRequestError` hook in `instrumentation.ts`: with OpenTelemetry set up, it would record every server error a second time. +- **Errors `traced` already recorded** on a data-loading span. + +A client component that throws on every render shows up twice on a full page load: once on the server render span, where it turned the response into a 500, and once from `error.tsx`, when the browser renders the component again and it throws again. Those are two executions of the bug, not one error reported twice. After a client navigation, only the browser one happens. + +## Link the first page load to the server render + +To join the browser's `pageload` span to the server render, the server hands its trace context to the browser in a `Server-Timing` header. Next.js doesn't give pages a way to set response headers: `headers()` in a Server Component only reads the request. What can set them is `proxy.ts` (called `middleware.ts` before Next.js 16), which runs before the render. `withMapleProxy` creates one: + +```ts +// src/proxy.ts +import { withMapleProxy } from "@maple-dev/browser/nextjs/server" + +export const proxy = withMapleProxy() + +export const config = { + matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"], +} +``` + +If you already have a proxy, pass it in, and keep your own matcher. Your function runs first, and its responses pass through: + +```ts +// src/proxy.ts +import { withMapleProxy } from "@maple-dev/browser/nextjs/server" +import { type NextRequest, NextResponse } from "next/server" + +export const proxy = withMapleProxy((request: NextRequest) => { + if (!request.cookies.has("session")) return NextResponse.redirect(new URL("/login", request.url)) +}) +``` + +Next.js runs the proxy inside its own `middleware GET` span. `withMapleProxy` puts that span's context in the `traceparent` request header, which Next.js reads when it starts the render's root span, and in the `Server-Timing` response header, which the browser reads. The first page load becomes one trace: `middleware GET` at the root, with the `GET /projects/[id]` render and the browser's `pageload` span under it. + +- **Only responses that go on to a render in your app change.** That's `NextResponse.next()`, a rewrite to the same origin, or no response at all. Redirects, responses your proxy builds itself, and rewrites to another origin pass through unchanged. +- **A `traceparent` the request already carries is kept.** The RSC requests from client navigations carry the browser's, and replacing it would cut them off from the browser `fetch` span that made them. + +This works with `next start` on Node. Next.js only reads the incoming `traceparent` when no span is active yet, so on a platform that starts its own server span first, or that runs the proxy separately from your app, the render may not join. After deploying, open one page load in Maple and check that the `pageload` span shares a trace with the server spans. + +The proxy runs on every request it matches, including prerendered pages and `304` revalidations, so each response gets a fresh header. That changes if a shared cache such as a CDN sits in front of `next start`: Next.js sends prerendered HTML with a long `s-maxage`, the cache would store the header, and every visitor would join the same trace. In that setup, leave prerendered routes out of the matcher or use the option below. + +If the page load doesn't join, Next.js has an experimental option that does the injection itself. `experimental.clientTraceMetadata: ["traceparent"]` in `next.config.ts` renders a `` tag with the render's trace context into every dynamically rendered page. The SDK reads that tag when there's no header, so you can use it instead of the proxy. + +## Next.js-specific gotchas + +- **The pageload span measures hydration, not the full load.** It starts when `instrumentation-client.ts` runs and ends after the first commit. The time before that is in the server spans and the browser's navigation timing. +- **Development doubles effects.** React Strict Mode runs effects twice in `next dev`. The second end finds nothing open and does nothing, so traces look the same, but the dev server's timings are nothing like production. +- **Static pages have no render to join.** With the proxy, a prerendered page's `pageload` span still joins the proxy's trace, but there's no render span under it, because nothing rendered. With `clientTraceMetadata`, prerendered pages get no `` tag, and their `pageload` span is its own trace. +- **Some route templates are ambiguous.** Templates are rebuilt by matching param values from the end of the path, so a static segment after a param with the same value (`/users/settings/settings` for `/users/[name]/settings`) can be named wrong. `useParams()` can't tell an optional catch-all from a required one either: with `[[...slug]]`, `/docs` is `navigate /docs` and `/docs/a` is `navigate /docs/[...slug]`. +- **`basePath` and hash entries.** With a `basePath`, going back or forward to a history entry that only differs by its hash can open a span that ends as interrupted. +- **`global-error.tsx` replaces the root layout.** It unmounts `MapleNavigation`, so the navigation that hit the error ends as interrupted at the next navigation, or when the page is left. + +## What this setup doesn't cover + +- **`XMLHttpRequest`.** Only `fetch` is instrumented. Clients built on XHR, like axios by default, need `adapter: "fetch"` or OpenTelemetry's `XMLHttpRequestInstrumentation`. +- **Web Vitals.** The SDK doesn't record LCP, INP or CLS. +- **Readable stack traces.** Errors are grouped without bundle hashes and line numbers, so one bug stays one issue across deploys, but stacks show minified names. +- **Ad blockers.** Some block telemetry requests. If that matters for your users, point `endpoint` at a proxy on your own domain. +- **Trace sampling.** `replay.sampleRate` samples session recordings; browser traces are all sent. + +## FAQ + +### Does Next.js have built-in OpenTelemetry support? + +On the server, yes. Next.js emits spans for requests, rendering, route handlers, and `fetch()`, and `@vercel/otel` exports them. There's nothing built in for the browser, which is what this guide adds. The [Next.js instrumentation guide](/docs/guides/instrumentation-nextjs) covers the server setup. + +### Does this work with the Next.js Pages Router? + +The SDK setup does, but `@maple-dev/browser/nextjs` is for the App Router. For navigations, the Pages Router has `router.events`: call `MapleBrowser.startNavigation` with the new path on `routeChangeStart`, and `MapleBrowser.endNavigation(router.pathname)` on `routeChangeComplete` and `routeChangeError`. There, `router.pathname` is already the route template, like `/projects/[id]`. Report errors from your error boundary with `MapleBrowser.captureException(error)`. + +### Why aren't my Next.js server spans under the navigate span? + +Next.js fetches Server Component data from inside its router, where no span of yours is active. That request is still traced, with the server render under it, as its own `GET ...?_rsc=` trace. Look for it by time and session, or open the session replay, which links every trace from the session. + +## Next steps + +- [Frontend tracing overview](/docs/frontend): every framework guide. +- [Browser SDK reference](/docs/session-replay/browser-sdk): consent, masking and URL redaction. +- [Session replays](/docs/session-replay/replays): open the recording behind a trace. +- [Errors and issues](/docs/errors/overview): how reported errors are grouped into issues. +- [Instrument your application](/docs/instrumentation): backend guides, so browser traces continue into your services. diff --git a/apps/landing/src/content/docs/frontend/other.md b/apps/landing/src/content/docs/frontend/other.md new file mode 100644 index 0000000000..37657a3727 --- /dev/null +++ b/apps/landing/src/content/docs/frontend/other.md @@ -0,0 +1,317 @@ +--- +title: "Frontend tracing for other frameworks" +description: "Trace navigations, data loading and caught errors in any browser app with OpenTelemetry, whatever router it uses, and link them to your backend and server render." +group: "Frontend" +order: 8 +navLabel: "Other frameworks" +icon: "javascript" +--- + +Use this guide when your frontend has no guide of its own: Solid, Qwik, Preact, Remix v2, Ember, Lit, a hand-rolled router, or a multi-page app. The setup is the same everywhere. What changes is where each piece plugs in, and this page shows how to find those places in your framework's API. + +By the end, a click produces one trace with a span for the navigation, spans for the data it loads, the fetches those make, and the backend spans behind them. + +## Quick setup with a coding agent + +Copy this prompt into Claude Code, Codex, Cursor or another 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. + +```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 a framework without its own Maple guide (say which one). + +My Maple public ingest key is maple_pk_... and my organization is in the US region. +``` + +Use your public key from **Settings → Ingestion**. Without one, the agent uses a placeholder you can replace later. EU organizations should say EU region. + +## Install the browser SDK + +```bash +npm install @maple-dev/browser +``` + +This guide needs `@maple-dev/browser` 0.10.0 or later. + +```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, +}) +``` + +Import `./maple` first in the module that runs first in the browser: the client entry (`main.ts`, `index.tsx`, `entry-client.tsx`, `client.ts`), or a ` + +{@render children()} +``` + +You pass in the four `$app` imports because only your app can import SvelteKit's `$app` modules. `$app/state` needs SvelteKit 2.12 or later. On 2.10 and 2.11, pass `navigating: { get type() { return get(navigatingStore)?.type ?? null } }` and `page: { get route() { return get(pageStore).route } }` instead, with both stores from `$app/stores` and `get` from `svelte/store`. + +The span name comes from the route id, which is the file-system path of the route: `/projects/[id]`, not `/projects/8f2a-4c11`. It includes route groups, so a page in `src/routes/(app)/projects/[id]` is named `/(app)/projects/[id]`. SvelteKit puts the same string in the `http.route` attribute of its server spans, so browser and server spans group under the same name. + +The edge cases are where SvelteKit differs from other routers: + +- **A click during a pending navigation continues the same span.** SvelteKit skips the callbacks while a navigation is in progress, so you get one span covering both clicks, named after the route the user ended up on, with `url.path` from the first click. +- **Cancelled navigations end right away, as interrupted.** When a page calls `cancel()` in `beforeNavigate` (to guard unsaved changes, say), the span ends with the plain name `navigate` and `app.navigation.interrupted` set. +- **Redirects stay in one span.** A `load` function that throws `redirect()` starts a new navigation internally, but without calling `beforeNavigate`. The span covers both routes and is named after the destination. +- **Back and forward are navigations.** Both hooks fire, so each gets a `navigate` span, and the page's universal `load` functions run again under it. +- **Hash links don't navigate.** A click on `#section` on the same page is handled without a navigation, so there's no span. Query-string changes like `?tab=2` are real navigations and do, but only `load` functions that read `url.searchParams` run again. +- **Unknown routes reload the page.** A link to a path that matches no route makes SvelteKit load a new document. Its span is a plain `pageload`, since there's no route id to name it after. +- **`invalidate()`, `invalidateAll()`, and shallow routing** (`pushState` and `replaceState` from `$app/navigation`) start no span. Load functions rerun by `invalidate` show up as their own traces. +- **Hash routing** (`router.type: "hash"`) names spans correctly, but their `url.path` is the document's path, `/`. + +## Trace universal load functions + +Universal `load` functions in `+page.ts` and `+layout.ts` run in the browser on every client-side navigation, so each one gets a span. Wrap them in `loadSpan`: + +```ts +// src/routes/projects/[id]/+page.ts +import { loadSpan } from "@maple-dev/browser/sveltekit" +import { error } from "@sveltejs/kit" +import type { PageLoad } from "./$types" + +export const load: PageLoad = async ({ fetch, params, route }) => + loadSpan(`loader ${route.id}`, async () => { + // Start both requests before the first await, so both nest under the span + const [project, members] = await Promise.all([ + fetch(`/api/projects/${params.id}`), + fetch(`/api/projects/${params.id}/members`), + ]) + if (project.status === 404) error(404, "Project not found") + + return { project: await project.json(), members: await members.json() } + }) +``` + +`redirect()` and `error()` both work by throwing, and `loadSpan` doesn't count either as a failure when the status is below 500. That's also the rule SvelteKit's own server spans use. On the server, `loadSpan` only runs your function, because SvelteKit's own tracing already records a span per load. + +`route.id` gives the span the same template the navigation uses. Use the `fetch` that SvelteKit passes to `load`: it calls `window.fetch` when the request is made, so it goes through the SDK's instrumentation and carries `traceparent`. + +The [`await` problem](#the-await-problem) applies here too: only requests started before the first `await` nest under the span. That's why the example starts both with `Promise.all`. + +Four things to know about load spans: + +- **On the first page load, the load span is nearly empty.** Load functions run again in the browser during hydration, but their `fetch` calls are answered from responses the server inlined into the HTML. There are no network requests, so the span lasts about 0ms. The real requests are in the server half of the trace. +- **Preloading moves loads before the click.** The default `app.html` sets `data-sveltekit-preload-data="hover"`, so load functions often run when the user hovers a link. There's no navigation yet, so those load spans and their fetches become their own traces. The `navigate` span after the click has no children and lasts a few milliseconds, because the data is already there. +- **Server `load` functions get their own trace.** `+page.server.ts` runs on the server, and the browser fetches its result from a `__data.json` URL. SvelteKit starts that request outside any `load` span, so its `fetch` span, and the server's `sveltekit.load` span behind it, form a separate trace next to the navigation. +- **A 404 from your API isn't a failure.** When a load calls `error(404)` after a 404 response, the navigation and load spans stay OK. The `fetch` span for the 404 itself is marked as an error, as OpenTelemetry does for any 4xx response to a client request. + +## Report errors from SvelteKit's handleError hook + +SvelteKit catches errors thrown in `load` functions to render your `+error.svelte` page, and passes the unexpected ones to the `handleError` hook in `hooks.client.ts`. Errors from `error()` and `redirect()` never get there. Add `handleErrorWithMaple` to the client hooks file: + +```ts +// src/hooks.client.ts +import "$lib/maple" // first: starts the SDK before anything else runs +import { handleErrorWithMaple, startPageLoad } from "@maple-dev/browser/sveltekit" + +export const init = startPageLoad +export const handleError = handleErrorWithMaple() +``` + +If you already have a `handleError`, pass it in, `handleErrorWithMaple(handleError)`, and what it returns is kept. Errors are reported as `sveltekit.client_error`, with two kinds skipped: errors `loadSpan` already recorded on a load span, and 404s, because a link to a route that doesn't exist also reaches `handleError`, and you probably don't want every mistyped URL as an issue. + +Errors thrown while a component renders don't go through `handleError` by default. In the browser they escape to the window, and the SDK's global handlers record them once as `browser.uncaught_error`. SvelteKit has an experimental `handleRenderingErrors` option that wraps components in ``, renders your `+error.svelte` page in place of the broken component, and sends those errors through `handleError` too, where they're reported as `sveltekit.client_error`. + +On the server, SvelteKit's tracing records an unexpected error on the `load` or `handle` span it was thrown in, so a server `handleError` would record those twice. Errors thrown while rendering on the server are the gap: the response is a 500 and the server span is marked as an error, but no span records the exception itself. + +## Server-side tracing with SvelteKit's built-in OpenTelemetry + +Since version 2.31, SvelteKit can emit OpenTelemetry spans for the server side of a request. Both switches are under `experimental`, which means they can change in any release. Projects created with `sv create` on SvelteKit 2.62 or later keep their SvelteKit options in `vite.config.ts`, passed to the `sveltekit()` plugin, with no `kit` key: + +```ts +// vite.config.ts +import adapter from "@sveltejs/adapter-node" +import { sveltekit } from "@sveltejs/kit/vite" +import { defineConfig } from "vite" + +export default defineConfig({ + plugins: [ + sveltekit({ + adapter: adapter(), + experimental: { + tracing: { server: true }, + instrumentation: { server: true }, + }, + }), + ], +}) +``` + +If your project has a `svelte.config.js` instead, put the same `experimental` object under `kit` there. SvelteKit ignores `svelte.config.js` when options are passed to the plugin. + +`tracing.server` turns on the spans. `instrumentation.server` makes SvelteKit load `src/instrumentation.server.ts` before your app code, which is where the OpenTelemetry Node SDK starts: + +```ts +// src/instrumentation.server.ts +import { register } from "node:module" +import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node" +import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto" +import { NodeSDK } from "@opentelemetry/sdk-node" +import { createAddHookMessageChannel } from "import-in-the-middle" + +// Lets the auto-instrumentations patch ES modules, not only CommonJS +const { registerOptions } = createAddHookMessageChannel() +register("import-in-the-middle/hook.mjs", import.meta.url, registerOptions) + +const MAPLE_ENDPOINT = "https://ingest.maple.dev" // EU: https://ingest.eu.maple.dev +const MAPLE_KEY = "YOUR_INGEST_KEY" + +const sdk = new NodeSDK({ + serviceName: "acme-web-server", + traceExporter: new OTLPTraceExporter({ + url: `${MAPLE_ENDPOINT}/v1/traces`, + headers: { authorization: `Bearer ${MAPLE_KEY}` }, + }), + instrumentations: [ + getNodeAutoInstrumentations({ + // Hashed JS and CSS files: otherwise one trace per asset on every page load + "@opentelemetry/instrumentation-http": { + ignoreIncomingRequestHook: (request) => request.url?.startsWith("/_app/immutable/") ?? false, + }, + }), + ], +}) + +sdk.start() +``` + +The [Node.js guide](/docs/guides/instrumentation-nodejs) covers logs and metrics too, if you want them from the server. Install `@opentelemetry/api`, `@opentelemetry/sdk-node`, `@opentelemetry/auto-instrumentations-node`, `@opentelemetry/exporter-trace-otlp-proto`, and `import-in-the-middle` as `dependencies`, not `devDependencies`: `adapter-node` bundles devDependencies into the build and only leaves `dependencies` as imports, which is what the instrumentation needs to patch them. + +The `/_app/immutable/` filter matches SvelteKit's default `appDir`. Without it, the HTTP instrumentation starts a trace for every script and stylesheet the browser downloads. + +Every request then gets an HTTP server span with a `sveltekit.handle.root` span under it, which has the route in `http.route`, a `sveltekit.resolve` span for rendering, and a `sveltekit.load` span per `load` function. SvelteKit also reads an incoming `traceparent`, which is what connects the browser's `__data.json` requests to the server's spans. One thing to know when you query these: every load span has the same name, and `sveltekit.load.node_id` tells you which file it came from. + +### Hand the server's trace to the browser + +The last piece joins the first page load to the server render. `mapleHandle` is a `handle` hook that adds the trace context of SvelteKit's root span, `event.tracing.root`, to rendered pages in a `Server-Timing` header: + +```ts +// src/hooks.server.ts +import { mapleHandle } from "@maple-dev/browser/sveltekit/server" + +export const handle = mapleHandle +``` + +If you already have a `handle`, put `mapleHandle` first: `export const handle = sequence(mapleHandle, yourHandle)`, with `sequence` from `@sveltejs/kit/hooks`. + +The browser side needs nothing more: the `pageload` span becomes a child of `sveltekit.handle.root`. Only HTML responses get the header, and without server tracing, or before SvelteKit 2.31, `mapleHandle` passes every response through unchanged. + +`mapleHandle` also drops the `ETag` from the responses it adds the header to. SvelteKit adds an `ETag` to every page it renders without streaming, and answers a matching `If-None-Match` with a 304 that doesn't include the `server-timing` header. The browser then reuses the header it cached with the page, and a reload joins the trace of an earlier visit. Dropping the `ETag` costs you those 304s on HTML, which are rare for pages that render per request anyway. For the same reason, leave `mapleHandle` out for pages a CDN caches, or every visitor joins the same trace. + +## SvelteKit tracing gotchas + +- **Call `traceNavigation` in the root layout.** In a nested layout, it misses every navigation outside it, and stops when the layout unmounts. +- **Keep typed `load` functions `async`.** With `export const load: PageLoad`, a non-async arrow that returns `loadSpan(...)` can leave `data` typed as `{}`. The `async` version infers the real type. +- **The browser tracing code runs on the server too.** The root layout and universal loads run during server rendering, where the navigation hooks never fire and `loadSpan` only runs your function. It does nothing there, but adapters that bundle ship it in the server bundle. +- **Check your adapter.** `instrumentation.server.ts` only runs first if the adapter supports it. `adapter-node` does; check the docs of any other. +- **Measure the overhead.** SvelteKit's docs point out that tracing has a cost, and a span per `load` adds up on busy pages. +- **Node 26 warns about `module.register()`.** The ES module hook still works. `import-in-the-middle` also ships a synchronous `register-hooks.mjs` entry for newer Node versions. + +## What this setup doesn't cover + +- **`XMLHttpRequest`.** Only `fetch` is instrumented. Clients built on XHR, like axios by default, need `adapter: "fetch"` or OpenTelemetry's `XMLHttpRequestInstrumentation`. +- **Web Vitals.** The SDK doesn't record LCP, INP or CLS. +- **Readable stack traces.** Errors are grouped without bundle hashes and line numbers, so one bug stays one issue across deploys, but stacks show minified names. +- **Ad blockers.** Some block telemetry requests. If that matters for your users, point `endpoint` at a proxy on your own domain. +- **Trace sampling.** `replay.sampleRate` samples session recordings; browser traces are all sent. + +## FAQ + +### Does SvelteKit have built-in OpenTelemetry support? + +Yes, on the server. Since SvelteKit 2.31, `kit.experimental.tracing.server` emits spans for the `handle` hook, `load` functions, form actions, and remote functions, and `instrumentation.server.ts` is where you start the Node SDK. It's experimental, and there's no browser tracing, which is what the rest of this guide adds. + +### Why are my SvelteKit load spans not connected to the navigation? + +Usually because the load ran during a preload, when the user hovered a link, or because of `invalidate()`. Neither has a navigation to attach to. Server `load` functions in `+page.server.ts` are always in their own trace, since the browser requests their data outside any span. The other cause is a `fetch` after an `await` inside the load, which loses its parent span. + +### Does this work with a SvelteKit SPA or adapter-static? + +Yes. Everything before the server section works without a server. Without a `server-timing` header, the `pageload` span starts its own trace. + +## Next steps + +- [Frontend tracing overview](/docs/frontend): every framework guide. +- [Browser SDK reference](/docs/session-replay/browser-sdk): consent, masking and URL redaction. +- [Session replays](/docs/session-replay/replays): open the recording behind a trace. +- [Errors and issues](/docs/errors/overview): how reported errors are grouped into issues. +- [Instrument your application](/docs/instrumentation): backend guides, so browser traces continue into your services. diff --git a/apps/landing/src/content/docs/frontend/tanstack.md b/apps/landing/src/content/docs/frontend/tanstack.md new file mode 100644 index 0000000000..919a1cc0be --- /dev/null +++ b/apps/landing/src/content/docs/frontend/tanstack.md @@ -0,0 +1,267 @@ +--- +title: "Frontend tracing for TanStack Router and TanStack Start" +description: "Trace every TanStack Router navigation, loader and server render as one OpenTelemetry trace, linked to your backend and to session replay." +group: "Frontend" +order: 1 +navLabel: "TanStack" +icon: "tanstack" +--- + +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. This guide turns that into one trace per click, 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. + +## Quick setup with a coding agent + +Copy this prompt into Claude Code, Codex, Cursor or another 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. + +```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. +``` + +Use your public key from **Settings → Ingestion**. Without one, the agent uses a placeholder you can replace later. EU organizations should say EU region. + +## Install the browser SDK + +```bash +npm install @maple-dev/browser +``` + +This guide needs `@maple-dev/browser` 0.10.0 or later. + +```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, +}) +``` + +In a TanStack Router app, import `./maple` first in your client entry (`src/main.tsx`), before anything renders. In TanStack Start, import it at the top of the root route (`src/routes/__root.tsx`); `init()` does nothing during server rendering, so that's safe. + +`init()` sets up: + +- a span for every `fetch()` call; +- error spans for uncaught errors and unhandled promise rejections, which show up on the [Errors](/docs/errors/overview) page; +- session replay, with the same `session.id` on every span and replay event, so a trace links to the recording of the session that produced it; +- export every 2 seconds, plus a flush when the tab is hidden or closed, so the spans from the last moments of a visit aren't lost; +- redaction of credential-looking query parameters (`token`, `code`, `password` and similar) in every URL it sends. + +Use the public ingest key (`maple_pk_…`) from **Settings → Ingestion**. It can only write telemetry, so it's safe in browser code. For an EU organization, add `region: "eu"`. Every option is in the [Browser SDK reference](/docs/session-replay/browser-sdk). + +## Connect browser traces to your backend + +Each `fetch()` span sends a W3C `traceparent` header, and your backend's span joins the same trace. For requests to the page's own origin this happens automatically. For an API on another origin, list it: + +```ts +MapleBrowser.init({ + // ... + tracing: { + propagateTraceHeaderCorsUrls: [/^https:\/\/api\.acme\.com\//], + }, +}) +``` + +Then allow the header in the API's CORS configuration. Without it, the browser blocks the request after the preflight: + +```http +Access-Control-Allow-Headers: content-type, authorization, traceparent, tracestate +``` + +Only list your own APIs. Sending `traceparent` to third parties leaks your trace ids, and many of them reject the preflight. + +Your backend needs OpenTelemetry to read the header; every OpenTelemetry HTTP server instrumentation does. See [Instrument your application](/docs/instrumentation) for your backend's language or framework. + +Browser and server clocks disagree, so a server span can appear to start slightly before the `fetch` that caused it, and a laptop that slept can be minutes off. Durations are accurate; the offsets between browser and server spans are approximate. + +## Navigation and data-loading spans + +Out of the box, every `fetch()` is its own trace, so a navigation that makes three requests shows up as three unrelated traces. The SDK fixes that with a span per navigation, and the data-loading and `fetch` spans nested under it. Three calls do the work: + +- `MapleBrowser.startNavigation(path)` opens a `pageload` span for the first route and a `navigate` span for each one after it. If a navigation starts before the previous one ended, the previous span ends and is marked `app.navigation.interrupted`. +- `MapleBrowser.endNavigation(route)` names the span after the route template and ends it. +- `MapleBrowser.traced(name, fn, { isFailure })` runs data loading in a child span of the current navigation, and marks the span failed when `fn` throws, unless `isFailure` returns `false`. An error it recorded isn't reported a second time by `captureException` or the SDK's global error handlers. + +The TanStack integration below connects them to TanStack Router. + +Span names use the route template, like `navigate /projects/:id`, never the concrete URL. Maple groups by span name, so a template gives you one row with a real p95, while concrete URLs give you one row per project. The concrete path is still on the span as `url.path`. + +The first page load joins the server render's trace without any browser code: when the server sends its trace context in a `Server-Timing` header or a `` tag, the `pageload` span becomes part of that trace, and follows its sampling decision: a page load under a trace the server didn't sample isn't recorded. In a client-only app, it starts a trace of its own. The [Browser SDK reference](/docs/session-replay/browser-sdk#navigation-and-data-loading-spans) has the details. + +### The await problem + +In the browser, a span only stays active until the first `await` inside it. A request that starts after an `await` loses its parent and shows up as a separate trace. + +```ts +// ❌ fetchMembers starts after an await, so it becomes its own trace +MapleBrowser.traced("load project", async () => { + const project = await fetchProject(id) + const members = await fetchMembers(project.id) + return { project, members } +}) +``` + +```ts +// ✅ Save the context before the first await, and run later requests inside it +import { context } from "@opentelemetry/api" + +MapleBrowser.traced("load project", async () => { + const ctx = context.active() + const project = await fetchProject(id) + const members = await context.with(ctx, () => fetchMembers(project.id)) + return { project, members } +}) +``` + +`context` comes from `@opentelemetry/api`, so add it with `npm install @opentelemetry/api` if you use this pattern. The SDK already depends on it, but strict package managers like pnpm only resolve packages you list yourself. + +If the requests don't depend on each other, start them together with `Promise.all` instead. Both nest under the span, and the page stops waiting on one request before starting the next. + +This happens because browsers have no equivalent of Node's `AsyncLocalStorage`, which is what carries the active span across `await` on the server. + +## Trace TanStack Router navigations + +`traceRouter` subscribes to the router's lifecycle events and turns each navigation into a span. Call it once, right after you create the router. In TanStack Start, `src/router.tsx` exports a `getRouter()` function, which also runs on the server for every request; `traceRouter` does nothing there: + +```ts +// src/router.tsx +import { traceRouter } from "@maple-dev/browser/tanstack" +import { createRouter } from "@tanstack/react-router" +import { routeTree } from "./routeTree.gen" + +export function getRouter() { + const router = createRouter({ routeTree }) + traceRouter(router) + return router +} +``` + +In a client-only TanStack Router app that exports `const router = createRouter(...)`, call `traceRouter(router)` right after it. Either way, `MapleBrowser.init()` has to run first, which the import order above takes care of. + +What you get: + +- **Spans are named after the route's `fullPath`**, like `navigate /projects/$projectId`. That's the URL the user sees with the dynamic parts left as `$projectId`, without the pathless layouts and route groups that `routeId` includes. A URL no route matches, including one a layout matches but none of its children do, is named `not-found`, so 404 pages don't count as visits to your home page. With the deprecated `notFoundRoute` option, unmatched URLs are named after that route instead. +- **The first page load is covered.** `traceRouter` opens the `pageload` span as soon as it runs, and ends it when the first route renders, which also covers TanStack Start hydrating a server-rendered page. A click before the first route resolves ends the page load as interrupted. +- **Interrupted navigations are marked.** If the user clicks a second link before the first finishes loading, TanStack Router abandons the first one, and its span ends as interrupted. +- **Redirects stay in one span.** A `redirect()` thrown from `beforeLoad` or a loader starts a second navigation that replaces the current history entry. It stays in the span of the navigation it came from, which keeps the original `url.path` and is named after the route the user lands on. That includes a redirect during the first page load. +- **Search changes are navigations, hash links aren't.** Going from `/projects/1` to `/projects/1?tab=members` makes a `navigate /projects/$projectId` span, with a loader span only if the route's `loaderDeps` read the search. Back and forward are navigations. A link to `#section`, `router.invalidate()` and a link to the route on screen start no span, and end a navigation still in flight as interrupted. + +## Trace route loaders + +Loaders are where most of the time in a TanStack Router navigation goes, so each one gets its own span. `tracedLoader` is `MapleBrowser.traced` for TanStack Router: `redirect()` and `notFound()` are thrown, and it doesn't count them as failures: + +```ts +// src/routes/projects.$projectId.tsx +import { tracedLoader } from "@maple-dev/browser/tanstack" +import { createFileRoute } from "@tanstack/react-router" + +export const Route = createFileRoute("/projects/$projectId")({ + loader: ({ params }) => + tracedLoader("loader /projects/$projectId", () => + Promise.all([fetchProject(params.projectId), fetchMembers(params.projectId)]), + ), +}) +``` + +Wrap `beforeLoad` the same way if it does I/O or can throw. + +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 await problem](#the-await-problem) explains why, and how to pass the context along when a request really does depend on the previous one. + +A loader that turns an API 404 into `throw notFound()` leaves its span Ok. The `fetch` span that got the 404 is still marked as an error, like every 4xx client span. + +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 router renders the preloaded data right away and reloads it in the background, so the navigation span is very short and can end before its loader span does. + +## 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. + +The errors those boundaries catch go through the router's `defaultOnCatch` option. Report from there with `reportRouterError`: + +```ts +// src/router.tsx +import { reportRouterError, traceRouter } from "@maple-dev/browser/tanstack" +import { createRouter } from "@tanstack/react-router" +import { routeTree } from "./routeTree.gen" + +export function getRouter() { + const router = createRouter({ + routeTree, + defaultOnCatch: (error) => reportRouterError(router, error), + }) + traceRouter(router) + return router +} +``` + +A loader that throws ends up in the same boundary, and `reportRouterError` skips it, because its loader span already recorded it: in the browser, or on the server for the first page load, where the browser only receives a copy of the error. It recognizes loader errors by the route match they're stored on, so errors from a loader or `beforeLoad` that isn't wrapped in `tracedLoader` aren't reported at all. That's one more reason to wrap a `beforeLoad` that can throw. + +What's left are render errors. Each one is reported once, as a `react.render_error` span. + +TanStack Router only calls `defaultOnCatch` for routes that have an `errorComponent`, or when `defaultErrorComponent` is set, and a route's own `onCatch` replaces it. Errors that reach the router's global boundary aren't reported. + +## 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), in a `src/instrumentation.ts` file, and import it before anything else in your server entry. If your server process already loads the SDK with `node --import`, skip that import. That gives you spans for the HTTP calls your loaders make on the server. + +The Start scaffold doesn't include a server entry, so create `src/server.ts`. TanStack Start uses it instead of its default one: + +```ts +// src/server.ts +import "./instrumentation" // starts the OpenTelemetry Node SDK; must load first +import { traceRender, traceRequests } from "@maple-dev/browser/tanstack/server" +import { createStartHandler, defaultStreamHandler } from "@tanstack/react-start/server" +import { createServerEntry } from "@tanstack/react-start/server-entry" + +export default createServerEntry({ + fetch: traceRequests(createStartHandler(traceRender(defaultStreamHandler))), +}) +``` + +If your app already has a `src/server.ts`, wrap its `fetch` with `traceRequests` and its handler callback with `traceRender` the same way. + +- **`traceRequests` opens a span for every request**, so a page's server loaders and its render share a trace. It has to wrap `fetch`, because the router runs the loaders before it calls the handler callback. The span records the method, `url.path` and the status code, and it's only marked as an error for a 5xx response. If the server's HTTP instrumentation already opened a span for the request, it becomes a child of that span instead, so a request never gets two server spans. A request that carries a `traceparent`, like a server function called from the browser, joins that trace. +- **`traceRender` names the span after the route**, like `ssr /projects/$projectId`, and sends its trace context to the browser in a `Server-Timing` header. On the browser side there's nothing to add: the `pageload` span joins the `ssr` span, so the first page load becomes one trace with the request, the loaders that ran on the server, their fetches, your backend's spans, and the page load in the browser. +- **Requests that don't render a page**, like server functions and redirects, keep the HTTP method as their span name and get no `Server-Timing` header. +- **The span ends at the first byte.** `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. Deferred data that resolves while the page streams falls outside it. + +`tracedLoader` works on the server too, and better than in the browser: it nests under the request span, and Node has `AsyncLocalStorage`, so fetches after an `await` keep their parent. Server-side fetches get spans from OpenTelemetry's undici instrumentation, which the Node.js guide's auto-instrumentations include. The server span's `url.path` is the raw path, like OpenTelemetry's HTTP instrumentation records it: the browser's `sanitizeUrl` option doesn't apply on the server. + +If a CDN caches your HTML, it stores the `Server-Timing` header with it, and every visitor's page load joins the same old trace. Remove the header from responses the CDN caches. TanStack Start doesn't set an `ETag` on HTML, so a browser revalidation can't reuse an old header. + +## What this setup doesn't cover + +- **`XMLHttpRequest`.** Only `fetch` is instrumented. Clients built on XHR, like axios by default, need `adapter: "fetch"` or OpenTelemetry's `XMLHttpRequestInstrumentation`. +- **Web Vitals.** The SDK doesn't record LCP, INP or CLS. +- **Readable stack traces.** Errors are grouped without bundle hashes and line numbers, so one bug stays one issue across deploys, but stacks show minified names. +- **Ad blockers.** Some block telemetry requests. If that matters for your users, point `endpoint` at a proxy on your own domain. +- **Trace sampling.** `replay.sampleRate` samples session recordings; browser traces are all sent. + +## 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. `@maple-dev/browser/tanstack` uses the router's public events, plus the history index TanStack Router keeps in `location.state.__TSR_index` to recognize redirects. That index isn't documented API, and it has one side effect: a `replace` navigation started while another one is still loading lands on the same index, so it's merged into that span like a redirect. + +### 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. + +### Does this work 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, there's no `Server-Timing` header to join, and the `pageload` span starts its own trace. + +## Next steps + +- [Frontend tracing overview](/docs/frontend): every framework guide. +- [Browser SDK reference](/docs/session-replay/browser-sdk): consent, masking and URL redaction. +- [Session replays](/docs/session-replay/replays): open the recording behind a trace. +- [Errors and issues](/docs/errors/overview): how reported errors are grouped into issues. +- [Instrument your application](/docs/instrumentation): backend guides, so browser traces continue into your services. diff --git a/apps/landing/src/content/docs/frontend/vue.md b/apps/landing/src/content/docs/frontend/vue.md new file mode 100644 index 0000000000..db17335e46 --- /dev/null +++ b/apps/landing/src/content/docs/frontend/vue.md @@ -0,0 +1,398 @@ +--- +title: "Frontend tracing for Vue, Vue Router and Nuxt" +description: "Trace every Vue Router navigation, the data your pages load and the errors Vue catches as one OpenTelemetry trace, plus the server render in Nuxt." +group: "Frontend" +order: 4 +navLabel: "Vue & Nuxt" +icon: "vue" +--- + +Vue Router tells you exactly when a navigation starts and when it's confirmed. Most Vue apps load their data after that point, inside the new page's `setup`, and that's the one thing to design around when tracing a Vue app. This guide turns a click into one trace with a span for the navigation, a span for each piece of data the new page loads, and the fetches and backend spans behind them. In Nuxt, the first page load also includes the server render. The code is for Vue 3.5 and works the same with Vue Router 4 and 5. + +## Quick setup with a coding agent + +Copy this prompt into Claude Code, Codex, Cursor or another 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. + +```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 Vue with Vue Router (or Nuxt). + +My Maple public ingest key is maple_pk_... and my organization is in the US region. +``` + +Use your public key from **Settings → Ingestion**. Without one, the agent uses a placeholder you can replace later. EU organizations should say EU region. + +## Install the browser SDK + +```bash +npm install @maple-dev/browser +``` + +This guide needs `@maple-dev/browser` 0.10.0 or later. + +```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, +}) +``` + +Import `./maple` first in `src/main.ts`, before `createApp`. Nuxt has no `main.ts`; see [Nuxt](#tracing-nuxt-client-plugin-vueerror-and-the-server-timing-header) below. + +`init()` sets up: + +- a span for every `fetch()` call; +- error spans for uncaught errors and unhandled promise rejections, which show up on the [Errors](/docs/errors/overview) page; +- session replay, with the same `session.id` on every span and replay event, so a trace links to the recording of the session that produced it; +- export every 2 seconds, plus a flush when the tab is hidden or closed, so the spans from the last moments of a visit aren't lost; +- redaction of credential-looking query parameters (`token`, `code`, `password` and similar) in every URL it sends. + +Use the public ingest key (`maple_pk_…`) from **Settings → Ingestion**. It can only write telemetry, so it's safe in browser code. For an EU organization, add `region: "eu"`. Every option is in the [Browser SDK reference](/docs/session-replay/browser-sdk). + +## Connect browser traces to your backend + +Each `fetch()` span sends a W3C `traceparent` header, and your backend's span joins the same trace. For requests to the page's own origin this happens automatically. For an API on another origin, list it: + +```ts +MapleBrowser.init({ + // ... + tracing: { + propagateTraceHeaderCorsUrls: [/^https:\/\/api\.acme\.com\//], + }, +}) +``` + +Then allow the header in the API's CORS configuration. Without it, the browser blocks the request after the preflight: + +```http +Access-Control-Allow-Headers: content-type, authorization, traceparent, tracestate +``` + +Only list your own APIs. Sending `traceparent` to third parties leaks your trace ids, and many of them reject the preflight. + +Your backend needs OpenTelemetry to read the header; every OpenTelemetry HTTP server instrumentation does. See [Instrument your application](/docs/instrumentation) for your backend's language or framework. + +Browser and server clocks disagree, so a server span can appear to start slightly before the `fetch` that caused it, and a laptop that slept can be minutes off. Durations are accurate; the offsets between browser and server spans are approximate. + +## Navigation and data-loading spans + +Out of the box, every `fetch()` is its own trace, so a navigation that makes three requests shows up as three unrelated traces. The SDK fixes that with a span per navigation, and the data-loading and `fetch` spans nested under it. Three calls do the work: + +- `MapleBrowser.startNavigation(path)` opens a `pageload` span for the first route and a `navigate` span for each one after it. If a navigation starts before the previous one ended, the previous span ends and is marked `app.navigation.interrupted`. +- `MapleBrowser.endNavigation(route)` names the span after the route template and ends it. +- `MapleBrowser.traced(name, fn, { isFailure })` runs data loading in a child span of the current navigation, and marks the span failed when `fn` throws, unless `isFailure` returns `false`. An error it recorded isn't reported a second time by `captureException` or the SDK's global error handlers. + +The Vue Router integration below connects the first two to the router, and your pages call `traced` for their data. + +Span names use the route template, like `navigate /projects/:id`, never the concrete URL. Maple groups by span name, so a template gives you one row with a real p95, while concrete URLs give you one row per project. The concrete path is still on the span as `url.path`. + +The first page load joins the server render's trace without any browser code: when the server sends its trace context in a `Server-Timing` header or a `` tag, the `pageload` span becomes part of that trace, and follows its sampling decision: a page load under a trace the server didn't sample isn't recorded. In a client-only app, it starts a trace of its own. The [Browser SDK reference](/docs/session-replay/browser-sdk#navigation-and-data-loading-spans) has the details. + +### The await problem + +In the browser, a span only stays active until the first `await` inside it. A request that starts after an `await` loses its parent and shows up as a separate trace. + +```ts +// ❌ fetchMembers starts after an await, so it becomes its own trace +MapleBrowser.traced("load project", async () => { + const project = await fetchProject(id) + const members = await fetchMembers(project.id) + return { project, members } +}) +``` + +```ts +// ✅ Save the context before the first await, and run later requests inside it +import { context } from "@opentelemetry/api" + +MapleBrowser.traced("load project", async () => { + const ctx = context.active() + const project = await fetchProject(id) + const members = await context.with(ctx, () => fetchMembers(project.id)) + return { project, members } +}) +``` + +`context` comes from `@opentelemetry/api`, so add it with `npm install @opentelemetry/api` if you use this pattern. The SDK already depends on it, but strict package managers like pnpm only resolve packages you list yourself. + +If the requests don't depend on each other, start them together with `Promise.all` instead. Both nest under the span, and the page stops waiting on one request before starting the next. + +This happens because browsers have no equivalent of Node's `AsyncLocalStorage`, which is what carries the active span across `await` on the server. + +## Trace Vue Router navigations + +`traceRouter` hooks into the router's `beforeEach`, `afterEach` and `onError` and turns each navigation into a span. Call it right after creating the router. Guards run in the order they were added, so adding it first puts your own guards, and the requests an auth guard makes, inside the span: + +```ts +// src/router/index.ts +import { traceRouter } from "@maple-dev/browser/vue" +import { createRouter, createWebHistory } from "vue-router" +import { routes } from "./routes" + +const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes }) + +// Before any other guard, so the navigation span covers them too +traceRouter(router) + +export default router +``` + +The first navigation starts when `app.use(router)` installs the router, and becomes the `pageload` span. That span starts when your bundle runs, so it doesn't include downloading the HTML and JavaScript. + +The span ends once Vue has rendered the new route, on the `nextTick` after `afterEach`, not when `afterEach` runs. By then the new page's `setup`, its immediate watchers, and its `onMounted` hooks have run, so anything they start lands inside the span. + +### Route templates, redirects, and failed navigations + +- **The span is named after the deepest matched route**, parents included, with params as placeholders: `/projects/:id`, not `/projects/8f2a`. +- **Redirects stay in one span.** When a guard returns a different location, or a route record declares a `redirect`, the navigation stays in the span its original location opened. A redirect to the login page is one `navigate /login` span with the original path in `url.path`. +- **Aborted navigations end normally.** A guard that returns `false` ends the span, named after the route the user tried to reach. +- **Replaced navigations end as interrupted.** That covers a click on another link while a navigation is still loading its route chunk or waiting on a guard, and a link back to the page on screen while one is pending. Its route never resolved, so the span keeps the plain name `navigate`. Most Vue navigations resolve at once, though, and it's the page's data loading that outlives them. That load span keeps running in the old trace, which is what happened. +- **A link to the current page starts no span.** +- **Query and hash changes are navigations.** A search page that syncs its filters to the URL gets a span per change, and so does a plain `` link, because the router sees the browser's `popstate`. +- **Back and forward are navigations too.** They run the same guards, so each step is a `navigate` span, and the page loads its data again unless it's in ``. +- **Guard errors are reported.** An error thrown in a guard, or a route chunk that fails to load, is reported as `vue_router.error` and ends the span. Registering `router.onError` turns off Vue Router's own logging, so `traceRouter` logs these errors itself. If your own `onError` logs them too, you'll see them twice in the console. + +## Trace data loading in Vue components + +Vue Router doesn't load data for you. Most Vue apps fetch in the page component, in `setup`, `onMounted` or a watcher on the route params, and that code runs before the navigation span ends. Wrap it in `MapleBrowser.traced`, and name the span `loader` plus the route template, like the other framework guides: + +```vue + + + + +``` + +`immediate: true` runs the watcher during `setup`, so the first load nests under the navigation. Going from `/projects/1` to `/projects/2` reuses the component, and the watcher fires again while that navigation renders. + +The load span usually ends after its parent: the navigation span stops when the new page rendered, the load span when the data arrived. That's what the user saw, a page and then its content. As always, only requests started before the first `await` nest under the span (see [the await problem](#the-await-problem)). + +If you'd rather have the navigation wait for the data, fetch in a `beforeResolve` guard or the page's `beforeRouteEnter`. Those run inside the navigation, and `traced` works there unchanged. Vue Router 5 also has data loaders that do this (`defineBasicLoader` in `vue-router/experimental`), but they're experimental, and their `reroute()` redirects by throwing, which `traced` would record as a failure. + +### Aborted requests aren't failures + +Vue Router guards redirect by returning a location, not by throwing, so `traced` needs no `isFailure` for router code. Where it does matter is aborted requests. If the id changes again before the first request finished, you usually want to cancel it: + +```ts +// The watcher from above, with `onWatcherCleanup` imported from "vue" +watch( + () => route.params.id as string, + async (id) => { + const controller = new AbortController() + // Runs when the id changes again, or the page unmounts, before this request finished + onWatcherCleanup(() => controller.abort()) + + const aborted = () => controller.signal.aborted + try { + project.value = await MapleBrowser.traced("loader /projects/:id", () => fetchProject(id, controller.signal), { + isFailure: () => !aborted(), + }) + } catch (error) { + if (!aborted()) throw error + } + }, + { immediate: true }, +) +``` + +The aborted fetch rejects with an `AbortError`. `isFailure` keeps it from marking the span as failed, and the `catch` keeps it away from Vue's error handler. + +### Not-found pages + +Vue Router has no not-found error either. A common pattern is to load the data, and on a 404 replace the route with the catch-all route while keeping the URL. Throw your own error class for the 404, and use `isFailure` to keep it from counting as a failure: + +```ts +// Thrown by your API client on a 404 +class NotFoundError extends Error {} + +try { + project.value = await MapleBrowser.traced("loader /projects/:id", () => fetchProject(id), { + isFailure: (error) => !(error instanceof NotFoundError), + }) +} catch (error) { + if (!(error instanceof NotFoundError)) throw error + await router.replace({ + name: "not-found", + params: { pathMatch: route.path.substring(1).split("/") }, + query: route.query, + hash: route.hash, + }) +} +``` + +The replace is a second navigation, so it gets its own trace, named after the catch-all route: `navigate /:pathMatch(.*)*`. The `fetch` span that got the 404 is still marked as an error, like every client request with a 4xx status. + +## Report errors Vue catches with MapleVue + +Vue catches errors thrown in components, watchers, lifecycle hooks, and event handlers, and passes them to `app.config.errorHandler`. Without one, development builds rethrow the error, so the SDK's global handlers see it. + +Production builds only log it with `console.error`. That's how you end up with error reporting that works on your machine and reports nothing in production. `MapleVue` sets the handler: + +```ts +// src/main.ts +import "./maple" // first, before anything renders +import { MapleVue } from "@maple-dev/browser/vue" +import { createApp } from "vue" +import App from "./App.vue" +import router from "./router" + +const app = createApp(App) +app.use(MapleVue) +app.use(router) +app.mount("#app") +``` + +- Each error is reported as a `vue.error` span, with `vue.error.info` saying where it was thrown: `render function` or `watcher callback` in development, a link like `https://vuejs.org/error-reference/#runtime-1` in production. +- After reporting, the error goes back to Vue's own handling, so production builds still log it and development builds still warn and throw. If you set `app.config.errorHandler` before `app.use(MapleVue)`, your handler gets it instead. +- A failed `load project` records the error on its span and rethrows, and Vue passes the rejected watcher to the handler. `MapleVue` skips errors `traced` already recorded, so that stays one error. +- An `errorCaptured` hook that returns `false` stops the error before it reaches the handler. Report it from that hook with `reportVueError(error, instance, info)` from `@maple-dev/browser/vue`. +- Errors in navigation guards never reach the handler, which is why `traceRouter` reports them itself. + +## Tracing Nuxt: client plugin, vue:error, and the server-timing header + +Nuxt runs on Vue Router, so `traceRouter` works unchanged. What's different is where the code goes. Nuxt has no `main.ts`, so the SDK, the router tracing, and error reporting go in a client-only plugin: + +```ts +// app/plugins/maple.client.ts +// defineNuxtPlugin, useRouter and useRuntimeConfig are auto-imported +import { MapleBrowser } from "@maple-dev/browser" +import { reportVueError, traceRouter } from "@maple-dev/browser/vue" + +export default defineNuxtPlugin((nuxtApp) => { + MapleBrowser.init({ + // runtimeConfig.public.mapleIngestKey in nuxt.config, set with NUXT_PUBLIC_MAPLE_INGEST_KEY + ingestKey: useRuntimeConfig().public.mapleIngestKey as string, + serviceName: "acme-web", + }) + + traceRouter(useRouter()) + + // Nuxt calls this for every error a component throws + nuxtApp.hook("vue:error", reportVueError) +}) +``` + +Nuxt manages `app.config.errorHandler` itself, so use `reportVueError` from the `vue:error` hook instead of `MapleVue`. Nuxt calls that hook from its root component's `errorCaptured` hook for every component error, and keeps logging them to the console. + +A page whose template throws during a client-side navigation renders twice, once when it mounts and again when `` updates it, and each render throws a new error. You get two `vue.error` spans for it. On a direct page load, the same error happens during the server render instead: Nuxt shows its 500 page, and the client plugin never sees it. + +For data, wrap the function you pass to `useAsyncData`: + +```ts +// In a page's +``` + +That's all in the layouts. Don't add `data-route`, a navigation script, a `Tracing` component, or a hand-written `ssr` middleware: the integration covers them. + +- The integration adds a middleware (first in the chain, `order: "pre"`) that writes `Astro.routePattern` onto each page's `` (at build time for prerendered pages, while streaming for on-demand ones), and one bundled page script that calls `traceAstroNavigation()`. `MapleBrowser.init` stays in the user's script: its options can hold functions and `import.meta.env` values. +- Keep the `init` ` +``` + +## Data loading + +- Frontmatter runs on the server (on-demand pages) or at build time (prerendered). There is no client loader to wrap; the browser never sees that data loading. Server-side, it nests under the integration's `ssr` span. +- Islands (`client:*` components) fetch after hydration, which starts after `load` in testing, so their `fetch` spans are their own traces. Don't wrap them in `traced`. +- Island data loading that fails: a rejection nothing catches reaches the SDK as one `browser.unhandled_rejection` span with the `exception` event. An island that catches it to show an error state hides it from the SDK: add `MapleBrowser.captureException(error)` in that `catch` (one `exception` span). There is no `loader` span, so for `SKILL.md` Step 7's "data loading throws" check, expect one of those two spans instead. +- Server islands (`server:defer`) are fetched by an inline script Astro adds to the page. In testing those requests carried no `traceparent`, on the first load (the inline script runs before the SDK starts) and after a swap: each server island render is its own server trace (`ssr /_server-islands/[name]`). +- `is:inline` scripts also run before the bundled scripts; requests they make on load aren't traced. + +## Caught errors + +Astro has no client error hook. What reaches Maple: + +- Uncaught errors in islands and scripts: the SDK's global handler (`browser.uncaught_error`). A React 19 island that throws while rendering, with no boundary, lands here. +- Island code that fails to load (chunk 404 after a deploy, network): Astro catches it, retries once, then dispatches `astro:hydration-error` (Astro 6.3+). The integration reports it as `astro.hydration_error`; add nothing. +- Errors the island framework catches: report from its own boundary, once per framework used. + - React: `componentDidCatch(error)` in the app's error boundary, `MapleBrowser.captureException(error, { name: "react.render_error" })`. React calls it once; the error doesn't also reach the global handler. + - Vue: production builds only `console.error` component errors. Set `app.config.errorHandler` (as in `frameworks/vue.md`) from the file passed to `vue({ appEntrypoint: "/src/vue-app" })`; the file default-exports `(app: App) => void`. Keep an existing `appEntrypoint`. + - Svelte: ` MapleBrowser.captureException(error)}>`. Solid: in the `ErrorBoundary` fallback. + +## Server side + +### Static output (default, no adapter) + +Skip this section. No server runs at request time, and the `pageload` span starts its own trace. The integration still writes the route into each page at build time. + +### On-demand rendering (an adapter, with `output: "server"` or `export const prerender = false` pages) + +The integration's middleware runs every on-demand request in an `ssr ` span and appends `Server-Timing` to its HTML response; the browser's `pageload` becomes its child. Nothing to add in `src/middleware.ts`; an existing one keeps working (the integration's runs first). + +- The span ends when Astro starts streaming: after the page's own frontmatter, before the components inside it render. Their server requests still nest under it (AsyncLocalStorage) but run past its end. The HTTP server span covers the whole response. +- An error thrown in the page's frontmatter: recorded on the span (Error), 500 response, no header. A component deeper in the page that throws after streaming started produces a 200 with `Internal server error` appended, and nothing records it. Say so in the hand-off if the app renders data-fetching components. +- Endpoints and server islands get the span too (`ssr /api/...`, `ssr /_server-islands/[name]`), no header (not HTML). +- Skipped automatically, so cached copies don't share one trace: Astro 7 route-cached pages (`maxAge` or `swr`), `Cache-Control` with `public`, `s-maxage` or `max-age` > 0, `CDN-Cache-Control` and vendor variants, `Surrogate-Control` (unless `no-store`/`private`). A CDN that caches by its own rule without those headers isn't detected: tell the user to send one of them on those pages. Astro sets no `ETag` on on-demand HTML. +- `Astro.rewrite()` runs the middleware twice: two nested `ssr` spans and two `traceparent` entries in `Server-Timing`; the browser joins the inner one, same trace. + +Server OpenTelemetry, by adapter: + +- `@astrojs/node`: Node SDK per `maple-nodejs-style`, preloaded: `node --import ./instrumentation.mjs ./dist/server/entry.mjs` (update the `start` script or Dockerfile). The server build is ES modules: `register("@opentelemetry/instrumentation/hook.mjs", import.meta.url)` before `sdk.start()` is required. Without it `node:http` isn't patched: no HTTP server span, and an incoming `traceparent` isn't continued, so `` page fetches don't join the click's trace. Ignore Astro's hashed assets in the HTTP instrumentation, or every script and stylesheet gets a trace: `"@opentelemetry/instrumentation-http": { ignoreIncomingRequestHook: (request) => request.url?.startsWith("/_astro/") ?? false }` (`build.assets` if the project changes it). Install the OpenTelemetry packages as `dependencies`: the preload file isn't bundled. Prerendered pages and `public/` files the adapter serves still get a lone HTTP server span each, in a trace of its own; their `pageload` doesn't join it (no header). Node 26 prints a deprecation warning for `module.register()`; it still works. +- `@astrojs/cloudflare`: follow Maple's Cloudflare Workers guide (Workers Observability OTLP export, https://maple.dev/docs/guides/instrumentation-cloudflare-workers). Worker code can't read those spans' trace ids yet, and no OpenTelemetry provider is registered in the Worker, so the middleware sends no header (it still writes the route). The `pageload` span starts its own trace. Say so in the hand-off. +- Other adapters (Vercel, Netlify, Deno): follow that runtime's Maple guide. The page load joins the server trace only if an OpenTelemetry SDK is registered in the server process. + +## Check + +With the production build (`astro build`, then the adapter's start command; `astro preview` for static output), in addition to `SKILL.md` Step 7: + +- Every page's HTML has ``. +- Full load of an on-demand page: one `pageload