Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
e689795
docs(blog): frontend tracing guide (draft)
JeremyFunk Sep 28, 2026
9a1d380
docs(blog): SSR tracing section, figure matches the intro
JeremyFunk Sep 28, 2026
0ed45e3
docs(blog): split frontend tracing guide into a hub and a TanStack page
JeremyFunk Sep 28, 2026
e4809c1
feat(skills): maple-frontend-tracing skill, agent quick-setup prompts…
JeremyFunk Sep 28, 2026
158e0ee
docs(blog): clearer start-here paths in the frontend tracing intro
JeremyFunk Sep 28, 2026
345ab80
docs(blog): SvelteKit, Angular and Vue frontend tracing guides and sk…
JeremyFunk Sep 28, 2026
10c43b6
docs(blog): React Router frontend tracing guide and skill reference
JeremyFunk Sep 28, 2026
bf4deb8
docs(blog): Next.js frontend tracing guide, skill reference, meta tra…
JeremyFunk Sep 28, 2026
c7aeac7
docs(frontend): move frontend tracing to docs with an overview and se…
JeremyFunk Sep 28, 2026
c30d379
docs(frontend): longer TanStack card hint so the grid rows line up
JeremyFunk Sep 28, 2026
01465e1
docs(frontend): framework icons in the docs sidebar
JeremyFunk Sep 28, 2026
19243a6
docs(frontend): SvelteKit fixes from an end-to-end skill test; error-…
JeremyFunk Sep 28, 2026
95c9b1e
docs(frontend): Vue and Nuxt fixes from an end-to-end skill test
JeremyFunk Sep 28, 2026
1d2bdb2
docs(frontend): generic-router fixes from a Solid Router skill test; …
JeremyFunk Sep 28, 2026
ed53046
docs(frontend): TanStack Start fixes from an end-to-end skill test; A…
JeremyFunk Sep 28, 2026
6e086c0
docs(frontend): React Router framework-mode fixes from an end-to-end …
JeremyFunk Sep 28, 2026
6dc13f8
docs(frontend): Next.js fixes from an end-to-end skill test; exporter…
JeremyFunk Sep 28, 2026
333b58b
docs(frontend): Angular fixes from an end-to-end skill test; install …
JeremyFunk Sep 28, 2026
dc76f9a
docs(frontend): Astro guide and skill reference
JeremyFunk Sep 28, 2026
e857392
docs(frontend): Astro fixes from an end-to-end skill test
JeremyFunk Sep 28, 2026
3a09686
fix(skills): let maple-frontend-tracing open a PR when the user asks …
JeremyFunk Sep 28, 2026
7fd67a4
docs(frontend): split the await problem into wrong and right examples
JeremyFunk Sep 28, 2026
2d17c0e
Merge remote-tracking branch 'origin/main' into feat/frontend-observa…
JeremyFunk Sep 28, 2026
e736d01
docs(frontend): use the SDK's navigation API and framework entries in…
JeremyFunk Sep 29, 2026
30dbcc1
feat(skills): maple-frontend-tracing uses the SDK's framework entries
JeremyFunk Sep 29, 2026
4ae5b08
docs(frontend): Angular guide uses @maple-dev/browser/angular
JeremyFunk Sep 29, 2026
c241030
docs(frontend): Astro guide uses the maple() integration
JeremyFunk Sep 29, 2026
47ddd80
docs(frontend): list Angular and Astro integrations, drop first-perso…
JeremyFunk Sep 29, 2026
ef9adba
feat(skills): Angular and Astro references use the SDK integrations
JeremyFunk Sep 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions apps/landing/src/components/docs/DocsCategoryIcon.astro
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,17 @@ const base = {
</svg>
)}

{name === "Frontend" && (
<svg class={className} {...base}>
<path d="M3 4H21V20H3V4Z" />
<path d="M3 8H21" />
<path d="M6 6.01V6" />
<path d="M9 6.01V6" />
<path d="M8 13L6 15L8 17" />
<path d="M16 13L18 15L16 17" />
</svg>
)}

{name === "Session Replay" && (
<svg class={className} {...base}>
<path d="M4 6L4 6.01" />
Expand Down
5 changes: 4 additions & 1 deletion apps/landing/src/components/docs/DocsSidebar.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 ? (
<LanguageLogo id={doc.data.sdk} class="h-3.5 w-3.5 shrink-0" />
) : (
doc.data.icon && <BrandMarkIcon id={doc.data.icon} class="h-3.5 w-3.5 shrink-0" />
)}
<span class="truncate">{label(doc)}</span>
</a>
Expand Down
229 changes: 229 additions & 0 deletions apps/landing/src/components/docs/FrontendTrace.astro
Original file line number Diff line number Diff line change
@@ -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})`;
}
---

<figure class="ftrace">
<div class="ftrace__frame">
<div class="ftrace__head">
<span class="ftrace__title">One click on a project link · 840ms · browser to API</span>
</div>
<div class="ftrace__panel">
<div class="ftrace__scroll">
<div class="ftrace__grid">
{rows.map((r, i) => {
const startPct = (r.startMs / TOTAL_MS) * 100;
const widthPct = (r.durationMs / TOTAL_MS) * 100;
return (
<div class="ftrace__row" style={`--i:${i}`}>
<span class="ftrace__svc" style={`padding-left:${r.depth * INDENT_PX + 4}px`}>
{Array.from({ length: r.depth }).map((_, d) => (
<span class="ftrace__connector" style={`left:${d * INDENT_PX + 4}px`} aria-hidden="true" />
))}
{r.service}
</span>
<span class="ftrace__op">{r.op}</span>
<span class="ftrace__track">
<span
class="ftrace__bar"
style={`left:${startPct}%; width:${widthPct}%; --service-color:${serviceColor(r.service)};`}
/>
{r.note && <span class="ftrace__slowest">{r.note}</span>}
</span>
<span class="ftrace__dur">{r.durationMs}ms</span>
</div>
);
})}
</div>
</div>
<p class="ftrace__note">
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.
</p>
</div>
</div>
<figcaption class="ftrace__cap">
The <code>acme-web</code> rows come from the browser, the <code>acme-api</code> rows from the backend. They are
one trace because every fetch sent a <code>traceparent</code> header.
</figcaption>
</figure>

<style>
.ftrace {
margin: 40px 0;
}

.ftrace__frame {
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
background: color-mix(in oklab, var(--bg-elevated) 30%, transparent);
}

.ftrace__head {
display: flex;
align-items: center;
padding: 9px 14px;
background: var(--bg-elevated);
border-bottom: 1px solid var(--border);
}
.ftrace__title {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted-foreground);
}

.ftrace__panel {
padding: 10px 6px 0;
}

.ftrace__scroll {
overflow-x: auto;
}
.ftrace__grid {
min-width: 660px;
}

/* ── Waterfall rows ─────────────────────────────────────── */
.ftrace__row {
display: grid;
grid-template-columns: 14ch minmax(22ch, 30ch) minmax(0, 1fr) 6.5ch;
align-items: center;
gap: 12px;
padding: 6px 10px;
border-bottom: 1px solid color-mix(in oklab, var(--border) 60%, transparent);
}
.ftrace__row:last-child {
border-bottom: none;
}
.ftrace__svc {
position: relative;
font-family: var(--font-mono);
font-size: 12px;
color: var(--foreground);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ftrace__connector {
position: absolute;
top: -7px;
bottom: -7px;
width: 1px;
background: color-mix(in oklab, var(--border) 70%, transparent);
pointer-events: none;
}
.ftrace__row:last-child .ftrace__connector {
bottom: 50%;
}
.ftrace__op {
font-family: var(--font-mono);
font-size: 12px;
color: var(--muted-foreground);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ftrace__track {
position: relative;
height: 8px;
background: color-mix(in oklab, var(--bg-elevated) 80%, transparent);
border-radius: 2px;
}
.ftrace__bar {
position: absolute;
top: 0;
bottom: 0;
background: var(--service-color, var(--primary));
border-radius: 2px;
transform-origin: left center;
animation: ftrace-grow 460ms cubic-bezier(0.22, 1, 0.36, 1) both;
animation-delay: calc(var(--i, 0) * 28ms);
}
.ftrace__slowest {
position: absolute;
right: 0;
top: 12px;
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--primary);
white-space: nowrap;
}
.ftrace__row:has(.ftrace__slowest) {
padding-bottom: 22px;
}
.ftrace__dur {
font-family: var(--font-mono);
font-size: 11.5px;
color: var(--muted-foreground);
text-align: right;
font-variant-numeric: tabular-nums;
}

.ftrace__note {
margin: 10px 8px 0;
padding: 8px 10px 12px;
font-size: 13px;
line-height: 1.6;
color: var(--muted-foreground);
border-top: 1px dashed color-mix(in oklab, var(--border) 80%, transparent);
}

.ftrace__cap {
font-family: var(--font-mono);
font-size: 12px;
color: var(--muted-foreground);
margin-top: 10px;
}

@keyframes ftrace-grow {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}

@media (prefers-reduced-motion: reduce) {
.ftrace__bar {
animation: none;
}
}
</style>
12 changes: 7 additions & 5 deletions apps/landing/src/components/docs/GuideGrid.astro
Original file line number Diff line number Diff line change
@@ -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}`);
---

Expand Down
4 changes: 4 additions & 0 deletions apps/landing/src/content.config.ts
Original file line number Diff line number Diff line change
@@ -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"

Expand Down Expand Up @@ -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(),
}),
})

Expand Down
63 changes: 63 additions & 0 deletions apps/landing/src/content/docs/frontend.mdx
Original file line number Diff line number Diff line change
@@ -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:

<FrontendTrace />

## 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.

<GuideGrid cards={FRONTEND_GUIDES} />

## 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.
Loading
Loading