diff --git a/bun.lock b/bun.lock index 9b1e1a67b..dff6c2859 100644 --- a/bun.lock +++ b/bun.lock @@ -657,12 +657,22 @@ }, "devDependencies": { "@maple/browser-session": "workspace:*", + "@types/react": "catalog:react", + "@types/react-dom": "catalog:react", "@vitest/browser-playwright": "catalog:", "playwright": "catalog:", + "react": "catalog:react", + "react-dom": "catalog:react", "tsdown": "^0.23.0", "typescript": "catalog:tooling", "vitest": "catalog:", }, + "peerDependencies": { + "react": ">=18", + }, + "optionalPeers": [ + "react", + ], }, "packages/browser-session": { "name": "@maple/browser-session", diff --git a/docs/browser-sdk.md b/docs/browser-sdk.md index 8ebc37937..dddf6b381 100644 --- a/docs/browser-sdk.md +++ b/docs/browser-sdk.md @@ -514,6 +514,43 @@ createRoot(document.getElementById("root")!).render() In Next.js, run the import from a client component mounted high in the tree (e.g. the root layout), since the SDK is browser-only. +### React integration + +`@maple-dev/browser/react` adds an error boundary, a React 19 root error handler, and adapters that +span navigations straight from your router. `react` is an optional peer dependency; routers are +typed structurally, so no router package is required. + +```tsx +import { createBrowserRouter, RouterProvider } from "react-router" +import { instrumentReactRouter, mapleReactErrorHandler, MapleErrorBoundary } from "@maple-dev/browser/react" + +const router = createBrowserRouter(routes) +instrumentReactRouter(router) + +createRoot(document.getElementById("root")!, { + onCaughtError: mapleReactErrorHandler(), + onUncaughtError: mapleReactErrorHandler(), +}).render( + }> + + , +) +``` + +- `MapleErrorBoundary` reports a render error once (as a `react.render_error` span with the + component stack in `maple.react.component_stack`) and renders `fallback`, which may be a node or a + function of `{ error, reset }`. +- `mapleReactErrorHandler()` fits React 19's `onCaughtError`, `onUncaughtError` and + `onRecoverableError` root options. An error already reported by a boundary is not reported again. +- `instrumentReactRouter(router)` takes a data router (`createBrowserRouter` and friends). A + navigation starts when the router starts loading and ends when its loaders settle, named by the + route template (`navigate /projects/:id`). The first is the `pageload`. +- `instrumentTanStackRouter(router)` does the same from `onBeforeNavigate` to `onResolved`, named by + the leaf route's full path (`navigate /projects/$projectId`). Search-only changes are not + navigations. + +Both adapters return an unsubscribe. Don't also call `startNavigation`/`endNavigation` yourself. + ## Notes - Replay event blobs live in object storage. Only small, queryable metadata is indexed, and playback streams blobs directly via signed URLs. diff --git a/packages/browser/README.md b/packages/browser/README.md index e1ba2eca0..0d896558f 100644 --- a/packages/browser/README.md +++ b/packages/browser/README.md @@ -199,6 +199,18 @@ MapleBrowser.endNavigation("/projects/:id") // the route is ready: its template - All three are no-ops on the server, before `init()`, with tracing disabled or without consent (`traced` then only runs `fn`). +## React + +`@maple-dev/browser/react` has `MapleErrorBoundary`, `mapleReactErrorHandler()` for React 19's +`createRoot` error options, and router adapters that call `startNavigation`/`endNavigation` for you: + +```ts +import { instrumentReactRouter, instrumentTanStackRouter } from "@maple-dev/browser/react" + +instrumentReactRouter(createBrowserRouter(routes)) // navigate /projects/:id +instrumentTanStackRouter(router) // navigate /projects/$projectId +``` + ## 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 9c2b600b0..22470bc73 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -30,6 +30,10 @@ ".": { "types": "./dist/index.d.mts", "import": "./dist/index.mjs" + }, + "./react": { + "types": "./dist/react.d.mts", + "import": "./dist/react.mjs" } }, "publishConfig": { @@ -58,10 +62,22 @@ }, "devDependencies": { "@maple/browser-session": "workspace:*", + "@types/react": "catalog:react", + "@types/react-dom": "catalog:react", "@vitest/browser-playwright": "catalog:", "playwright": "catalog:", + "react": "catalog:react", + "react-dom": "catalog:react", "tsdown": "^0.23.0", "typescript": "catalog:tooling", "vitest": "catalog:" + }, + "peerDependencies": { + "react": ">=18" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } } } diff --git a/packages/browser/src/react.browser.test.ts b/packages/browser/src/react.browser.test.ts new file mode 100644 index 000000000..e280de15c --- /dev/null +++ b/packages/browser/src/react.browser.test.ts @@ -0,0 +1,218 @@ +// TEST-SEAM: This focused test replaces process-global modules that have no instance-level injection seam. +import type { ReadableSpan } from "@opentelemetry/sdk-trace-base" +import { createElement, type ReactNode } from "react" +import { flushSync } from "react-dom" +import { createRoot } from "react-dom/client" +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 { resetNavigationForTests } = await import("./navigation") +const { resetReportedErrorsForTests } = await import("./errors") +const { MapleErrorBoundary, instrumentReactRouter, instrumentTanStackRouter, mapleReactErrorHandler } = + await import("./react") +type ReactRouterState = import("./react").ReactRouterState + +let handle: ReturnType | undefined +const stop = async (): Promise => { + await handle?.shutdown() + handle = undefined +} +const TIMING_SPANS = new Set([ + "documentFetch", + "dns", + "connect", + "request", + "response", + "domProcessing", + "loadEvent", +]) +const spanNames = () => exported.filter((span) => !TIMING_SPANS.has(span.name)).map((span) => span.name) + +beforeEach(() => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response("{}")), + ) + handle = MapleBrowser.init({ + ingestKey: "k", + serviceName: "web", + endpoint: "https://ingest.test", + replay: { enabled: false }, + tracing: { instrumentFetch: false, instrumentXhr: false }, + webVitals: false, + breadcrumbs: false, + }) +}) + +afterEach(async () => { + await stop() + exported.length = 0 + resetNavigationForTests() + resetReportedErrorsForTests() + vi.unstubAllGlobals() +}) + +describe("MapleErrorBoundary", () => { + it("reports a render error once with its component stack, renders the fallback, and resets", async () => { + let shouldThrow = true + const Broken = (): ReactNode => { + if (shouldThrow) throw new Error("render failed") + return createElement("p", null, "recovered") + } + let resetBoundary = (): void => {} + const container = document.createElement("div") + const root = createRoot(container) + // React logs caught errors; keep the test output quiet. + vi.spyOn(console, "error").mockImplementation(() => {}) + flushSync(() => + root.render( + createElement(MapleErrorBoundary, { + fallback: ({ reset }: { reset: () => void }) => { + resetBoundary = reset + return createElement("p", null, "fallback") + }, + children: createElement(Broken), + }), + ), + ) + expect(container.textContent).toBe("fallback") + + shouldThrow = false + flushSync(() => resetBoundary()) + expect(container.textContent).toBe("recovered") + root.unmount() + await stop() + + const reported = exported.filter((span) => span.name === "react.render_error") + expect(reported).toHaveLength(1) + expect(reported[0]?.attributes["maple.exception.source"]).toBe("react.error_boundary") + expect(String(reported[0]?.attributes["maple.react.component_stack"])).toContain("Broken") + }) +}) + +describe("mapleReactErrorHandler", () => { + it("reports what a React 19 root caught, once", async () => { + const onCaughtError = mapleReactErrorHandler() + const error = new Error("caught by root") + onCaughtError(error, { componentStack: "\n at Widget" }) + onCaughtError(error, { componentStack: "\n at Widget" }) + await stop() + const reported = exported.filter((span) => span.name === "react.render_error") + expect(reported).toHaveLength(1) + expect(reported[0]?.attributes["maple.exception.source"]).toBe("react.root") + expect(reported[0]?.attributes["maple.react.component_stack"]).toBe("at Widget") + }) +}) + +/** A React Router data router, reduced to what the adapter reads. */ +function fakeReactRouter(initial: ReactRouterState) { + const listeners = new Set<(state: ReactRouterState) => void>() + const router = { + state: initial, + subscribe(listener: (state: ReactRouterState) => void) { + listeners.add(listener) + return () => listeners.delete(listener) + }, + set(next: Partial) { + router.state = { ...router.state, ...next } + for (const listener of listeners) listener(router.state) + }, + } + return router +} + +const idle = { state: "idle" } +const projectMatches = [{ route: { path: "/" } }, { route: { path: "projects/:id" } }] + +describe("instrumentReactRouter", () => { + it("spans the page load until the router initializes, then each loading navigation by template", async () => { + const router = fakeReactRouter({ + initialized: false, + location: { pathname: "/" }, + navigation: idle, + matches: [{ route: { path: "/" } }], + }) + const unsubscribe = instrumentReactRouter(router) + router.set({ initialized: true }) + router.set({ navigation: { state: "loading", location: { pathname: "/projects/42" } } }) + router.set({ navigation: idle, location: { pathname: "/projects/42" }, matches: projectMatches }) + unsubscribe() + await stop() + expect(spanNames()).toEqual(["pageload /", "navigate /projects/:id"]) + }) + + it("spans a navigation to a route without loaders, which never enters loading", async () => { + const router = fakeReactRouter({ + initialized: true, + location: { pathname: "/" }, + navigation: idle, + matches: [{ route: { path: "/" } }], + }) + const unsubscribe = instrumentReactRouter(router) + router.set({ location: { pathname: "/projects/7" }, matches: projectMatches }) + unsubscribe() + await stop() + expect(spanNames()).toEqual(["pageload /", "navigate /projects/:id"]) + }) +}) + +type TanStackEvent = { toLocation: { pathname: string }; pathChanged: boolean } + +function fakeTanStackRouter() { + const listeners = new Map void>>() + const router = { + state: { + status: "pending", + location: { pathname: "/" }, + matches: [] as Array<{ fullPath?: string; routeId: string }>, + }, + subscribe(eventType: "onBeforeNavigate" | "onResolved", listener: (event: TanStackEvent) => void) { + const set = listeners.get(eventType) ?? new Set() + set.add(listener) + listeners.set(eventType, set) + return () => set.delete(listener) + }, + emit(eventType: string, event: TanStackEvent) { + for (const listener of listeners.get(eventType) ?? []) listener(event) + }, + } + return router +} + +describe("instrumentTanStackRouter", () => { + it("keeps the initial load as the page load and ignores search-only changes", async () => { + const router = fakeTanStackRouter() + const unsubscribe = instrumentTanStackRouter(router) + // The router's own initial load re-announces the page load. + router.emit("onBeforeNavigate", { toLocation: { pathname: "/" }, pathChanged: true }) + router.state.matches = [{ routeId: "__root__" }, { fullPath: "/", routeId: "/" }] + router.emit("onResolved", { toLocation: { pathname: "/" }, pathChanged: true }) + + router.emit("onBeforeNavigate", { toLocation: { pathname: "/" }, pathChanged: false }) + router.emit("onBeforeNavigate", { toLocation: { pathname: "/projects/42" }, pathChanged: true }) + router.state.matches = [ + { routeId: "__root__" }, + { fullPath: "/projects/$projectId", routeId: "/projects/$projectId" }, + ] + router.emit("onResolved", { toLocation: { pathname: "/projects/42" }, pathChanged: true }) + unsubscribe() + await stop() + expect(spanNames()).toEqual(["pageload /", "navigate /projects/$projectId"]) + }) +}) diff --git a/packages/browser/src/react.ts b/packages/browser/src/react.ts new file mode 100644 index 000000000..dc0ccb413 --- /dev/null +++ b/packages/browser/src/react.ts @@ -0,0 +1,190 @@ +// `@maple-dev/browser/react`: an error boundary, a React 19 root error +// handler, and router adapters that drive `startNavigation`/`endNavigation` +// from the router itself. Routers are typed structurally, so this entry does +// not depend on any router package. +import { Component, type ErrorInfo, type ReactNode } from "react" +import { MapleBrowser } from "./index" + +/** Component stacks can run to hundreds of lines; the top of it names the failing tree. */ +const MAX_COMPONENT_STACK = 2_000 + +function reportReactError(error: unknown, componentStack: string | null | undefined, source: string): void { + MapleBrowser.captureException(error, { + name: "react.render_error", + attributes: { + "maple.exception.source": source, + ...(componentStack + ? { "maple.react.component_stack": componentStack.trim().slice(0, MAX_COMPONENT_STACK) } + : undefined), + }, + }) +} + +export interface MapleErrorBoundaryFallbackProps { + // BOUNDARY: a thrown value is unparsed by definition. + readonly error: unknown + /** Clear the error and render the children again. */ + readonly reset: () => void +} + +export interface MapleErrorBoundaryProps { + readonly children?: ReactNode + /** Rendered instead of the children after an error. Default: nothing. */ + readonly fallback?: ReactNode | ((props: MapleErrorBoundaryFallbackProps) => ReactNode) + /** Called after the error is reported. */ + readonly onError?: (error: unknown, info: ErrorInfo) => void +} + +interface MapleErrorBoundaryState { + readonly failed: boolean + readonly error: unknown +} + +/** Reports render errors below it to Maple once, then renders `fallback`. */ +export class MapleErrorBoundary extends Component { + override state: MapleErrorBoundaryState = { failed: false, error: undefined } + + static getDerivedStateFromError(error: unknown): MapleErrorBoundaryState { + return { failed: true, error } + } + + override componentDidCatch(error: unknown, info: ErrorInfo): void { + reportReactError(error, info.componentStack, "react.error_boundary") + this.props.onError?.(error, info) + } + + private readonly reset = (): void => { + this.setState({ failed: false, error: undefined }) + } + + override render(): ReactNode { + if (!this.state.failed) return this.props.children ?? null + const { fallback } = this.props + return typeof fallback === "function" + ? fallback({ error: this.state.error, reset: this.reset }) + : (fallback ?? null) + } +} + +/** + * For React 19's `createRoot(el, { onCaughtError, onUncaughtError, onRecoverableError })`: + * reports what React caught, with its component stack. Errors already reported + * (by a `MapleErrorBoundary`, say) are not reported twice. + */ +export function mapleReactErrorHandler( + source = "react.root", +): (error: unknown, info: { readonly componentStack?: string | null | undefined }) => void { + return (error, info) => reportReactError(error, info.componentStack, source) +} + +/** A React Router data router (`createBrowserRouter` and friends), as far as Maple reads it. */ +export interface ReactRouterLike { + readonly state: ReactRouterState + subscribe(listener: (state: ReactRouterState) => void): () => void +} + +export interface ReactRouterState { + readonly initialized: boolean + readonly location: { readonly pathname: string } + readonly navigation: { + readonly state: string + readonly location?: { readonly pathname: string } | undefined + } + readonly matches: ReadonlyArray<{ readonly route: { readonly path?: string | undefined } }> +} + +/** `/projects/:id/settings`, from the matched routes' own path segments. */ +function reactRouterTemplate(state: ReactRouterState): string { + let template = "" + for (const match of state.matches) { + const path = match.route.path + if (!path) continue + template = path.startsWith("/") ? path : `${template.replace(/\/$/, "")}/${path}` + } + return template || "/" +} + +/** + * Span each navigation of a React Router data router, from the moment it starts + * loading until its loaders settle, named by route template. The first is the + * page load. Returns an unsubscribe. + */ +export function instrumentReactRouter(router: ReactRouterLike): () => void { + let open = false + let pathname = router.state.location.pathname + const start = (path: string): void => { + MapleBrowser.startNavigation(path) + open = true + } + const end = (state: ReactRouterState): void => { + if (!open) return + MapleBrowser.endNavigation(reactRouterTemplate(state)) + open = false + } + start(pathname) + if (router.state.initialized && router.state.navigation.state === "idle") end(router.state) + return router.subscribe((state) => { + const target = state.navigation.location?.pathname + if (state.navigation.state !== "idle" && target !== undefined) { + if (!open || target !== pathname) start(target) + pathname = target + return + } + if (!state.initialized) return + // A route without loaders never enters `loading`: the location just changes. + if (!open && state.location.pathname !== pathname) start(state.location.pathname) + pathname = state.location.pathname + end(state) + }) +} + +/** A TanStack Router instance, as far as Maple reads it. */ +export interface TanStackRouterLike { + readonly state: { + readonly status: string + readonly location: { readonly pathname: string } + readonly matches: ReadonlyArray<{ readonly fullPath?: string | undefined; readonly routeId: string }> + } + subscribe( + eventType: "onBeforeNavigate" | "onResolved", + listener: (event: { + readonly toLocation: { readonly pathname: string } + readonly pathChanged: boolean + }) => void, + ): () => void +} + +/** + * Span each TanStack Router navigation from `onBeforeNavigate` to `onResolved`, + * named by the leaf route's full path (`/projects/$projectId`). Search-only + * changes are not navigations. The first is the page load. Returns an unsubscribe. + */ +export function instrumentTanStackRouter(router: TanStackRouterLike): () => void { + /** The path of the navigation in flight, if any. */ + let open: string | undefined + const template = (): string => { + const leaf = router.state.matches.at(-1) + return leaf?.fullPath || leaf?.routeId || "/" + } + open = router.state.location.pathname + MapleBrowser.startNavigation(open) + if (router.state.status === "idle" && router.state.matches.length > 0) { + MapleBrowser.endNavigation(template()) + open = undefined + } + const stopBefore = router.subscribe("onBeforeNavigate", (event) => { + // The router's own initial load re-announces the page load already open. + if (!event.pathChanged || event.toLocation.pathname === open) return + open = event.toLocation.pathname + MapleBrowser.startNavigation(open) + }) + const stopResolved = router.subscribe("onResolved", () => { + if (open === undefined) return + MapleBrowser.endNavigation(template()) + open = undefined + }) + return () => { + stopBefore() + stopResolved() + } +} diff --git a/packages/browser/tsdown.config.ts b/packages/browser/tsdown.config.ts index 8938a40e3..2202c38f6 100644 --- a/packages/browser/tsdown.config.ts +++ b/packages/browser/tsdown.config.ts @@ -3,6 +3,7 @@ import { defineConfig } from "tsdown" export default defineConfig({ entry: { index: "./src/index.ts", + react: "./src/react.ts", }, format: "esm", // Types are emitted by tsgo in one pass rooted at the tsconfig's directory,