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,