diff --git a/apps/landing/src/content/docs/session-replay/browser-sdk.md b/apps/landing/src/content/docs/session-replay/browser-sdk.md
index 020fe71f9..ba9007aa1 100644
--- a/apps/landing/src/content/docs/session-replay/browser-sdk.md
+++ b/apps/landing/src/content/docs/session-replay/browser-sdk.md
@@ -251,7 +251,94 @@ MapleBrowser.endNavigation("/projects/:id")
The browser has no async context: only requests `fn` starts before its first `await` nest under its span. Start independent requests together, with `Promise.all`.
-All three do nothing on the server, before `init()`, with tracing disabled or before consent is granted; `traced` then only runs `fn`. A page load that happened before consent isn't traced later: the next navigation is a `navigate` span. Leaving the page or calling `shutdown()` ends an open navigation as interrupted, so it still exports.
+In the browser, all three do nothing before `init()`, with tracing disabled or before consent is granted; `traced` then only runs `fn`. A page load that happened before consent isn't traced later: the next navigation is a `navigate` span. Leaving the page or calling `shutdown()` ends an open navigation as interrupted, so it still exports.
+
+On the server, `startNavigation` and `endNavigation` do nothing, and `traced` works like the server version below.
+
+## Server-side data loading
+
+`@maple-dev/browser/server` is for server code: Server Components, SSR loaders and resolvers, and your framework's response hook. It depends only on `@opentelemetry/api`, so it runs in Node, edge runtimes and Workers.
+
+```ts
+import { serverTiming, traced } from "@maple-dev/browser/server"
+
+// a span under the active server span, like the request or render span
+const project = await traced("db.query project", () => db.project.find(id))
+
+// in the hook that sets response headers for rendered pages
+const value = serverTiming()
+if (value) headers.append("server-timing", value)
+```
+
+- `traced(name, fn, options?)` runs `fn` in a span from the global tracer your server registered (`@vercel/otel`, the OpenTelemetry Node SDK), under the active span. The server keeps the parent across `await`, so requests `fn` makes after an `await` nest under it too. It takes the same `isFailure` option as the browser version, and records an error once. Without server OpenTelemetry it only runs `fn`.
+- `serverTiming()` returns the `Server-Timing` value for the active span, `traceparent;desc="00-…"`, or `undefined` when no span is active. Sent on the HTML response, it joins the browser's `pageload` span to the server's trace. Leave it off responses a shared cache stores, or every visitor joins the same trace. The browser follows the server's sampling decision: a page load under an unsampled server trace isn't recorded.
+
+## Next.js integration
+
+`@maple-dev/browser/nextjs` connects the App Router (Next.js 15.3 or later) to the navigation spans, and `@maple-dev/browser/nextjs/server` joins the first page load to the server render. Start and end the spans:
+
+```ts
+// src/instrumentation-client.ts
+import { MapleBrowser } from "@maple-dev/browser"
+
+MapleBrowser.init({ ingestKey: "maple_pk_...", serviceName: "acme-web" })
+
+// Next.js only reports client-side navigations, so the page load starts here
+MapleBrowser.startNavigation(location.pathname)
+
+export { onRouterTransitionStart } from "@maple-dev/browser/nextjs"
+```
+
+```tsx
+// src/app/layout.tsx
+import { MapleNavigation } from "@maple-dev/browser/nextjs"
+
+export default function RootLayout({ children }: { children: React.ReactNode }) {
+ return (
+
+
+
+ {children}
+
+
+ )
+}
+```
+
+Report what your error boundaries catch, in `error.tsx` and `global-error.tsx`:
+
+```tsx
+// src/app/error.tsx
+"use client"
+
+import { reportNextError } from "@maple-dev/browser/nextjs"
+import { useEffect } from "react"
+
+export default function ErrorPage({ error }: { error: Error & { digest?: string } }) {
+ useEffect(() => reportNextError(error), [error])
+ return
Something went wrong
+}
+```
+
+And pass the server's trace to the render and the browser from `proxy.ts` (`middleware.ts` before Next.js 16):
+
+```ts
+// src/proxy.ts
+import { withMapleProxy } from "@maple-dev/browser/nextjs/server"
+
+export const proxy = withMapleProxy() // or withMapleProxy(yourProxy) to keep your own logic
+
+export const config = {
+ matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
+}
+```
+
+- Spans are named after the route template, like `navigate /projects/[id]`, rebuilt from `useParams()`. A URL no route matches is named `/_not-found`.
+- Render `` once, in the root layout, above `{children}`. It ends the span when the new route commits, so a route with `loading.tsx` ends it when the skeleton appears.
+- Hash links and links to the URL on screen start no span. A query change is a navigation.
+- `reportNextError` skips errors with a `digest`. Those are Server Component errors with the message stripped, which Next.js already recorded on its server span. It also skips errors `traced` already recorded.
+- `withMapleProxy` keeps a `traceparent` the request already carries, as client navigations do, and only changes responses that go on to a render in your app. Redirects and responses your proxy builds itself pass through unchanged. If a shared cache such as a CDN stores prerendered pages, leave them out of the matcher, or every visitor joins the same trace.
+- Server Components can time database and SDK calls with `traced` from `@maple-dev/browser/server`. Pass `isFailure: () => false`: Next.js records an error thrown from a Server Component on its render span, and `redirect()` and `notFound()` work by throwing.
## Custom events
@@ -424,6 +511,8 @@ export default function RootLayout({ children }: { children: React.ReactNode })
}
```
+For navigation spans, error boundaries and joining the page load to the server render, see [Next.js integration](#nextjs-integration).
+
## Verify
Load a page with the SDK installed, click around for a few seconds, then leave the tab.
@@ -444,7 +533,7 @@ Load a page with the SDK installed, click around for a few seconds, then leave t
## Notes
- Replay recordings are stored as compressed blobs. Only small, queryable metadata is indexed, and playback streams the blobs through signed URLs.
-- The SDK is browser-only and best-effort. Telemetry network failures never surface to your application.
+- The SDK is best-effort. Telemetry network failures never surface to your application. It records in the browser; the `/server` entries only add spans to the OpenTelemetry setup your server already has.
## Next steps
diff --git a/bun.lock b/bun.lock
index ad4c665d8..a6e01e8b5 100644
--- a/bun.lock
+++ b/bun.lock
@@ -653,12 +653,26 @@
},
"devDependencies": {
"@maple/browser-session": "workspace:*",
+ "@opentelemetry/context-async-hooks": "^2.11.0",
+ "@types/react": "catalog:react",
+ "@types/react-dom": "catalog:react",
"@vitest/browser-playwright": "catalog:",
+ "next": "^16.3.6",
"playwright": "catalog:",
+ "react": "catalog:react",
+ "react-dom": "catalog:react",
"tsdown": "^0.23.0",
"typescript": "catalog:tooling",
"vitest": "catalog:",
},
+ "peerDependencies": {
+ "next": "*",
+ "react": "*",
+ },
+ "optionalPeers": [
+ "next",
+ "react",
+ ],
},
"packages/browser-session": {
"name": "@maple/browser-session",
@@ -1639,6 +1653,24 @@
"@neon-rs/load": ["@neon-rs/load@0.0.4", "", {}, "sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw=="],
+ "@next/env": ["@next/env@16.3.6", "", {}, "sha512-x9Vblze1EbtltQYnNH38xCPWU3TVfBd1eXqA3+w9+BTpedkkdNpAaltXlGQ/nsc1+E0mVTNrtcbX3GoO09zeLQ=="],
+
+ "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.3.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-E/7GEqaUkt8mk/T8v9lAnrhzR06kdq1ZBkC12F8tAMkdIadwNp3H1KqHynDHrpcTlGCUdq/qu6vUL2aYVyYBdw=="],
+
+ "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.3.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-yBE893/nDWTlaiBD1p+qgt7NUen4U5R6FXyH0s67Npq1S3E0cVSef1WIXC2xBRgQvwAvJq6DnS6Y6PrY0cy4Ew=="],
+
+ "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.3.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-KJDpjBqBPYlvkivmyrp+Qys6k/7ksbqGQvRVc6ZEGfR+cjQxx+nUkJaWmNZJsmoOrqYNbaXByF8wa0lBwDhB3Q=="],
+
+ "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.3.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-mqNg2K+hvWskSRb/QM+Ix412DvBsuSF0XV+frTSw5vmoucNnIlynFwKYew8D01bfATErMOM7Bujrf0BA5DRKFA=="],
+
+ "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.3.6", "", { "os": "linux", "cpu": "x64" }, "sha512-nFncBNGAYouRHjRVaITs9beZRfhX4ssVwpnvPIAbkZVH6LtGoAVlH4bJ8Cnf9SOo9bsXgPFer/GdHtEE3JNOkw=="],
+
+ "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.3.6", "", { "os": "linux", "cpu": "x64" }, "sha512-5Mf3cHDGR/Iz0ng2Bj3zUR3p5QS9YK3Hn2QiAfavFmyF48zwThAjpFoiTKNIcOHLYS4zEk+gzyJ/9deQ2ZB8yQ=="],
+
+ "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.3.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-0jkJy0C2kbrJWTk4YLa3xk80pVBpx8FCHJym7CnUfDAXe/FWv5qT7SQJbR0KuemyxaEDlEx5WT4VQJoTW+/9Qw=="],
+
+ "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.3.6", "", { "os": "win32", "cpu": "x64" }, "sha512-/YXjI1e5OXcZ7YpxRwgP/1jAV/SBKTzeVKqN2mk7mLpcICsyn3Gl5+dIfDTJp70M0ccMhyMMRso4v6mPDCGepg=="],
+
"@nodable/entities": ["@nodable/entities@3.0.0", "", {}, "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw=="],
"@octokit/auth-token": ["@octokit/auth-token@6.0.0", "", {}, "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w=="],
@@ -1675,6 +1707,8 @@
"@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.222.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-9mb1If+IF6u0ZVXkHQ6ogEae5HwA6ajIVUgpSDQyRASxft6BSXHvBvPooRle3yFN/fKnCdSOnuu0OC3PLcF6+g=="],
+ "@opentelemetry/context-async-hooks": ["@opentelemetry/context-async-hooks@2.11.0", "", { "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-Tr79DyWI8itsBdg+jH+opjfrwLzX+erk1/ExkIwhWoAVjVrJIn2y5+cGjTC0Vy8fyNIA/y8wuJPZwr1T3xCZeQ=="],
+
"@opentelemetry/core": ["@opentelemetry/core@2.11.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-7YP44XH0tV6+Mb54x2YGf84i7yi+31MBZlE8JwvozkxyTvXbSp10X7cI7YE49ChJ3shMJoBmCJF3+1QFBJctGA=="],
"@opentelemetry/exporter-trace-otlp-http": ["@opentelemetry/exporter-trace-otlp-http@0.222.0", "", { "dependencies": { "@opentelemetry/otlp-exporter-base": "0.222.0", "@opentelemetry/otlp-transformer": "0.222.0", "@opentelemetry/sdk-trace": "2.11.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-RCnPWcHppwiquQ+cV3nWvNwdf0MG1w26e5jewW2T83nTZOlXgcg88sY9ulCVgagGHcq1mj0L0GP6YHgzk2v8oA=="],
@@ -2709,6 +2743,8 @@
"cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="],
+ "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
+
"clipboard-image": ["clipboard-image@0.1.0", "", { "dependencies": { "run-jxa": "^3.0.0" }, "bin": { "clipboard-image": "cli.js" } }, "sha512-SWk7FgaXLNFld19peQ/rTe0n97lwR1WbkqxV6JKCAOh7U52AKV/PeMFCyt/8IhBdqyDA8rdyewQMKZqvWT5Akg=="],
"clipboardy": ["clipboardy@5.3.2", "", { "dependencies": { "clipboard-image": "^0.1.0", "execa": "^9.6.1", "is-wayland": "^0.1.0", "is-wsl": "^3.1.0", "is64bit": "^2.0.0", "powershell-utils": "^0.2.0" } }, "sha512-R35PENCHFCw6lsd5SjYPuAVV3Zawr74mKc7ogFNzoDPoQsmWDoJgUNNnWCk/czeqdZGZs8Y0M8zkOlVoySfHEQ=="],
@@ -3567,6 +3603,8 @@
"neotraverse": ["neotraverse@1.0.1", "", {}, "sha512-WmmLty1YWwJl9yZi77v2dVIV6X2kuYV8YYBI/G3LWGKdGHmHUvL1z7FW0iDvEvGAwNEoc5x1tOOOyDnf5jJw/w=="],
+ "next": ["next@16.3.6", "", { "dependencies": { "@next/env": "16.3.6", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.3.6", "@next/swc-darwin-x64": "16.3.6", "@next/swc-linux-arm64-gnu": "16.3.6", "@next/swc-linux-arm64-musl": "16.3.6", "@next/swc-linux-x64-gnu": "16.3.6", "@next/swc-linux-x64-musl": "16.3.6", "@next/swc-win32-arm64-msvc": "16.3.6", "@next/swc-win32-x64-msvc": "16.3.6", "sharp": "^0.35.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-L+otWM/aQbYTx98aZhgEoMb4bZAXx1YVW4UMA/vuCyCoWG5HJyZUili8QAkqzrcC+5///tsz3s0M+SlyB5bLMw=="],
+
"next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="],
"nlcst-to-string": ["nlcst-to-string@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0" } }, "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA=="],
@@ -3699,7 +3737,7 @@
"portless": ["portless@0.14.0", "", { "os": [ "linux", "win32", "darwin", ], "bin": { "portless": "dist/cli.js" } }, "sha512-kKxmxB1DQ9gI8t+V13VhuwTXu31io5nbFubvZAE82AxzXBsvcJV7qQJbmomrQUpbVxo2UVHqs14iX4YK0BClmQ=="],
- "postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="],
+ "postcss": ["postcss@8.5.23", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg=="],
"postcss-calc": ["postcss-calc@10.1.1", "", { "dependencies": { "postcss-selector-parser": "^7.0.0", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.4.38" } }, "sha512-NYEsLHh8DgG/PRH2+G9BTuUdtf9ViS+vdoQ0YA5OQdGsfN4ztiwtDWNtBl9EKeqNMFnIu8IKZ0cLxEQ5r5KVMw=="],
@@ -4017,6 +4055,8 @@
"style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
+ "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "@babel/core": "*", "babel-plugin-macros": "*", "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" }, "optionalPeers": ["@babel/core", "babel-plugin-macros"] }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="],
+
"stylehacks": ["stylehacks@9.0.4", "", { "dependencies": { "browserslist": "^4.29.0", "postcss-selector-parser": "^7.1.6" }, "peerDependencies": { "postcss": "^8.5.28" } }, "sha512-9ZZtqFGNsw03QhFLAXmyNdxs2gSQgDy/2yhhfLpsELxlh5Iry3Cl7TBfI4je3JUccr4EQmO5ikZZGvNX4e6oVA=="],
"stylis": ["stylis@4.4.0", "", {}, "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA=="],
@@ -4381,6 +4421,8 @@
"@maizzle/framework/picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
+ "@maizzle/framework/postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="],
+
"@maizzle/framework/sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
"@maizzle/framework/tinyexec": ["tinyexec@1.3.1", "", {}, "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA=="],
@@ -4435,6 +4477,8 @@
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+ "@tailwindcss/postcss/postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="],
+
"@tanstack/devtools-bundler-core/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"@tanstack/devtools-bundler-core/oxc-parser": ["oxc-parser@0.120.0", "", { "dependencies": { "@oxc-project/types": "^0.120.0" }, "optionalDependencies": { "@oxc-parser/binding-android-arm-eabi": "0.120.0", "@oxc-parser/binding-android-arm64": "0.120.0", "@oxc-parser/binding-darwin-arm64": "0.120.0", "@oxc-parser/binding-darwin-x64": "0.120.0", "@oxc-parser/binding-freebsd-x64": "0.120.0", "@oxc-parser/binding-linux-arm-gnueabihf": "0.120.0", "@oxc-parser/binding-linux-arm-musleabihf": "0.120.0", "@oxc-parser/binding-linux-arm64-gnu": "0.120.0", "@oxc-parser/binding-linux-arm64-musl": "0.120.0", "@oxc-parser/binding-linux-ppc64-gnu": "0.120.0", "@oxc-parser/binding-linux-riscv64-gnu": "0.120.0", "@oxc-parser/binding-linux-riscv64-musl": "0.120.0", "@oxc-parser/binding-linux-s390x-gnu": "0.120.0", "@oxc-parser/binding-linux-x64-gnu": "0.120.0", "@oxc-parser/binding-linux-x64-musl": "0.120.0", "@oxc-parser/binding-openharmony-arm64": "0.120.0", "@oxc-parser/binding-wasm32-wasi": "0.120.0", "@oxc-parser/binding-win32-arm64-msvc": "0.120.0", "@oxc-parser/binding-win32-ia32-msvc": "0.120.0", "@oxc-parser/binding-win32-x64-msvc": "0.120.0" } }, "sha512-WyPWZlcIm+Fkte63FGfgFB8mAAk33aH9h5N9lphXVOHSXEBFFsmYdOBedVKly363aWABjZdaj/m9lBfEY4wt+w=="],
@@ -4475,6 +4519,8 @@
"@vue/compiler-sfc/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
+ "@vue/compiler-sfc/postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="],
+
"@vue/devtools-kit/hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="],
"alchemy/undici": ["undici@7.30.0", "", {}, "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ=="],
@@ -4577,6 +4623,8 @@
"juice/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
+ "juice/postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="],
+
"katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
"knip/zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="],
@@ -4621,6 +4669,8 @@
"pkg-types/confbox": ["confbox@0.3.1", "", {}, "sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA=="],
+ "postcss-merge-longhand/postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="],
+
"pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
"prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
@@ -4633,6 +4683,8 @@
"rolldown-plugin-dts/obug": ["obug@3.0.0", "", {}, "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw=="],
+ "rrweb-snapshot/postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="],
+
"run-jxa/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="],
"run-jxa/type-fest": ["type-fest@2.19.0", "", {}, "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA=="],
@@ -4647,6 +4699,8 @@
"strip-literal/js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="],
+ "stylehacks/postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="],
+
"subsume/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
"svgo/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
@@ -4677,6 +4731,8 @@
"unstorage/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
+ "vite/postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="],
+
"vite/rolldown": ["rolldown@1.2.11", "", { "dependencies": { "@oxc-project/types": "=0.151.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.11", "@rolldown/binding-android-arm64": "1.2.11", "@rolldown/binding-darwin-arm64": "1.2.11", "@rolldown/binding-darwin-x64": "1.2.11", "@rolldown/binding-freebsd-x64": "1.2.11", "@rolldown/binding-linux-arm-gnueabihf": "1.2.11", "@rolldown/binding-linux-arm64-gnu": "1.2.11", "@rolldown/binding-linux-arm64-musl": "1.2.11", "@rolldown/binding-linux-ppc64-gnu": "1.2.11", "@rolldown/binding-linux-s390x-gnu": "1.2.11", "@rolldown/binding-linux-x64-gnu": "1.2.11", "@rolldown/binding-linux-x64-musl": "1.2.11", "@rolldown/binding-openharmony-arm64": "1.2.11", "@rolldown/binding-win32-arm64-msvc": "1.2.11", "@rolldown/binding-win32-x64-msvc": "1.2.11" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw=="],
"vue-router/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
diff --git a/knip.json b/knip.json
index abbe3554d..a45af5714 100644
--- a/knip.json
+++ b/knip.json
@@ -100,6 +100,8 @@
"entry": ["src/drain/index.ts"]
},
"packages/browser": {
+ // One source module per `exports` subpath; the built `dist/` names don't map back to them.
+ "entry": ["src/index.ts", "src/server.ts", "src/nextjs/index.ts", "src/nextjs/server.ts"],
"ignoreDependencies": ["rrweb"]
}
},
diff --git a/packages/browser/README.md b/packages/browser/README.md
index d2a5cd1cd..c77457e9a 100644
--- a/packages/browser/README.md
+++ b/packages/browser/README.md
@@ -160,8 +160,94 @@ MapleBrowser.endNavigation("/projects/:id") // the route is ready: its template
- `traced` returns `fn`'s result and rethrows its error unchanged. Only requests
started before `fn`'s first `await` nest under its span. An error it recorded
isn't reported again by `captureException` or the global handlers.
-- All three are no-ops on the server, before `init()`, with tracing disabled or
+- In the browser, all three are no-ops before `init()`, with tracing disabled or
without consent (`traced` then only runs `fn`).
+- On the server, `startNavigation` and `endNavigation` do nothing, and `traced`
+ spans through the server's own OpenTelemetry setup (see below).
+
+## Server-side data loading
+
+`@maple-dev/browser/server` depends on `@opentelemetry/api` only, so it runs in
+Node, edge runtimes and Workers:
+
+```ts
+import { serverTiming, traced } from "@maple-dev/browser/server"
+
+// A Server Component, SSR loader or resolver: a span under the active server span
+const project = await traced("db.query project", () => db.project.find(id))
+
+// Your framework's response hook: joins the browser's page load to this trace
+const value = serverTiming()
+if (value) headers.append("server-timing", value)
+```
+
+- `traced` spans through the global tracer the server registered (`@vercel/otel`,
+ the Node SDK), so it nests under the request span and keeps its parent across
+ `await`. An error is recorded once, like in the browser. Without server OpenTelemetry it
+ only runs `fn`. `MapleBrowser.traced` does the same when there is no `window`.
+- `serverTiming()` returns `traceparent;desc="00-…"` for the active span, or
+ `undefined` when none is active.
+
+## Next.js
+
+`@maple-dev/browser/nextjs` wires the App Router (Next.js 15.3+) to the
+navigation spans:
+
+```ts
+// src/instrumentation-client.ts
+import { MapleBrowser } from "@maple-dev/browser"
+
+MapleBrowser.init({ ingestKey: "maple_pk_...", serviceName: "acme-web" })
+MapleBrowser.startNavigation(location.pathname) // the page load
+export { onRouterTransitionStart } from "@maple-dev/browser/nextjs"
+```
+
+```tsx
+// src/app/layout.tsx: render it once, above {children}
+import { MapleNavigation } from "@maple-dev/browser/nextjs"
+
+export default function RootLayout({ children }: { children: React.ReactNode }) {
+ return (
+
+
+
+ {children}
+
+
+ )
+}
+```
+
+```tsx
+// src/app/error.tsx (and global-error.tsx)
+"use client"
+import { reportNextError } from "@maple-dev/browser/nextjs"
+import { useEffect } from "react"
+
+export default function ErrorPage({ error }: { error: Error & { digest?: string } }) {
+ useEffect(() => reportNextError(error), [error])
+ return
Something went wrong
+}
+```
+
+```ts
+// src/proxy.ts (middleware.ts before Next.js 16): joins the page load to the render
+import { withMapleProxy } from "@maple-dev/browser/nextjs/server"
+
+export const proxy = withMapleProxy() // or withMapleProxy(yourProxy)
+export const config = { matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"] }
+```
+
+- Spans are named after the route, like `navigate /projects/[id]`; a URL no
+ route matches is `/_not-found`. Hash links start no span.
+- `reportNextError` skips server errors (they arrive with a `digest`, and Next.js
+ already recorded them on its server span) and errors `traced` recorded.
+- `withMapleProxy` keeps a `traceparent` the request already carries (client
+ navigations), and only touches responses that go on to a render in this app:
+ your redirects and responses pass through unchanged. Leave prerendered pages a
+ shared cache (CDN) stores out of the matcher, or every visitor joins one trace.
+- The browser follows the server's sampling decision: a page load under an
+ unsampled server trace isn't recorded.
## Linking a marketing site to your app
diff --git a/packages/browser/package.json b/packages/browser/package.json
index 68af26e7b..0dac2b336 100644
--- a/packages/browser/package.json
+++ b/packages/browser/package.json
@@ -30,6 +30,18 @@
".": {
"types": "./dist/index.d.mts",
"import": "./dist/index.mjs"
+ },
+ "./server": {
+ "types": "./dist/server.d.mts",
+ "import": "./dist/server.mjs"
+ },
+ "./nextjs": {
+ "types": "./dist/nextjs.d.mts",
+ "import": "./dist/nextjs.mjs"
+ },
+ "./nextjs/server": {
+ "types": "./dist/nextjs-server.d.mts",
+ "import": "./dist/nextjs-server.mjs"
}
},
"publishConfig": {
@@ -54,10 +66,28 @@
},
"devDependencies": {
"@maple/browser-session": "workspace:*",
+ "@opentelemetry/context-async-hooks": "^2.11.0",
+ "@types/react": "catalog:react",
+ "@types/react-dom": "catalog:react",
"@vitest/browser-playwright": "catalog:",
+ "next": "^16.3.6",
"playwright": "catalog:",
+ "react": "catalog:react",
+ "react-dom": "catalog:react",
"tsdown": "^0.23.0",
"typescript": "catalog:tooling",
"vitest": "catalog:"
+ },
+ "peerDependencies": {
+ "next": "*",
+ "react": "*"
+ },
+ "peerDependenciesMeta": {
+ "next": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ }
}
}
diff --git a/packages/browser/src/errors.browser.test.ts b/packages/browser/src/errors.browser.test.ts
index dd1dd1ff0..0168e5fc4 100644
--- a/packages/browser/src/errors.browser.test.ts
+++ b/packages/browser/src/errors.browser.test.ts
@@ -2,7 +2,8 @@ import { assert, beforeEach, describe, it } from "vitest"
import { SpanStatusCode, trace } from "@opentelemetry/api"
import { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base"
import type { ReadableSpan } from "@opentelemetry/sdk-trace-base"
-import { captureException, resetReportedErrorsForTests, setupErrorCapture } from "./errors"
+import { captureException, setupErrorCapture } from "./errors"
+import { resetReportedErrorsForTests } from "./failures"
const exporter = new InMemorySpanExporter()
diff --git a/packages/browser/src/errors.ts b/packages/browser/src/errors.ts
index c8424ea6f..ed4ec6aa2 100644
--- a/packages/browser/src/errors.ts
+++ b/packages/browser/src/errors.ts
@@ -10,7 +10,8 @@
// Error. That is the shape `error_events_mv` fingerprints on, so these arrive in
// error tracking beside server-side errors rather than in a separate silo.
import { scrubUrl } from "@maple/browser-session"
-import { type Span, SpanKind, SpanStatusCode } from "@opentelemetry/api"
+import { SpanKind } from "@opentelemetry/api"
+import { alreadyReported, recordFailure } from "./failures"
import { mapleTracer } from "./tracing"
import { SDK_NAME, SDK_VERSION } from "./version"
@@ -21,56 +22,6 @@ export interface CaptureExceptionOptions {
readonly attributes?: Record | undefined
}
-const asError = (value: unknown): Error => {
- if (value instanceof Error) return value
- if (typeof value === "string") return new Error(value)
- if (typeof value === "object" && value !== null) {
- const message = (value as { readonly message?: unknown }).message
- if (typeof message === "string") return new Error(message)
- }
- // A rejected promise can carry literally anything. `String` keeps a number or
- // a boolean legible; an unrenderable object still produces one grouped issue
- // rather than throwing inside the error handler.
- try {
- return new Error(String(value))
- } catch {
- return new Error("Unknown error")
- }
-}
-
-/**
- * Errors already reported, by identity. Module-level so the global handlers and
- * `captureException` share it: a framework boundary that reports an error and
- * then rethrows it would otherwise produce two issues for one crash.
- */
-let reported = new WeakSet