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 ba9007aa16..266e49c038 100644
--- a/apps/landing/src/content/docs/session-replay/browser-sdk.md
+++ b/apps/landing/src/content/docs/session-replay/browser-sdk.md
@@ -340,6 +340,81 @@ export const config = {
- `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.
+## React Router integration
+
+`@maple-dev/browser/react-router` connects React Router 7.15 or later to the navigation spans, with a span for every loader and action, and `@maple-dev/browser/react-router/server` traces framework mode's server render. In data mode, pass the instrumentation to the router you create, and subscribe to it right after:
+
+```tsx
+// src/main.tsx
+import {
+ dataRouterInstrumentation,
+ reportRouteError,
+ traceNavigations,
+} from "@maple-dev/browser/react-router"
+import { createBrowserRouter } from "react-router"
+import { RouterProvider } from "react-router/dom"
+
+const router = createBrowserRouter(routes, { instrumentations: [dataRouterInstrumentation] })
+traceNavigations(router)
+
+createRoot(document.getElementById("root")!).render(
+ ,
+)
+```
+
+In framework mode, React Router creates the router, so pass the framework instrumentation to `HydratedRouter`, and end the page load in the root route's `Layout`, which also wraps the root `ErrorBoundary`:
+
+```tsx
+// app/entry.client.tsx
+import { frameworkInstrumentation, reportRouteError } from "@maple-dev/browser/react-router"
+
+startTransition(() => {
+ hydrateRoot(
+ document,
+ ,
+ )
+})
+```
+
+```tsx
+// app/root.tsx
+import { useMaplePageload } from "@maple-dev/browser/react-router"
+
+export function Layout({ children }: { children: React.ReactNode }) {
+ useMaplePageload()
+ // ...the rest of the generated Layout
+}
+```
+
+Then trace the server render and hand its trace to the page load, in `app/entry.server.tsx`:
+
+```tsx
+// app/entry.server.tsx
+import { serverInstrumentation } from "@maple-dev/browser/react-router/server"
+import { serverTiming } from "@maple-dev/browser/server"
+
+export { handleError } from "@maple-dev/browser/react-router/server"
+export const instrumentations = [serverInstrumentation]
+
+export default function handleRequest(
+ request: Request,
+ responseStatusCode: number,
+ responseHeaders: Headers /* , ... */,
+) {
+ const timing = serverTiming()
+ if (timing) responseHeaders.append("server-timing", timing)
+ // ...the rest of the generated handleRequest
+}
+```
+
+- Spans are named after the route pattern, like `navigate /projects/:id`, and loader and action spans after the route id, like `loader routes/project`. In data mode, route ids default to positions like `0-1`, so give routes with loaders an `id`. A URL no route matches is named after the root route, `/`.
+- In data mode, a redirect or a click while loaders run continues the same span, named after the route the user lands on. In framework mode, a click still loading ends as interrupted when the next click, back or forward replaces it. A redirected navigation is named after the route that was clicked, and back and forward themselves aren't traced.
+- Hash changes, revalidations and fetcher loads start no span. Only a thrown `Error` fails a loader span: `redirect()`, `data()` and thrown responses don't.
+- `reportRouteError` skips thrown responses, like a loader's 404, and errors a loader span already recorded. In production, a server loader's error reaches the browser as `Unexpected Server Error`, so it's recorded on the server's span only. If you have your own `onError`, call it from there.
+- `handleError` logs like React Router's default and records render errors on the request span, which fails with the 500. If you have your own `handleError`, call it from there instead of logging.
+- `serverInstrumentation` needs the server's OpenTelemetry setup, like the Node SDK loaded with `--import`. The request span nests under its HTTP span and is active while `handleRequest` runs.
+- Framework mode names navigations and server requests after the route since React Router 8.1. Earlier versions don't report the matched pattern, so those spans keep their plain names.
+
## Custom events
`track(name, props)` records a product event against the current session. It appears inline in the session transcript next to the clicks and network calls around it, and counts as a [product event](/docs/product-events/overview).
@@ -473,6 +548,8 @@ import { App } from "./App"
createRoot(document.getElementById("root")!).render()
```
+With React Router, see [React Router integration](#react-router-integration) for navigation and loader spans. In framework mode, import `./maple` first in `app/entry.client.tsx`.
+
### Next.js
Next.js exposes `NEXT_PUBLIC_*` variables through `process.env`, not `import.meta.env`. Initialize from a client component and render it in the root layout:
diff --git a/bun.lock b/bun.lock
index a6e01e8b5e..132cc848fc 100644
--- a/bun.lock
+++ b/bun.lock
@@ -661,6 +661,7 @@
"playwright": "catalog:",
"react": "catalog:react",
"react-dom": "catalog:react",
+ "react-router": "^8.4.0",
"tsdown": "^0.23.0",
"typescript": "catalog:tooling",
"vitest": "catalog:",
@@ -668,10 +669,12 @@
"peerDependencies": {
"next": "*",
"react": "*",
+ "react-router": "*",
},
"optionalPeers": [
"next",
"react",
+ "react-router",
],
},
"packages/browser-session": {
@@ -1989,6 +1992,8 @@
"@redis/time-series": ["@redis/time-series@6.2.1", "", { "peerDependencies": { "@redis/client": "^6.2.1" } }, "sha512-kiYniph04dJOole+L359B6C9E+jYS2uDP7hca6Onj0xF38ZIpyxARO0Iq0W4ZRn1e8Q6vqW00QFZVSMRA/2Ijw=="],
+ "@remix-run/route-pattern": ["@remix-run/route-pattern@0.22.1", "", {}, "sha512-czdGJWh09Lapvcqt7Vb09KlrU2PhRJ8xQaI+AItTuD9aDJ5MAgxnS38lnys6Ll+6RJEqPiaUqTwIQhVLwFvv+w=="],
+
"@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.5", "", { "os": "android", "cpu": "arm" }, "sha512-DLe/i+l8ynIBY7XEQ191TeZvCoowIGa18R+dIV30GW7DiOtp74i/xX8hs8GUjW5ARV7VZuie3d6AumSmCwbeRA=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.5", "", { "os": "android", "cpu": "arm64" }, "sha512-zXcwKlQApYAOELHd8PwKDFkagYF9Wy4e0RJ+0qnzl9Pjnpj75TEG8ufv40p2J7kCEfwZAsNiuzRIyNNMWT38ig=="],
@@ -3825,6 +3830,8 @@
"react-resizable-panels": ["react-resizable-panels@4.14.1", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-OB1bXDNTLcGgTTbaX6Dn5efZhlMboSnkfr1w4xpsJTHPEbRJpACBhXpgRCXozgPO1ztgFeZRUe5J3mFNCVKm5g=="],
+ "react-router": ["react-router@8.4.0", "", { "dependencies": { "@remix-run/route-pattern": "^0.22.1", "cookie-es": "^3.1.1" }, "peerDependencies": { "react": ">=19.2.7", "react-dom": ">=19.2.7" }, "optionalPeers": ["react-dom"] }, "sha512-JtydAkBvU9UTEe5qNC+POhu+ZBNM7rlKY2IegwXc7Idj7xfRG16x5RZrw3mju+XlqBxfvb2ky9P3jUp9WyWC1g=="],
+
"react-server-dom-webpack": ["react-server-dom-webpack@19.2.8", "", { "dependencies": { "acorn-loose": "^8.3.0", "neo-async": "^2.6.1", "webpack-sources": "^3.2.0" }, "peerDependencies": { "react": "^19.2.8", "react-dom": "^19.2.8", "webpack": "^5.59.0" } }, "sha512-rFO1VJZ+cH9ZH6hGy9+qmIsmZHZpCMKJZXhWHqT71UnSfg+w+W+gwrwp58MVzMqdFquG8GF1c6C8q0OGphkEdg=="],
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
diff --git a/knip.json b/knip.json
index a45af5714c..4b8513bb34 100644
--- a/knip.json
+++ b/knip.json
@@ -101,7 +101,14 @@
},
"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"],
+ "entry": [
+ "src/index.ts",
+ "src/server.ts",
+ "src/nextjs/index.ts",
+ "src/nextjs/server.ts",
+ "src/react-router/index.ts",
+ "src/react-router/server.ts"
+ ],
"ignoreDependencies": ["rrweb"]
}
},
diff --git a/packages/browser/README.md b/packages/browser/README.md
index c77457e9a8..82bf5bac2a 100644
--- a/packages/browser/README.md
+++ b/packages/browser/README.md
@@ -249,6 +249,78 @@ export const config = { matcher: ["/((?!api|_next/static|_next/image|favicon.ico
- The browser follows the server's sampling decision: a page load under an
unsampled server trace isn't recorded.
+## React Router
+
+`@maple-dev/browser/react-router` wires React Router 7.15+ to the navigation
+spans, with a span per loader and action. Data mode (`createBrowserRouter`):
+
+```tsx
+import {
+ dataRouterInstrumentation,
+ reportRouteError,
+ traceNavigations,
+} from "@maple-dev/browser/react-router"
+
+const router = createBrowserRouter(routes, { instrumentations: [dataRouterInstrumentation] })
+traceNavigations(router)
+
+createRoot(root).render()
+```
+
+Framework mode:
+
+```tsx
+// app/entry.client.tsx
+import { frameworkInstrumentation, reportRouteError } from "@maple-dev/browser/react-router"
+
+hydrateRoot(
+ document,
+ ,
+)
+```
+
+```tsx
+// app/root.tsx: ends the page load once the page hydrates
+import { useMaplePageload } from "@maple-dev/browser/react-router"
+
+export function Layout({ children }: { children: React.ReactNode }) {
+ useMaplePageload()
+ // ...
+}
+```
+
+```tsx
+// app/entry.server.tsx
+import { serverInstrumentation } from "@maple-dev/browser/react-router/server"
+import { serverTiming } from "@maple-dev/browser/server"
+
+export { handleError } from "@maple-dev/browser/react-router/server"
+export const instrumentations = [serverInstrumentation]
+
+export default function handleRequest(request: Request, status: number, headers: Headers /* , ... */) {
+ const timing = serverTiming() // joins the page load to this request's trace
+ if (timing) headers.append("server-timing", timing)
+ // ...
+}
+```
+
+- Spans are named after the route pattern, like `navigate /projects/:id`, and
+ loader spans after the route id, like `loader routes/project`. A URL no route
+ matches is named after the root, `/`.
+- Data mode keeps a redirect or a click during loading in one span, named after
+ the route it landed on. In framework mode, a click still loading ends as
+ `app.navigation.interrupted` when the next click, back or forward replaces
+ it, a redirect is named after the route clicked, and back and forward
+ themselves aren't traced.
+- Hash changes, revalidations and fetcher loads start no span. Only thrown
+ `Error`s fail a loader span: `redirect()` and thrown responses don't.
+- `reportRouteError` skips thrown responses and errors a loader span recorded.
+ A server loader's error reaches the browser without its message in
+ production, so only the server's span records it. `handleError` logs like
+ React Router's default and records render errors on the request span.
+- Framework mode names navigations and server requests after the route since
+ React Router 8.1, which added the matched pattern to its instrumentation.
+
## Linking a marketing site to your app
The visitor id lives in localStorage **and** a cookie scoped to your registered
diff --git a/packages/browser/package.json b/packages/browser/package.json
index 0dac2b3363..5f21809816 100644
--- a/packages/browser/package.json
+++ b/packages/browser/package.json
@@ -42,6 +42,14 @@
"./nextjs/server": {
"types": "./dist/nextjs-server.d.mts",
"import": "./dist/nextjs-server.mjs"
+ },
+ "./react-router": {
+ "types": "./dist/react-router.d.mts",
+ "import": "./dist/react-router.mjs"
+ },
+ "./react-router/server": {
+ "types": "./dist/react-router-server.d.mts",
+ "import": "./dist/react-router-server.mjs"
}
},
"publishConfig": {
@@ -74,13 +82,15 @@
"playwright": "catalog:",
"react": "catalog:react",
"react-dom": "catalog:react",
+ "react-router": "^8.4.0",
"tsdown": "^0.23.0",
"typescript": "catalog:tooling",
"vitest": "catalog:"
},
"peerDependencies": {
"next": "*",
- "react": "*"
+ "react": "*",
+ "react-router": "*"
},
"peerDependenciesMeta": {
"next": {
@@ -88,6 +98,9 @@
},
"react": {
"optional": true
+ },
+ "react-router": {
+ "optional": true
}
}
}
diff --git a/packages/browser/src/failures.ts b/packages/browser/src/failures.ts
index 7a51653f7e..0f34ac1c37 100644
--- a/packages/browser/src/failures.ts
+++ b/packages/browser/src/failures.ts
@@ -68,6 +68,13 @@ export function recordFailure(span: Span, error: unknown): void {
span.setStatus({ code: SpanStatusCode.ERROR, message: normalized.message })
}
+/** `recordFailure`'s exception event alone, for a span whose status follows something else, like a response's. */
+export function recordExceptionOnce(span: Span, error: unknown): void {
+ if (alreadyReported(error)) return
+ if (span.isRecording() && isObject(error)) reported.add(error)
+ span.recordException(asError(error))
+}
+
/** Run `fn` as `span`'s work: its result or error passes through unchanged, and the span ends either way. */
export async function runTraced(span: Span, fn: () => Promise, options: TracedOptions): Promise {
try {
diff --git a/packages/browser/src/react-router/index.browser.test.ts b/packages/browser/src/react-router/index.browser.test.ts
new file mode 100644
index 0000000000..9ea7137be2
--- /dev/null
+++ b/packages/browser/src/react-router/index.browser.test.ts
@@ -0,0 +1,400 @@
+// TEST-SEAM: This focused test replaces process-global modules that have no instance-level injection seam.
+import { context, SpanStatusCode, trace } from "@opentelemetry/api"
+import type { ReadableSpan } from "@opentelemetry/sdk-trace-base"
+import { act, createElement } from "react"
+import { createRoot, type Root } from "react-dom/client"
+import {
+ createMemoryRouter,
+ type DataRouter,
+ data,
+ Outlet,
+ type RouteObject,
+ RouterProvider,
+ redirect,
+} from "react-router"
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
+
+const exported: ReadableSpan[] = []
+vi.mock("@opentelemetry/exporter-trace-otlp-http", () => ({
+ OTLPTraceExporter: class {
+ export(spans: ReadableSpan[], callback: (result: { code: number }) => void): void {
+ exported.push(...spans)
+ callback({ code: 0 })
+ }
+ forceFlush(): Promise {
+ return Promise.resolve()
+ }
+ shutdown(): Promise {
+ return Promise.resolve()
+ }
+ },
+}))
+
+const { MapleBrowser } = await import("../index")
+const { resetReportedErrorsForTests } = await import("../failures")
+const { resetNavigationForTests } = await import("../navigation")
+const {
+ dataRouterInstrumentation,
+ frameworkInstrumentation,
+ reportRouteError,
+ traceNavigations,
+ useMaplePageload,
+} = await import("./index")
+
+Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
+
+let handle: ReturnType | undefined
+let router: DataRouter | undefined
+let root: Root | undefined
+
+const stop = async (): Promise => {
+ await handle?.shutdown()
+ handle = undefined
+}
+
+const names = () => exported.map((span) => span.name)
+const named = (name: string): ReadableSpan => {
+ const span = exported.find((candidate) => candidate.name === name)
+ if (!span) throw new Error(`no exported span named ${name}: ${names().join(", ")}`)
+ return span
+}
+const parentOf = (span: ReadableSpan) => span.parentSpanContext?.spanId
+const idOf = (span: ReadableSpan) => span.spanContext().spanId
+
+const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
+const Page = () => createElement("main")
+
+/** A route tree like a small app's, with readable ids for the loader spans. */
+const routes = (): RouteObject[] => [
+ {
+ id: "root",
+ path: "/",
+ Component: Outlet,
+ children: [
+ { id: "home", index: true, Component: Page },
+ { id: "project", path: "projects/:id", loader: () => sleep(5), Component: Page },
+ { id: "about", path: "about", Component: Page },
+ { id: "old", path: "old", loader: () => redirect("/projects/1") },
+ { id: "slow", path: "slow", loader: () => sleep(50), Component: Page },
+ {
+ id: "broken-loader",
+ path: "broken-loader",
+ loader: () => {
+ throw new Error("loader exploded")
+ },
+ Component: Page,
+ },
+ {
+ id: "server-error",
+ path: "server-error",
+ loader: () => {
+ // A server loader's error, as production React Router hands it to the browser
+ throw Object.assign(new Error("Unexpected Server Error"), { stack: undefined })
+ },
+ Component: Page,
+ },
+ {
+ id: "missing",
+ path: "missing",
+ loader: () => {
+ throw data("Not found", { status: 404 })
+ },
+ Component: Page,
+ },
+ {
+ id: "broken-render",
+ path: "broken-render",
+ Component: () => {
+ throw new Error("render exploded")
+ },
+ },
+ ],
+ },
+]
+
+/** Data mode: the router the app creates, then `traceNavigations` right after. */
+const dataRouter = async (initial: string) => {
+ router = createMemoryRouter(routes(), {
+ initialEntries: [initial],
+ instrumentations: [dataRouterInstrumentation],
+ })
+ traceNavigations(router)
+ await vi.waitFor(() => expect(router?.state.initialized).toBe(true))
+ return router
+}
+
+beforeEach(() => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async () => new Response("{}")),
+ )
+ handle = MapleBrowser.init({
+ ingestKey: "k",
+ serviceName: "web",
+ endpoint: "https://ingest.test",
+ replay: { enabled: false },
+ })
+})
+
+afterEach(async () => {
+ act(() => root?.unmount())
+ root = undefined
+ router?.dispose()
+ router = undefined
+ await stop()
+ exported.length = 0
+ vi.unstubAllGlobals()
+ resetReportedErrorsForTests()
+ resetNavigationForTests()
+ trace.disable()
+ context.disable()
+})
+
+describe("data mode", () => {
+ it("names the page load after its route and nests its loader", async () => {
+ await dataRouter("/projects/1")
+ await stop()
+
+ expect(names()).toEqual(["loader project", "pageload /projects/:id"])
+ expect(parentOf(named("loader project"))).toBe(idOf(named("pageload /projects/:id")))
+ })
+
+ it("ends the page load when its loaders finish, not at a state change before", async () => {
+ router = createMemoryRouter(routes(), {
+ initialEntries: ["/slow"],
+ instrumentations: [dataRouterInstrumentation],
+ })
+ traceNavigations(router)
+ // A fetcher publishes router state while the first load is still running
+ await router.fetch("members", "root", "/projects/1")
+ await vi.waitFor(() => expect(router?.state.initialized).toBe(true))
+ await stop()
+
+ const pageload = named("pageload /slow")
+ expect(pageload.endTime >= named("loader slow").endTime).toBe(true)
+ })
+
+ it("ends a page load without loaders at once", async () => {
+ await dataRouter("/about")
+ await stop()
+
+ expect(names()).toEqual(["pageload /about"])
+ })
+
+ it("names each navigation after its route, with its loaders under it", async () => {
+ const router = await dataRouter("/")
+ await router.navigate("/projects/2")
+ await router.navigate("/about")
+ await stop()
+
+ expect(names()).toEqual(["pageload /", "loader project", "navigate /projects/:id", "navigate /about"])
+ const navigate = named("navigate /projects/:id")
+ expect(navigate.attributes["url.path"]).toBe("/projects/2")
+ expect(parentOf(named("loader project"))).toBe(idOf(navigate))
+ })
+
+ it("traces query changes and history navigations, and skips hash changes, revalidations and fetchers", async () => {
+ const router = await dataRouter("/projects/1")
+ await router.navigate("/projects/1?tab=members")
+ await router.navigate("/projects/1?tab=members#section")
+ await router.revalidate()
+ await router.fetch("members", "root", "/projects/2")
+ await router.navigate("/about")
+ await router.navigate(-1)
+ await vi.waitFor(() => expect(router.state.location.pathname).toBe("/projects/1"))
+ await stop()
+
+ expect(names().filter((name) => !name.startsWith("loader"))).toEqual([
+ "pageload /projects/:id",
+ "navigate /projects/:id",
+ "navigate /about",
+ "navigate /projects/:id",
+ ])
+ })
+
+ it("keeps a redirect in one span, named after where it landed", async () => {
+ const router = await dataRouter("/")
+ await router.navigate("/old")
+ await stop()
+
+ const navigate = named("navigate /projects/:id")
+ expect(navigate.attributes["url.path"]).toBe("/old")
+ expect(names().filter((name) => name.startsWith("navigate"))).toHaveLength(1)
+ })
+
+ it("merges a click while loading into the same span", async () => {
+ const router = await dataRouter("/")
+ void router.navigate("/slow")
+ await router.navigate("/projects/1")
+ await stop()
+
+ expect(names().filter((name) => name.startsWith("navigate"))).toEqual(["navigate /projects/:id"])
+ expect(named("navigate /projects/:id").attributes["url.path"]).toBe("/slow")
+ })
+
+ it("names a URL no route matches after the root", async () => {
+ const router = await dataRouter("/")
+ await router.navigate("/does-not-exist")
+ await stop()
+
+ expect(names()).toEqual(["pageload /", "navigate /"])
+ expect(named("navigate /").attributes["url.path"]).toBe("/does-not-exist")
+ })
+
+ it("records a loader error once, on its span", async () => {
+ const router = await dataRouter("/")
+ root = createRoot(document.body.appendChild(document.createElement("div")))
+ await act(async () =>
+ root?.render(createElement(RouterProvider, { router, onError: reportRouteError })),
+ )
+ await act(() => router.navigate("/broken-loader"))
+ await stop()
+
+ const loader = named("loader broken-loader")
+ expect(loader.status.code).toBe(SpanStatusCode.ERROR)
+ expect(loader.events.map((event) => event.attributes?.["exception.message"])).toEqual([
+ "loader exploded",
+ ])
+ expect(names()).not.toContain("react_router.error")
+ })
+
+ it("leaves a server loader's error to the server's span", async () => {
+ const router = await dataRouter("/")
+ root = createRoot(document.body.appendChild(document.createElement("div")))
+ await act(async () =>
+ root?.render(createElement(RouterProvider, { router, onError: reportRouteError })),
+ )
+ await act(() => router.navigate("/server-error"))
+ await stop()
+
+ expect(named("loader server-error").status.code).toBe(SpanStatusCode.UNSET)
+ expect(names()).not.toContain("react_router.error")
+ })
+
+ it("reports a render error once, with its route, and skips thrown responses", async () => {
+ const router = await dataRouter("/")
+ root = createRoot(document.body.appendChild(document.createElement("div")))
+ await act(async () =>
+ root?.render(createElement(RouterProvider, { router, onError: reportRouteError })),
+ )
+ await act(() => router.navigate("/missing"))
+ await act(() => router.navigate("/broken-render"))
+ await stop()
+
+ const reported = exported.filter((span) => span.name === "react_router.error")
+ expect(reported).toHaveLength(1)
+ expect(reported[0]?.attributes["app.route"]).toBe("/broken-render")
+ expect(reported[0]?.events[0]?.attributes?.["exception.message"]).toBe("render exploded")
+ expect(named("loader missing").status.code).toBe(SpanStatusCode.UNSET)
+ })
+})
+
+describe("framework mode", () => {
+ /** The root route's `Layout`: renders around every page, the error pages included. */
+ function Layout() {
+ useMaplePageload()
+ return createElement(Outlet)
+ }
+
+ const ids = (route: RouteObject): string[] => [route.id ?? "", ...(route.children ?? []).flatMap(ids)]
+
+ /** Framework mode after the server render: hydrated, with the loaders already run. */
+ const hydrate = async (initial: string) => {
+ const tree = routes()
+ tree[0] = { ...tree[0], Component: Layout }
+ const hydrated = createMemoryRouter(tree, {
+ initialEntries: [initial],
+ instrumentations: [frameworkInstrumentation],
+ // The server ran every loader: nothing runs again on hydration
+ hydrationData: { loaderData: Object.fromEntries(tree.flatMap(ids).map((id) => [id, null])) },
+ })
+ router = hydrated
+ root = createRoot(document.body.appendChild(document.createElement("div")))
+ await act(async () =>
+ root?.render(createElement(RouterProvider, { router: hydrated, onError: reportRouteError })),
+ )
+ return hydrated
+ }
+
+ it("ends the page load when the page hydrates, named after the matched routes", async () => {
+ await hydrate("/projects/1")
+ await stop()
+
+ expect(names()).toEqual(["pageload /projects/:id"])
+ })
+
+ it("names each navigation after the pattern React Router reports", async () => {
+ const router = await hydrate("/")
+ await act(() => router.navigate("/projects/2?tab=members"))
+ await stop()
+
+ expect(names()).toEqual(["pageload /", "loader project", "navigate /projects/:id"])
+ const navigate = named("navigate /projects/:id")
+ expect(navigate.attributes["url.path"]).toBe("/projects/2")
+ expect(parentOf(named("loader project"))).toBe(idOf(navigate))
+ })
+
+ it("skips hash links and history navigations", async () => {
+ const router = await hydrate("/about")
+ await act(() => router.navigate("#section"))
+ await act(() => router.navigate("/projects/1"))
+ await act(() => router.navigate(-1))
+ await stop()
+
+ expect(names().filter((name) => !name.startsWith("loader"))).toEqual([
+ "pageload /about",
+ "navigate /projects/:id",
+ ])
+ })
+
+ it("ends a navigation interrupted by the next click as interrupted", async () => {
+ const router = await hydrate("/")
+ await act(async () => {
+ void router.navigate("/slow")
+ await router.navigate("/projects/1")
+ })
+ // The interrupted navigation's own end, after the next one ended
+ await sleep(60)
+ await stop()
+
+ expect(names().filter((name) => !name.startsWith("loader"))).toEqual([
+ "pageload /",
+ "navigate",
+ "navigate /projects/:id",
+ ])
+ expect(named("navigate").attributes["app.navigation.interrupted"]).toBe(true)
+ expect(named("navigate").attributes["url.path"]).toBe("/slow")
+ })
+
+ it("ends a click abandoned by back or forward as interrupted", async () => {
+ const router = await hydrate("/")
+ const slow = router.navigate("/slow")
+ window.dispatchEvent(new PopStateEvent("popstate"))
+ await act(() => slow)
+ await stop()
+
+ expect(names().filter((name) => !name.startsWith("loader"))).toEqual(["pageload /", "navigate"])
+ expect(named("navigate").attributes["app.navigation.interrupted"]).toBe(true)
+ })
+
+ it("ends the page load once, however many times Layout mounts", async () => {
+ const router = await hydrate("/")
+ const slow = router.navigate("/slow")
+ // The error boundary and the hydrate fallback each mount a Layout of their own
+ const other = createRoot(document.body.appendChild(document.createElement("div")))
+ await act(async () => other.render(createElement(RouterProvider, { router })))
+ await act(() => slow)
+ act(() => other.unmount())
+ await stop()
+
+ expect(names().filter((name) => !name.startsWith("loader"))).toEqual(["pageload /", "navigate /slow"])
+ })
+
+ it("names a redirected navigation after the route that was clicked", async () => {
+ const router = await hydrate("/")
+ await act(() => router.navigate("/old"))
+ await stop()
+
+ expect(names().filter((name) => name.startsWith("navigate"))).toEqual(["navigate /old"])
+ })
+})
diff --git a/packages/browser/src/react-router/index.ts b/packages/browser/src/react-router/index.ts
new file mode 100644
index 0000000000..7f5507fdb2
--- /dev/null
+++ b/packages/browser/src/react-router/index.ts
@@ -0,0 +1,135 @@
+// `@maple-dev/browser/react-router`: navigations, loaders, actions and route errors.
+//
+// React Router's instrumentation API wraps every loader and action, and says
+// when a navigation starts. Where it ends depends on the mode: data mode
+// subscribes to the router the app created, framework mode ends each click in
+// the `navigate` hook, and its page load in the root `Layout` once the page
+// hydrates.
+//
+// `root.tsx` renders on the server too: nothing here touches `window` on import.
+// An effect is the only signal that the page hydrated
+// oxlint-disable-next-line maple/no-react-use-effect
+import { useEffect } from "react"
+import {
+ type ClientInstrumentation,
+ type ClientOnErrorFunction,
+ type DataRouter,
+ isRouteErrorResponse,
+ useMatches,
+} from "react-router"
+import { captureException } from "../errors"
+import { endNavigation, interruptNavigation, startNavigation, traced } from "../navigation"
+import { matchedPattern, routePattern, traceRouteHandlers } from "./route"
+
+/**
+ * A server loader's or action's error as production React Router hands it to
+ * the browser: its message replaced, its stack dropped. The server's span
+ * recorded the real one.
+ */
+const fromServer = (error: unknown): boolean =>
+ error instanceof Error && error.message === "Unexpected Server Error" && error.stack === undefined
+
+const traceHandlers = traceRouteHandlers((name, fn) =>
+ traced(name, fn, { isFailure: (error) => !fromServer(error) }),
+)
+
+/** Pass to `createBrowserRouter`'s `instrumentations` (data mode), then call `traceNavigations(router)`. */
+export const dataRouterInstrumentation: ClientInstrumentation = {
+ // Runs when the router is created, before the first route's loaders
+ router: () => startNavigation(window.location.pathname),
+ route: traceHandlers,
+}
+
+/** Call right after `createBrowserRouter` (data mode): ends each navigation span, named after its route pattern. */
+export function traceNavigations(router: DataRouter): () => void {
+ const pattern = (state: DataRouter["state"]) =>
+ matchedPattern(state.matches.map((match) => match.route.path))
+ // The first route's loaders may still be running
+ let loading = !router.state.initialized
+ let pathname = router.state.location.pathname
+ if (!loading) endNavigation(pattern(router.state))
+
+ return router.subscribe((state) => {
+ if (state.navigation.state !== "idle") {
+ // A redirect or a second click while loading continues the same span
+ if (!loading) startNavigation(state.navigation.location.pathname)
+ loading = true
+ return
+ }
+ // The first load leaves the navigation state idle: it ends once the router is initialized
+ if (!state.initialized) return
+ // Fetcher loads, revalidations and hash changes aren't navigations
+ if (!loading && state.location.pathname === pathname) return
+ // A route without loaders goes straight to the new location
+ if (!loading) startNavigation(state.location.pathname)
+ endNavigation(pattern(state))
+ loading = false
+ pathname = state.location.pathname
+ })
+}
+
+/** Route ids to their paths, for `useMaplePageload`: `useMatches()` only has the ids. */
+const routePaths = new Map()
+/** Counts clicks and history navigations: only the latest click ends its span. */
+let latest = 0
+/** Whether the page load is still open for `useMaplePageload` to end. */
+let pageLoading = false
+
+/** Pass to `HydratedRouter`'s `instrumentations` (framework mode), with `useMaplePageload()` in the root `Layout`. */
+export const frameworkInstrumentation: ClientInstrumentation = {
+ router({ instrument }) {
+ startNavigation(window.location.pathname)
+ pageLoading = true
+ // Back and forward don't go through `navigate`, and abandon a click still loading
+ window.addEventListener("popstate", () => {
+ latest++
+ interruptNavigation()
+ })
+ instrument({
+ navigate: async (navigate, { to }) => {
+ // `navigate(-1)` is a history navigation, like the back button, and hash links run nothing
+ if (typeof to === "number" || to.startsWith("#")) return
+ const id = ++latest
+ // `to` can carry a query string, or be relative. React Router resolves a
+ // relative one against the route, this against the URL: only `url.path` can differ.
+ startNavigation(new URL(to, window.location.href).pathname)
+ // `meta` since React Router 8.1
+ const { meta } = await navigate()
+ // A newer navigation has already replaced this one
+ if (id === latest) endNavigation(meta && routePattern(meta.pattern))
+ },
+ })
+ },
+ route(route) {
+ routePaths.set(route.id, route.path)
+ traceHandlers(route)
+ },
+}
+
+/**
+ * Call in the root route's `Layout` (framework mode): ends the page load span once the page hydrates.
+ * `Layout` also wraps the root `ErrorBoundary`, so 404 and error pages end it too.
+ */
+export function useMaplePageload(): void {
+ const matches = useMatches()
+ // The server already ran the loaders: the page is ready once it hydrates. The
+ // page, its error boundary and its hydrate fallback each mount a `Layout` of
+ // their own: only the first ends the page load.
+ useEffect(() => {
+ if (!pageLoading) return
+ pageLoading = false
+ endNavigation(matchedPattern(matches.map((match) => routePaths.get(match.id))))
+ // oxlint-disable-next-line react-hooks/exhaustive-deps
+ }, [])
+}
+
+/** Pass as `onError` to `RouterProvider` or `HydratedRouter`: reports render, loader and action errors once each. */
+export const reportRouteError: ClientOnErrorFunction = (error, { pattern }) => {
+ // Thrown responses, like a loader's 404, are expected
+ if (isRouteErrorResponse(error) || fromServer(error)) return
+ // A loader or action error is already on its span, and `captureException` skips it
+ captureException(error, {
+ name: "react_router.error",
+ attributes: { "app.route": routePattern(pattern) },
+ })
+}
diff --git a/packages/browser/src/react-router/route.test.ts b/packages/browser/src/react-router/route.test.ts
new file mode 100644
index 0000000000..9d8825246f
--- /dev/null
+++ b/packages/browser/src/react-router/route.test.ts
@@ -0,0 +1,75 @@
+import type { InstrumentationHandlerResult, InstrumentRouteFunction } from "react-router"
+import { describe, expect, it, vi } from "vitest"
+import { matchedPattern, routePattern, traceRouteHandlers } from "./route"
+
+describe("routePattern", () => {
+ it("adds the leading slash framework mode's patterns leave off", () => {
+ expect(routePattern("projects/:id")).toBe("/projects/:id")
+ expect(routePattern("/projects/:id")).toBe("/projects/:id")
+ expect(routePattern("")).toBe("/")
+ })
+})
+
+describe("matchedPattern", () => {
+ it("joins the paths of the matched routes, skipping layout and index routes", () => {
+ expect(matchedPattern(["/", undefined, "projects/:id", undefined])).toBe("/projects/:id")
+ expect(matchedPattern(["", "docs", "*"])).toBe("/docs/*")
+ expect(matchedPattern([undefined])).toBe("/")
+ expect(matchedPattern([])).toBe("/")
+ })
+})
+
+/** What a route instrumentation registers: `{ loader, action, ... }`. */
+type RouteInstrumentations = Parameters[0]["instrument"]>[0]
+
+describe("traceRouteHandlers", () => {
+ /** The instrumentations `traceRouteHandlers` registers for a route. */
+ const instrument = (traced: (name: string, fn: () => Promise) => Promise) => {
+ let registered: RouteInstrumentations = {}
+ traceRouteHandlers(traced)({
+ id: "routes/project",
+ index: undefined,
+ path: "projects/:id",
+ instrument: (instrumentations) => {
+ registered = instrumentations
+ },
+ })
+ return registered
+ }
+ const info = {} as Parameters>[1]
+
+ it("names loader and action spans after the route id", async () => {
+ const traced = vi.fn(async (_name: string, fn: () => Promise) => fn())
+ const handlers = instrument(traced)
+ const handler = vi.fn(async (): Promise => ({
+ status: "success",
+ error: undefined,
+ }))
+ await handlers.loader?.(handler, info)
+ await handlers.action?.(handler, info)
+
+ expect(traced.mock.calls.map(([name]) => name)).toEqual([
+ "loader routes/project",
+ "action routes/project",
+ ])
+ expect(handler).toHaveBeenCalledTimes(2)
+ })
+
+ it("hands a failed handler's error to traced and never throws itself", async () => {
+ const error = new Error("loader exploded")
+ let seen: unknown
+ const handlers = instrument(async (_name, fn) => {
+ try {
+ await fn()
+ } catch (thrown) {
+ seen = thrown
+ throw thrown
+ }
+ })
+ await expect(
+ handlers.loader?.(async () => ({ status: "error", error }), info),
+ ).resolves.toBeUndefined()
+
+ expect(seen).toBe(error)
+ })
+})
diff --git a/packages/browser/src/react-router/route.ts b/packages/browser/src/react-router/route.ts
new file mode 100644
index 0000000000..f47d35babe
--- /dev/null
+++ b/packages/browser/src/react-router/route.ts
@@ -0,0 +1,39 @@
+// Shared by the browser and server entries: depends on `react-router` types only.
+import type { InstrumentationHandlerResult, InstrumentRouteFunction } from "react-router"
+
+/** `traced` from the browser entry or the server one. */
+type Traced = (name: string, fn: () => Promise) => Promise
+
+/** A route pattern with the leading slash framework mode's patterns leave off: `projects/:id` is `/projects/:id`. */
+export const routePattern = (pattern: string): string => `/${pattern}`.replace(/\/+/g, "/")
+
+/**
+ * The pattern of the matched routes, from their paths: each is relative to its
+ * parent, and layout and index routes have none. Joined the way React Router
+ * joins the `pattern` it reports, so both name a route the same.
+ */
+export const matchedPattern = (paths: readonly (string | undefined)[]): string =>
+ routePattern(paths.filter(Boolean).join("/"))
+
+/** A span per loader and action run, named after the route id: `loader routes/project`. */
+export const traceRouteHandlers =
+ (traced: Traced): InstrumentRouteFunction =>
+ (route) => {
+ const handlerSpan = async (name: string, handler: () => Promise) => {
+ try {
+ await traced(name, async () => {
+ // React Router resolves the handler to a result and hands the loader's
+ // own error to the app itself. Only thrown `Error`s are `"error"`:
+ // `redirect()`, `data()` and thrown Responses aren't failures.
+ const result = await handler()
+ if (result.status === "error") throw result.error
+ })
+ } catch {
+ // Rethrown into `traced` only to record it on the span
+ }
+ }
+ route.instrument({
+ loader: (handler) => handlerSpan(`loader ${route.id}`, handler),
+ action: (handler) => handlerSpan(`action ${route.id}`, handler),
+ })
+ }
diff --git a/packages/browser/src/react-router/server.test.ts b/packages/browser/src/react-router/server.test.ts
new file mode 100644
index 0000000000..9674dc0c3b
--- /dev/null
+++ b/packages/browser/src/react-router/server.test.ts
@@ -0,0 +1,194 @@
+import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks"
+import { context, SpanStatusCode, trace } from "@opentelemetry/api"
+import {
+ BasicTracerProvider,
+ InMemorySpanExporter,
+ type ReadableSpan,
+ SimpleSpanProcessor,
+} from "@opentelemetry/sdk-trace-base"
+import { createRequestHandler, type HandleDocumentRequestFunction, type ServerBuild } from "react-router"
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
+import { resetReportedErrorsForTests } from "../failures"
+import { serverTiming } from "../server"
+import { handleError, serverInstrumentation } from "./server"
+
+const exporter = new InMemorySpanExporter()
+const spans = () => exporter.getFinishedSpans()
+const names = () => spans().map((span) => span.name)
+const named = (name: string): ReadableSpan => {
+ const span = spans().find((candidate) => candidate.name === name)
+ if (!span) throw new Error(`no span named ${name}: ${names().join(", ")}`)
+ return span
+}
+const parentOf = (span: ReadableSpan) => span.parentSpanContext?.spanId
+const idOf = (span: ReadableSpan) => span.spanContext().spanId
+const exceptions = (span: ReadableSpan) =>
+ span.events
+ .filter((event) => event.name === "exception")
+ .map((event) => event.attributes?.["exception.message"])
+
+/** `entry.server.tsx`'s `handleRequest`, reduced to the line that hands the trace to the browser. */
+const handleRequest: HandleDocumentRequestFunction = (request, status, headers) => {
+ const timing = serverTiming()
+ if (timing) headers.append("server-timing", timing)
+ // The first render throws; the second renders the error boundary
+ if (new URL(request.url).pathname === "/broken-render" && status !== 500)
+ throw new Error("render exploded")
+ return new Response("", { status, headers })
+}
+
+const page = { default: () => null }
+/** A React Router build with the Maple exports in its server entry, as `react-router build` makes it. */
+const build: ServerBuild = {
+ entry: { module: { default: handleRequest, instrumentations: [serverInstrumentation], handleError } },
+ routes: {
+ root: { id: "root", path: "", module: { ...page, ErrorBoundary: () => null } },
+ "routes/project": {
+ id: "routes/project",
+ parentId: "root",
+ path: "projects/:id",
+ module: {
+ ...page,
+ loader: async ({ params }) => ({ id: params.id }),
+ },
+ },
+ "routes/broken-loader": {
+ id: "routes/broken-loader",
+ parentId: "root",
+ path: "broken-loader",
+ module: {
+ ...page,
+ loader: () => {
+ throw new Error("loader exploded")
+ },
+ },
+ },
+ "routes/broken-render": {
+ id: "routes/broken-render",
+ parentId: "root",
+ path: "broken-render",
+ module: page,
+ },
+ },
+ assets: { entry: { module: "", imports: [] }, routes: {}, url: "", version: "1" },
+ future: {},
+ publicPath: "/",
+ assetsBuildDirectory: "",
+ ssr: true,
+ isSpaMode: false,
+ prerender: [],
+ routeDiscovery: { mode: "lazy", manifestPath: "/__manifest" },
+}
+
+const handler = createRequestHandler(build, "production")
+
+/** A request through the handler, inside the HTTP span the Node SDK opens for it. */
+const serve = (path: string): Promise =>
+ trace.getTracer("http").startActiveSpan("GET", async (span) => {
+ try {
+ return await handler(new Request(`https://acme.test${path}`))
+ } finally {
+ span.end()
+ }
+ })
+
+let consoleError: ReturnType
+
+beforeEach(() => {
+ consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined)
+})
+
+afterEach(() => {
+ exporter.reset()
+ resetReportedErrorsForTests()
+ vi.restoreAllMocks()
+ trace.disable()
+ context.disable()
+})
+
+describe("with server OpenTelemetry", () => {
+ beforeEach(() => {
+ context.setGlobalContextManager(new AsyncLocalStorageContextManager().enable())
+ trace.setGlobalTracerProvider(
+ new BasicTracerProvider({ spanProcessors: [new SimpleSpanProcessor(exporter)] }),
+ )
+ })
+
+ it("names the request span after the route, under the HTTP span, with the loader under it", async () => {
+ const response = await serve("/projects/1")
+
+ const request = named("GET /projects/:id")
+ expect(parentOf(request)).toBe(idOf(named("GET")))
+ expect(parentOf(named("loader routes/project"))).toBe(idOf(request))
+ expect(request.attributes["http.response.status_code"]).toBe(200)
+ expect(request.status.code).toBe(SpanStatusCode.UNSET)
+ // The page load joins the request span
+ const { traceId, spanId } = request.spanContext()
+ expect(response.headers.get("server-timing")).toBe(`traceparent;desc="00-${traceId}-${spanId}-01"`)
+ })
+
+ it("traces a client navigation's data request, which carries no Server-Timing", async () => {
+ const response = await serve("/projects/1.data")
+
+ expect(response.headers.get("server-timing")).toBeNull()
+ expect(parentOf(named("loader routes/project"))).toBe(idOf(named("GET /projects/:id")))
+ })
+
+ it("records a loader error once, on the loader span", async () => {
+ await serve("/broken-loader")
+
+ expect(named("loader routes/broken-loader").status.code).toBe(SpanStatusCode.ERROR)
+ expect(spans().flatMap(exceptions)).toEqual(["loader exploded"])
+ // Logged as React Router's default `handleError` logs it
+ expect(consoleError).toHaveBeenCalledWith(expect.objectContaining({ message: "loader exploded" }))
+ })
+
+ it("records a render error on the request span, which fails with the 500", async () => {
+ const response = await serve("/broken-render")
+
+ expect(response.status).toBe(500)
+ const request = named("GET /broken-render")
+ expect(exceptions(request)).toEqual(["render exploded"])
+ expect(request.status.code).toBe(SpanStatusCode.ERROR)
+ expect(spans().flatMap(exceptions)).toHaveLength(1)
+ })
+
+ it("records nothing for a URL no route matches, and logs it as React Router does", async () => {
+ const response = await serve("/does-not-exist")
+
+ expect(response.status).toBe(404)
+ const request = named("GET /")
+ expect(request.attributes["http.response.status_code"]).toBe(404)
+ expect(request.status.code).toBe(SpanStatusCode.UNSET)
+ expect(spans().flatMap(exceptions)).toEqual([])
+ expect(consoleError).toHaveBeenCalledWith(
+ expect.objectContaining({ message: expect.stringContaining("No route matches") }),
+ )
+ })
+
+ it("neither logs nor records an aborted request's error", () => {
+ const aborted = new AbortController()
+ aborted.abort()
+ trace.getTracer("http").startActiveSpan("GET", (span) => {
+ handleError(new Error("client went away"), {
+ request: new Request("https://acme.test/", { signal: aborted.signal }),
+ context: undefined as never,
+ params: {},
+ })
+ span.end()
+ })
+
+ expect(consoleError).not.toHaveBeenCalled()
+ expect(spans().flatMap(exceptions)).toEqual([])
+ })
+})
+
+describe("without server OpenTelemetry", () => {
+ it("serves the page unchanged", async () => {
+ const response = await serve("/projects/1")
+
+ expect(response.status).toBe(200)
+ expect(response.headers.get("server-timing")).toBeNull()
+ expect(spans()).toEqual([])
+ })
+})
diff --git a/packages/browser/src/react-router/server.ts b/packages/browser/src/react-router/server.ts
new file mode 100644
index 0000000000..77e327dbf6
--- /dev/null
+++ b/packages/browser/src/react-router/server.ts
@@ -0,0 +1,53 @@
+// `@maple-dev/browser/react-router/server`: framework mode's server render.
+//
+// Spans from the global tracer, under the active one, so they nest under the
+// server's own OpenTelemetry setup (the Node SDK's HTTP span). The request
+// span is active while `handleRequest` renders the page, which is where
+// `serverTiming()` hands its trace to the browser's page load. Depends on
+// `@opentelemetry/api` and `react-router` only: safe in Node and Workers.
+import { SpanStatusCode, trace } from "@opentelemetry/api"
+import { type HandleErrorFunction, isRouteErrorResponse, type ServerInstrumentation } from "react-router"
+import { recordExceptionOnce } from "../failures"
+import { traced } from "../server"
+import { SDK_NAME, SDK_VERSION } from "../version"
+import { routePattern, traceRouteHandlers } from "./route"
+
+/**
+ * Add to `instrumentations` in `entry.server.tsx`: a span per request, named after its route
+ * (`GET /projects/:id`), with a span per loader and action under it.
+ */
+export const serverInstrumentation: ServerInstrumentation = {
+ handler: ({ instrument }) =>
+ instrument({
+ // Page requests, and the `.data` requests of client-side navigations
+ request: (handle, { request }) =>
+ // Per request, not cached: the app may register its provider after this module loads
+ trace.getTracer(SDK_NAME, SDK_VERSION).startActiveSpan(request.method, async (span) => {
+ try {
+ // `meta` and `statusCode` since React Router 8.1
+ const { meta, statusCode } = await handle()
+ if (meta) span.updateName(`${request.method} ${routePattern(meta.pattern)}`)
+ span.setAttribute("http.response.status_code", statusCode)
+ if (statusCode >= 500) span.setStatus({ code: SpanStatusCode.ERROR })
+ } finally {
+ span.end()
+ }
+ }),
+ }),
+ route: traceRouteHandlers(traced),
+}
+
+/**
+ * Export from `entry.server.tsx`, or call from your own: logs like React Router's default,
+ * and records render errors, which reach no loader span, on the request span.
+ */
+export const handleError: HandleErrorFunction = (error, { request }) => {
+ // An aborted request isn't a failure
+ if (request.signal.aborted) return
+ // A 404 for a URL no route matches carries the error it logs
+ console.error(isRouteErrorResponse(error) && "error" in error && error.error ? error.error : error)
+ // Thrown responses, like a 404, are expected. A loader error is already on its
+ // span. The request span's status follows the response: a render error is a 500.
+ const span = trace.getActiveSpan()
+ if (span && !isRouteErrorResponse(error)) recordExceptionOnce(span, error)
+}
diff --git a/packages/browser/tsdown.config.ts b/packages/browser/tsdown.config.ts
index 0a3e217b73..d5cef997d9 100644
--- a/packages/browser/tsdown.config.ts
+++ b/packages/browser/tsdown.config.ts
@@ -6,6 +6,8 @@ export default defineConfig({
server: "./src/server.ts",
nextjs: "./src/nextjs/index.ts",
"nextjs-server": "./src/nextjs/server.ts",
+ "react-router": "./src/react-router/index.ts",
+ "react-router-server": "./src/react-router/server.ts",
},
format: "esm",
// Types are emitted by tsgo in one pass rooted at the tsconfig's directory,