diff --git a/apps/landing/src/content/docs/session-replay/browser-sdk.md b/apps/landing/src/content/docs/session-replay/browser-sdk.md index 020fe71f9..ba9007aa1 100644 --- a/apps/landing/src/content/docs/session-replay/browser-sdk.md +++ b/apps/landing/src/content/docs/session-replay/browser-sdk.md @@ -251,7 +251,94 @@ MapleBrowser.endNavigation("/projects/:id") The browser has no async context: only requests `fn` starts before its first `await` nest under its span. Start independent requests together, with `Promise.all`. -All three do nothing on the server, before `init()`, with tracing disabled or before consent is granted; `traced` then only runs `fn`. A page load that happened before consent isn't traced later: the next navigation is a `navigate` span. Leaving the page or calling `shutdown()` ends an open navigation as interrupted, so it still exports. +In the browser, all three do nothing before `init()`, with tracing disabled or before consent is granted; `traced` then only runs `fn`. A page load that happened before consent isn't traced later: the next navigation is a `navigate` span. Leaving the page or calling `shutdown()` ends an open navigation as interrupted, so it still exports. + +On the server, `startNavigation` and `endNavigation` do nothing, and `traced` works like the server version below. + +## Server-side data loading + +`@maple-dev/browser/server` is for server code: Server Components, SSR loaders and resolvers, and your framework's response hook. It depends only on `@opentelemetry/api`, so it runs in Node, edge runtimes and Workers. + +```ts +import { serverTiming, traced } from "@maple-dev/browser/server" + +// a span under the active server span, like the request or render span +const project = await traced("db.query project", () => db.project.find(id)) + +// in the hook that sets response headers for rendered pages +const value = serverTiming() +if (value) headers.append("server-timing", value) +``` + +- `traced(name, fn, options?)` runs `fn` in a span from the global tracer your server registered (`@vercel/otel`, the OpenTelemetry Node SDK), under the active span. The server keeps the parent across `await`, so requests `fn` makes after an `await` nest under it too. It takes the same `isFailure` option as the browser version, and records an error once. Without server OpenTelemetry it only runs `fn`. +- `serverTiming()` returns the `Server-Timing` value for the active span, `traceparent;desc="00-…"`, or `undefined` when no span is active. Sent on the HTML response, it joins the browser's `pageload` span to the server's trace. Leave it off responses a shared cache stores, or every visitor joins the same trace. The browser follows the server's sampling decision: a page load under an unsampled server trace isn't recorded. + +## Next.js integration + +`@maple-dev/browser/nextjs` connects the App Router (Next.js 15.3 or later) to the navigation spans, and `@maple-dev/browser/nextjs/server` joins the first page load to the server render. Start and end the spans: + +```ts +// src/instrumentation-client.ts +import { MapleBrowser } from "@maple-dev/browser" + +MapleBrowser.init({ ingestKey: "maple_pk_...", serviceName: "acme-web" }) + +// Next.js only reports client-side navigations, so the page load starts here +MapleBrowser.startNavigation(location.pathname) + +export { onRouterTransitionStart } from "@maple-dev/browser/nextjs" +``` + +```tsx +// src/app/layout.tsx +import { MapleNavigation } from "@maple-dev/browser/nextjs" + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + + {children} + + + ) +} +``` + +Report what your error boundaries catch, in `error.tsx` and `global-error.tsx`: + +```tsx +// src/app/error.tsx +"use client" + +import { reportNextError } from "@maple-dev/browser/nextjs" +import { useEffect } from "react" + +export default function ErrorPage({ error }: { error: Error & { digest?: string } }) { + useEffect(() => reportNextError(error), [error]) + return

Something went wrong

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

Something went wrong

+} +``` + +```ts +// src/proxy.ts (middleware.ts before Next.js 16): joins the page load to the render +import { withMapleProxy } from "@maple-dev/browser/nextjs/server" + +export const proxy = withMapleProxy() // or withMapleProxy(yourProxy) +export const config = { matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"] } +``` + +- Spans are named after the route, like `navigate /projects/[id]`; a URL no + route matches is `/_not-found`. Hash links start no span. +- `reportNextError` skips server errors (they arrive with a `digest`, and Next.js + already recorded them on its server span) and errors `traced` recorded. +- `withMapleProxy` keeps a `traceparent` the request already carries (client + navigations), and only touches responses that go on to a render in this app: + your redirects and responses pass through unchanged. Leave prerendered pages a + shared cache (CDN) stores out of the matcher, or every visitor joins one trace. +- The browser follows the server's sampling decision: a page load under an + unsampled server trace isn't recorded. ## Linking a marketing site to your app diff --git a/packages/browser/package.json b/packages/browser/package.json index 68af26e7b..0dac2b336 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -30,6 +30,18 @@ ".": { "types": "./dist/index.d.mts", "import": "./dist/index.mjs" + }, + "./server": { + "types": "./dist/server.d.mts", + "import": "./dist/server.mjs" + }, + "./nextjs": { + "types": "./dist/nextjs.d.mts", + "import": "./dist/nextjs.mjs" + }, + "./nextjs/server": { + "types": "./dist/nextjs-server.d.mts", + "import": "./dist/nextjs-server.mjs" } }, "publishConfig": { @@ -54,10 +66,28 @@ }, "devDependencies": { "@maple/browser-session": "workspace:*", + "@opentelemetry/context-async-hooks": "^2.11.0", + "@types/react": "catalog:react", + "@types/react-dom": "catalog:react", "@vitest/browser-playwright": "catalog:", + "next": "^16.3.6", "playwright": "catalog:", + "react": "catalog:react", + "react-dom": "catalog:react", "tsdown": "^0.23.0", "typescript": "catalog:tooling", "vitest": "catalog:" + }, + "peerDependencies": { + "next": "*", + "react": "*" + }, + "peerDependenciesMeta": { + "next": { + "optional": true + }, + "react": { + "optional": true + } } } diff --git a/packages/browser/src/errors.browser.test.ts b/packages/browser/src/errors.browser.test.ts index dd1dd1ff0..0168e5fc4 100644 --- a/packages/browser/src/errors.browser.test.ts +++ b/packages/browser/src/errors.browser.test.ts @@ -2,7 +2,8 @@ import { assert, beforeEach, describe, it } from "vitest" import { SpanStatusCode, trace } from "@opentelemetry/api" import { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base" import type { ReadableSpan } from "@opentelemetry/sdk-trace-base" -import { captureException, resetReportedErrorsForTests, setupErrorCapture } from "./errors" +import { captureException, setupErrorCapture } from "./errors" +import { resetReportedErrorsForTests } from "./failures" const exporter = new InMemorySpanExporter() diff --git a/packages/browser/src/errors.ts b/packages/browser/src/errors.ts index c8424ea6f..ed4ec6aa2 100644 --- a/packages/browser/src/errors.ts +++ b/packages/browser/src/errors.ts @@ -10,7 +10,8 @@ // Error. That is the shape `error_events_mv` fingerprints on, so these arrive in // error tracking beside server-side errors rather than in a separate silo. import { scrubUrl } from "@maple/browser-session" -import { type Span, SpanKind, SpanStatusCode } from "@opentelemetry/api" +import { SpanKind } from "@opentelemetry/api" +import { alreadyReported, recordFailure } from "./failures" import { mapleTracer } from "./tracing" import { SDK_NAME, SDK_VERSION } from "./version" @@ -21,56 +22,6 @@ export interface CaptureExceptionOptions { readonly attributes?: Record | undefined } -const asError = (value: unknown): Error => { - if (value instanceof Error) return value - if (typeof value === "string") return new Error(value) - if (typeof value === "object" && value !== null) { - const message = (value as { readonly message?: unknown }).message - if (typeof message === "string") return new Error(message) - } - // A rejected promise can carry literally anything. `String` keeps a number or - // a boolean legible; an unrenderable object still produces one grouped issue - // rather than throwing inside the error handler. - try { - return new Error(String(value)) - } catch { - return new Error("Unknown error") - } -} - -/** - * Errors already reported, by identity. Module-level so the global handlers and - * `captureException` share it: a framework boundary that reports an error and - * then rethrows it would otherwise produce two issues for one crash. - */ -let reported = new WeakSet() - -/** Whether this exact error object was already recorded. */ -const alreadyReported = (error: unknown): boolean => - typeof error === "object" && error !== null && reported.has(error) - -/** Test seam. */ -export function resetReportedErrorsForTests(): void { - reported = new WeakSet() -} - -/** - * Mark `span` as failed by `error`. The exception event goes on the first span - * that records this error object; a later one (an outer `traced`, say) only - * takes the Error status, so one error stays one issue. The error is claimed - * only when the span is recording: before `init()` the tracer is a no-op, and - * claiming it then would swallow the same error reported again once tracing is - * live. - */ -export function recordFailure(span: Span, error: unknown): void { - const normalized = asError(error) - if (!alreadyReported(error)) { - if (span.isRecording() && typeof error === "object" && error !== null) reported.add(error) - span.recordException(normalized) - } - span.setStatus({ code: SpanStatusCode.ERROR, message: normalized.message }) -} - /** Record `error` on a one-off span. */ function recordException(error: unknown, options: CaptureExceptionOptions): void { const span = mapleTracer(SDK_NAME, SDK_VERSION).startSpan(options.name ?? "exception", { diff --git a/packages/browser/src/failures.ts b/packages/browser/src/failures.ts new file mode 100644 index 000000000..7a51653f7 --- /dev/null +++ b/packages/browser/src/failures.ts @@ -0,0 +1,90 @@ +// Recording failures on spans, shared by the browser and server entries. +// +// Depends on `@opentelemetry/api` only: the `/server` entry imports it, and +// must stay free of anything browser-only. +import { type Span, SpanStatusCode } from "@opentelemetry/api" + +export interface TracedOptions { + /** Return `false` for throws that aren't errors, like redirects or not-found. Default: every throw is an error. */ + // BOUNDARY: a thrown value is unparsed by definition; the app narrows it. + readonly isFailure?: ((error: unknown) => boolean) | undefined +} + +const asError = (value: unknown): Error => { + if (value instanceof Error) return value + if (typeof value === "string") return new Error(value) + if (typeof value === "object" && value !== null) { + const message = (value as { readonly message?: unknown }).message + if (typeof message === "string") return new Error(message) + } + // A rejected promise can carry literally anything. `String` keeps a number or + // a boolean legible; an unrenderable object still produces one grouped issue + // rather than throwing inside the error handler. + try { + return new Error(String(value)) + } catch { + return new Error("Unknown error") + } +} + +/** + * Errors already recorded, by identity. Module-level so the global handlers, + * `captureException` and `traced` share it: a framework boundary that reports + * an error and then rethrows it would otherwise produce two issues for one + * crash. + * + * Once per error object, on a server too, where this outlives requests. Per + * trace would record an error again whenever it crosses into another trace, and + * the browser loses the trace at every `await`. The cost is an error object + * shared by many requests, like a memoized promise's rejection, recorded by + * `traced` on the first only; the framework's own spans still record the rest. + */ +let reported = new WeakSet() + +const isObject = (value: unknown): value is object => typeof value === "object" && value !== null + +/** Whether this exact error object was already recorded. */ +export const alreadyReported = (error: unknown): boolean => isObject(error) && reported.has(error) + +/** Test seam. */ +export function resetReportedErrorsForTests(): void { + reported = new WeakSet() +} + +/** + * Mark `span` as failed by `error`. The exception event goes on the first span + * that records this error object; a later one (an outer `traced`, say) only + * takes the Error status, so one error stays one issue. The error is claimed + * only when the span is recording: before `init()` the tracer is a no-op, and + * claiming it then would swallow the same error reported again once tracing is + * live. + */ +export function recordFailure(span: Span, error: unknown): void { + const normalized = asError(error) + if (!alreadyReported(error)) { + if (span.isRecording() && isObject(error)) reported.add(error) + span.recordException(normalized) + } + span.setStatus({ code: SpanStatusCode.ERROR, message: normalized.message }) +} + +/** 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 { + return await fn() + } catch (error) { + if (isFailure(options, error)) recordFailure(span, error) + throw error + } finally { + span.end() + } +} + +/** `isFailure` is the app's code: if it throws, the original error still propagates. */ +function isFailure(options: TracedOptions, error: unknown): boolean { + try { + return options.isFailure?.(error) ?? true + } catch { + return true + } +} diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 8680c9161..2d18c9662 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -1,8 +1,9 @@ import { type IdentifyInput, setConsent, type TrackProps, track } from "@maple/browser-session" import type { MapleBrowserConfig } from "./config" import { type CaptureExceptionOptions, captureException } from "./errors" +import type { TracedOptions } from "./failures" import { identify, init, type MapleBrowserHandle } from "./init" -import { endNavigation, startNavigation, type TracedOptions, traced } from "./navigation" +import { endNavigation, startNavigation, traced } from "./navigation" export type { IdentifyInput, @@ -14,7 +15,7 @@ export type { export type { MapleBrowserConfig } from "./config" export type { CaptureExceptionOptions } from "./errors" export type { MapleBrowserHandle } from "./init" -export type { TracedOptions } from "./navigation" +export type { TracedOptions } from "./failures" /** The `MapleBrowser` namespace object. */ export interface MapleBrowserApi { @@ -50,6 +51,7 @@ export interface MapleBrowserApi { /** * Run data loading, like a route loader, in a span under the current navigation. * Errors are recorded once and rethrown. Only requests started before `fn`'s first `await` nest under the span. + * On the server, spans under the active server span through the global tracer. */ traced: (name: string, fn: () => Promise, options?: TracedOptions) => Promise } diff --git a/packages/browser/src/navigation.browser.test.ts b/packages/browser/src/navigation.browser.test.ts index 155fc6e66..7caf05304 100644 --- a/packages/browser/src/navigation.browser.test.ts +++ b/packages/browser/src/navigation.browser.test.ts @@ -32,7 +32,7 @@ vi.mock("@opentelemetry/exporter-trace-otlp-http", () => ({ })) const { MapleBrowser } = await import("./index") -const { resetReportedErrorsForTests } = await import("./errors") +const { resetReportedErrorsForTests } = await import("./failures") const { resetNavigationForTests } = await import("./navigation") type InitConfig = Parameters[0] diff --git a/packages/browser/src/navigation.ts b/packages/browser/src/navigation.ts index 9d3c63dac..26d2bc060 100644 --- a/packages/browser/src/navigation.ts +++ b/packages/browser/src/navigation.ts @@ -9,26 +9,17 @@ // Everything spans through Maple's own provider, never the global one (a host // app that registered its provider first owns that). Without a live provider // (before `init()`, after `shutdown()`, tracing disabled, consent not yet -// granted, or on a server) nothing is spanned and `traced` only runs `fn`. +// granted) nothing is spanned and `traced` only runs `fn`. On a server there +// are no navigations, and `traced` spans through the server's own global +// tracer instead (`./server`). import { hasConsent, scrubUrl } from "@maple/browser-session" -import { - type Context, - context, - isSpanContextValid, - type Span, - type SpanContext, - trace, -} from "@opentelemetry/api" -import { recordFailure } from "./errors" +import { type Context, context, type Span, trace } from "@opentelemetry/api" +import { runTraced, type TracedOptions } from "./failures" +import { traced as tracedOnServer } from "./server" +import { parseTraceparent } from "./traceparent" import { liveMapleTracer } from "./tracing" import { SDK_NAME, SDK_VERSION } from "./version" -export interface TracedOptions { - /** Return `false` for throws that aren't errors, like redirects or not-found. Default: every throw is an error. */ - // BOUNDARY: a thrown value is unparsed by definition; the app narrows it. - readonly isFailure?: ((error: unknown) => boolean) | undefined -} - /** * The navigation in flight. Only ever set in a browser: a server shares module * state across requests. @@ -58,7 +49,7 @@ const tracedSpans = new WeakSet() const tracer = () => (hasConsent() ? liveMapleTracer(SDK_NAME, SDK_VERSION) : undefined) /** End the open navigation as interrupted: something other than its route finishing ended it. */ -function interruptNavigation(): void { +export function interruptNavigation(): void { navigation?.span.setAttribute("app.navigation.interrupted", true) navigation?.span.end() navigation = undefined @@ -90,21 +81,16 @@ export function endNavigation(route?: string): void { } export async function traced(name: string, fn: () => Promise, options: TracedOptions = {}): Promise { + // A server has no navigation, and keeps the parent across `await` + if (typeof window === "undefined") return tracedOnServer(name, fn, options) const live = tracer() if (!live) return fn() // `fn` runs synchronously inside the span's context, so requests it starts // before its first `await` are children of the span. The browser has no // async context: anything after that `await` is not. - return live.startActiveSpan(name, {}, parentContext(), async (span) => { + return live.startActiveSpan(name, {}, parentContext(), (span) => { tracedSpans.add(span) - try { - return await fn() - } catch (error) { - if (isFailure(options, error)) recordFailure(span, error) - throw error - } finally { - span.end() - } + return runTraced(span, fn, options) }) } @@ -119,15 +105,6 @@ function parentContext(): Context { return trace.setSpan(active, navigation.span) } -/** `isFailure` is the app's code: if it throws, the original error still propagates. */ -function isFailure(options: TracedOptions, error: unknown): boolean { - try { - return options.isFailure?.(error) ?? true - } catch { - return true - } -} - /** * End the open navigation so it exports with the provider's last flush, and * start the next `init()` at a page load. Called by `shutdown()`. @@ -155,23 +132,3 @@ function serverContext(): Context | undefined { parseTraceparent(document.querySelector('meta[name="traceparent"]')?.content) return spanContext && trace.setSpanContext(context.active(), spanContext) } - -/** - * W3C `version-traceid-parentid-flags`. Parsed here rather than through the - * global propagator, which the host app may own, or may not have registered. - */ -const TRACEPARENT = /^([\da-f]{2})-([\da-f]{32})-([\da-f]{16})-([\da-f]{2})(-.*)?$/ - -function parseTraceparent(value: string | undefined): SpanContext | undefined { - const match = value?.trim().match(TRACEPARENT) - // Version ff is invalid; version 00 has exactly four fields - if (!match || match[1] === "ff" || (match[1] === "00" && match[5] !== undefined)) return undefined - const spanContext = { - traceId: match[2], - spanId: match[3], - traceFlags: Number.parseInt(match[4], 16), - isRemote: true, - } - // All-zero ids are well-formed but invalid; rejecting them lets the meta tag stand in - return isSpanContextValid(spanContext) ? spanContext : undefined -} diff --git a/packages/browser/src/nextjs/index.browser.test.ts b/packages/browser/src/nextjs/index.browser.test.ts new file mode 100644 index 000000000..f1310deae --- /dev/null +++ b/packages/browser/src/nextjs/index.browser.test.ts @@ -0,0 +1,251 @@ +// TEST-SEAM: This focused test replaces process-global modules that have no instance-level injection seam. +import { context, 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 { 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() + } + }, +})) + +/** What the App Router's hooks return for the route on screen. */ +const route = vi.hoisted(() => ({ + pathname: "/", + search: "", + params: {} as Record, + segments: [] as string[], +})) +vi.mock("next/navigation", () => ({ + usePathname: () => route.pathname, + useSearchParams: () => new URLSearchParams(route.search), + useParams: () => route.params, + useSelectedLayoutSegments: () => route.segments, +})) + +const { MapleBrowser } = await import("../index") +const { resetReportedErrorsForTests } = await import("../failures") +const { resetNavigationForTests } = await import("../navigation") +const { MapleNavigation, onRouterTransitionStart, reportNextError } = await import("./index") + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) + +let handle: ReturnType | undefined +let root: Root | undefined + +const stop = async (): Promise => { + await handle?.shutdown() + handle = undefined +} + +/** Commit a route, as the App Router does at the end of a navigation. */ +const commit = async (pathname: string, params: Record = {}, search = "") => { + // A new object per route, as `useParams()` returns + Object.assign(route, { pathname, search, params: { ...params }, segments: [] }) + await act(async () => { + root ??= createRoot(document.body.appendChild(document.createElement("div"))) + root.render(createElement(MapleNavigation)) + }) +} + +/** The first load, as `instrumentation-client.ts` starts it. */ +const pageLoad = async (pathname: string, params: Record = {}) => { + MapleBrowser.startNavigation(pathname) + await commit(pathname, params) +} + +const names = () => exported.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 }, + }) +}) + +afterEach(async () => { + act(() => root?.unmount()) + root = undefined + await stop() + exported.length = 0 + vi.unstubAllGlobals() + resetReportedErrorsForTests() + resetNavigationForTests() + trace.disable() + context.disable() +}) + +describe("MapleNavigation and onRouterTransitionStart", () => { + it("names the page load and each click after the route", async () => { + await pageLoad("/projects/8f2a", { id: "8f2a" }) + onRouterTransitionStart("/projects/9b1c") + await commit("/projects/9b1c", { id: "9b1c" }) + onRouterTransitionStart("/docs/guides/setup") + await commit("/docs/guides/setup", { slug: ["guides", "setup"] }) + await stop() + + expect(names()).toEqual([ + "pageload /projects/[id]", + "navigate /projects/[id]", + "navigate /docs/[...slug]", + ]) + expect(exported[1]?.attributes["url.path"]).toBe("/projects/9b1c") + }) + + it("takes an absolute URL, as back and forward pass it", async () => { + await pageLoad("/") + onRouterTransitionStart(new URL("/projects/1", location.href).href) + await commit("/projects/1", { id: "1" }) + await stop() + + expect(names()).toEqual(["pageload /", "navigate /projects/[id]"]) + }) + + it("starts nothing for a hash link or a link to the URL on screen", async () => { + await pageLoad("/projects/1", { id: "1" }) + onRouterTransitionStart("/projects/1#members") + onRouterTransitionStart("/projects/1") + onRouterTransitionStart("/projects/1?") + await stop() + + // A span left open would export on shutdown, as interrupted + expect(names()).toEqual(["pageload /projects/[id]"]) + }) + + it("ends a navigation abandoned by a link back to the route on screen", async () => { + await pageLoad("/projects/1", { id: "1" }) + onRouterTransitionStart("/slow") + // Back to the route on screen before /slow commits: nothing renders again + onRouterTransitionStart("/projects/1") + // Ended now: it no longer parents what runs next + await MapleBrowser.traced("query members", async () => undefined) + await stop() + + expect(names()).toEqual(["pageload /projects/[id]", "navigate", "query members"]) + expect(exported[1]?.attributes["app.navigation.interrupted"]).toBe(true) + expect(exported[2]?.parentSpanContext).toBeUndefined() + }) + + it("traces a query change, which renders the route again", async () => { + await pageLoad("/projects/1", { id: "1" }) + onRouterTransitionStart("/projects/1?tab=members") + await commit("/projects/1", { id: "1" }, "tab=members") + await stop() + + expect(names()).toEqual(["pageload /projects/[id]", "navigate /projects/[id]"]) + }) + + it("names a URL no route matches /_not-found", async () => { + MapleBrowser.startNavigation("/does-not-exist") + Object.assign(route, { + pathname: "/does-not-exist", + search: "", + params: {}, + segments: ["/_not-found"], + }) + await act(async () => { + root = createRoot(document.body.appendChild(document.createElement("div"))) + root.render(createElement(MapleNavigation)) + }) + await stop() + + expect(names()).toEqual(["pageload /_not-found"]) + }) + + it("ends a navigation interrupted by the next click as interrupted", async () => { + await pageLoad("/") + onRouterTransitionStart("/slow") + onRouterTransitionStart("/projects/1") + await commit("/projects/1", { id: "1" }) + await stop() + + expect(names()).toEqual(["pageload /", "navigate", "navigate /projects/[id]"]) + expect(exported[1]?.attributes["app.navigation.interrupted"]).toBe(true) + }) + + it("does not end a navigation in flight when the same route commits again", async () => { + await pageLoad("/projects/1", { id: "1" }) + onRouterTransitionStart("/settings") + // A server action revalidates the page on screen: a new params object, same route + await commit("/projects/1", { id: "1" }) + await commit("/settings") + await stop() + + expect(names()).toEqual(["pageload /projects/[id]", "navigate /settings"]) + }) + + it("does not end a navigation in flight when it renders again for the same route", async () => { + await pageLoad("/projects/1", { id: "1" }) + onRouterTransitionStart("/projects/2") + // The layout renders again before the new route commits + await act(async () => root?.render(createElement(MapleNavigation))) + await commit("/projects/2", { id: "2" }) + await stop() + + expect(names()).toEqual(["pageload /projects/[id]", "navigate /projects/[id]"]) + expect(exported[1]?.attributes["url.path"]).toBe("/projects/2") + }) +}) + +describe("reportNextError", () => { + const reported = () => exported.filter((span) => span.name === "react.render_error") + + it("reports a client error once", async () => { + const error = new Error("render exploded") + reportNextError(error) + // Strict Mode runs the boundary's effect twice + reportNextError(error) + await stop() + + expect(reported()).toHaveLength(1) + expect(reported()[0]?.events[0]?.attributes?.["exception.message"]).toBe("render exploded") + }) + + it("skips a server error, which arrives with a digest", async () => { + reportNextError( + Object.assign(new Error("An error occurred in the Server Components render."), { digest: "123" }), + ) + await stop() + + expect(reported()).toHaveLength(0) + }) + + it("skips an error a traced call already recorded", async () => { + const error = new Error("loader exploded") + await expect( + MapleBrowser.traced("loader", async () => { + throw error + }), + ).rejects.toBe(error) + reportNextError(error) + await stop() + + expect(reported()).toHaveLength(0) + expect(names()).toEqual(["loader"]) + }) + + it("reports a thrown value that isn't an Error", async () => { + reportNextError("plain string") + await stop() + + expect(reported()).toHaveLength(1) + }) +}) diff --git a/packages/browser/src/nextjs/index.ts b/packages/browser/src/nextjs/index.ts new file mode 100644 index 000000000..d5735a6d5 --- /dev/null +++ b/packages/browser/src/nextjs/index.ts @@ -0,0 +1,67 @@ +"use client" +// `@maple-dev/browser/nextjs`: App Router navigations and error boundaries. +// +// Next.js reports where a navigation starts (`onRouterTransitionStart` in +// `instrumentation-client.ts`) but not where it ends. That comes from React: a +// component in the root layout ends the span in an effect, which runs once the +// new route is committed. Both halves share `committed`. +// +// "use client": the root layout, a Server Component, renders `MapleNavigation`. +import { useParams, usePathname, useSearchParams, useSelectedLayoutSegments } from "next/navigation" +// An effect is the only signal that a route committed +// oxlint-disable-next-line maple/no-react-use-effect +import { createElement, type ReactElement, Suspense, useEffect } from "react" +import { captureException } from "../errors" +import { endNavigation, interruptNavigation, startNavigation } from "../navigation" +import { routeTemplate, urlKey } from "./route" + +/** Pathname and query of the route React last committed. */ +let committed: string | undefined + +/** Re-export from `instrumentation-client.ts`: starts a span for each App Router navigation. */ +export function onRouterTransitionStart(url: string): void { + const target = new URL(url, location.href) + // Hash-only changes and links to the current URL don't render a new route, and + // the effect that ends a navigation won't run: one still in flight, like a + // click away and straight back, is abandoned + if (urlKey(target.pathname, target.search) === committed) interruptNavigation() + else startNavigation(target.pathname) +} + +function NavigationEnd(): null { + const pathname = usePathname() + const search = useSearchParams().toString() + const params = useParams() + // URLs no route matches render Next.js's built-in `/_not-found` route: without + // this, every mistyped URL would become its own span name + const route = + useSelectedLayoutSegments()[0] === "/_not-found" ? "/_not-found" : routeTemplate(pathname, params) + + // The commit is the signal itself: effects run once the new route is on screen. + // Keyed on strings: `useParams()` returns a new object when the same route + // commits again (`router.refresh()`, a server action revalidating), which must + // not end a navigation to another route still in flight. + useEffect(() => { + committed = urlKey(pathname, search) + endNavigation(route) + }, [pathname, search, route]) + + return null +} + +/** Render once in the root layout, above `{children}`: ends each navigation span, named after its route. */ +export function MapleNavigation(): ReactElement { + // `useSearchParams()` outside a Suspense boundary fails the build of a statically rendered page + return createElement(Suspense, null, createElement(NavigationEnd)) +} + +/** + * Report the error an `error.tsx` or `global-error.tsx` boundary caught, from an effect. + * Skips server errors (those with a `digest`), which Next.js already recorded on its server span. + */ +export function reportNextError(error: unknown): void { + // In production a Server Component's error reaches the browser with its + // message stripped and a `digest` added: one meaningless issue for all of them + if (typeof error === "object" && error !== null && "digest" in error && error.digest) return + captureException(error, { name: "react.render_error" }) +} diff --git a/packages/browser/src/nextjs/route.test.ts b/packages/browser/src/nextjs/route.test.ts new file mode 100644 index 000000000..3a0016680 --- /dev/null +++ b/packages/browser/src/nextjs/route.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest" +import { routeTemplate, urlKey } from "./route" + +describe("routeTemplate", () => { + it.each([ + ["a dynamic segment", "/projects/8f2a", { id: "8f2a" }, "/projects/[id]"], + ["a static route", "/settings/billing", {}, "/settings/billing"], + ["the root", "/", {}, "/"], + ["several params", "/orgs/acme/projects/1", { org: "acme", id: "1" }, "/orgs/[org]/projects/[id]"], + ["a param equal to a static segment", "/projects/projects", { id: "projects" }, "/projects/[id]"], + ["two params with one value", "/a/1/b/1", { x: "1", y: "1" }, "/a/[x]/b/[y]"], + ["a catch-all", "/docs/guides/setup", { slug: ["guides", "setup"] }, "/docs/[...slug]"], + [ + "a catch-all after a param", + "/acme/docs/a/b", + { org: "acme", slug: ["a", "b"] }, + "/[org]/docs/[...slug]", + ], + [ + "a one-segment catch-all equal to the static segment", + "/shop/shop", + { slug: ["shop"] }, + "/shop/[...slug]", + ], + ["an empty optional catch-all", "/docs", { slug: undefined }, "/docs"], + ["an optional catch-all with no segments", "/docs", { slug: [] }, "/docs"], + ["a percent-encoded path", "/projects/caf%C3%A9", { id: "café" }, "/projects/[id]"], + ["an encoded slash", "/files/a%2Fb", { name: "a/b" }, "/files/[name]"], + ["a decoded path", "/projects/café", { id: "café" }, "/projects/[id]"], + ["a partly encoded path", "/users/a%20b@x.com", { email: "a b@x.com" }, "/users/[email]"], + ["a malformed escape", "/files/100%", { name: "100%" }, "/files/[name]"], + ["a trailing slash", "/projects/1/", { id: "1" }, "/projects/[id]/"], + ["a param the path doesn't contain", "/projects/1", { id: "1", tab: "members" }, "/projects/[id]"], + ])("handles %s", (_, pathname, params, expected) => { + expect(routeTemplate(pathname, params)).toBe(expected) + }) +}) + +describe("urlKey", () => { + it("ignores the hash and a bare ?", () => { + expect(urlKey("/a", "")).toBe(urlKey("/a", "?")) + expect(urlKey(new URL("https://x.test/a#top").pathname, new URL("https://x.test/a#top").search)).toBe( + urlKey("/a", ""), + ) + }) + + it("tells paths and queries apart", () => { + expect(urlKey("/a", "?tab=1")).not.toBe(urlKey("/a", "?tab=2")) + expect(urlKey("/a", "")).not.toBe(urlKey("/b", "")) + // `useSearchParams().toString()` has no `?`, `URL.search` does + expect(urlKey("/a", "tab=1")).toBe(urlKey("/a", "?tab=1")) + }) +}) diff --git a/packages/browser/src/nextjs/route.ts b/packages/browser/src/nextjs/route.ts new file mode 100644 index 000000000..d661662c9 --- /dev/null +++ b/packages/browser/src/nextjs/route.ts @@ -0,0 +1,43 @@ +/** A route's dynamic params, as `useParams()` returns them. */ +type RouteParams = Readonly> + +/** Pathname and query: what renders a route. The hash doesn't. */ +export const urlKey = (pathname: string, search: string): string => + `${pathname}?${new URLSearchParams(search)}` + +/** + * The App Router's route for `pathname`: `/projects/8f2a` with `{ id: "8f2a" }` + * becomes `/projects/[id]`, `/docs/a/b` with `{ slug: ["a", "b"] }` becomes + * `/docs/[...slug]`. Next.js doesn't expose the matched route on the client, so + * it is rebuilt from the params. + */ +export function routeTemplate(pathname: string, params: RouteParams): string { + const segments = pathname.split("/") + let end = segments.length + // Params are ordered from the root. Matching from the end keeps a static + // segment that happens to equal a param value, like in `/projects/projects`. + for (const [name, value] of Object.entries(params).reverse()) { + // An optional catch-all without segments has nothing to replace + if (!value?.length) continue + const parts: readonly string[] = typeof value === "string" ? [value] : value + const matchesAt = (at: number) => + parts.every((part, i) => segments[at + i] === part || decode(segments[at + i]) === part) + let at = end - parts.length + while (at > 0 && !matchesAt(at)) at-- + // Index 0 is the empty segment before the leading `/` + if (at <= 0) continue + segments.splice(at, parts.length, typeof value === "string" ? `[${name}]` : `[...${name}]`) + end = at + } + return segments.join("/") +} + +/** A path segment as the param value it carries: raw, fully or partly percent-encoded. */ +function decode(segment: string | undefined): string | undefined { + try { + return segment && decodeURIComponent(segment) + } catch { + // A malformed escape, like a bare `%`: no param value decodes from it + return segment + } +} diff --git a/packages/browser/src/nextjs/server.test.ts b/packages/browser/src/nextjs/server.test.ts new file mode 100644 index 000000000..57daf594c --- /dev/null +++ b/packages/browser/src/nextjs/server.test.ts @@ -0,0 +1,233 @@ +import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks" +import { context, trace } from "@opentelemetry/api" +import { BasicTracerProvider } from "@opentelemetry/sdk-trace-base" +import { type NextFetchEvent, NextRequest, NextResponse } from "next/server" +import { afterEach, beforeEach, describe, expect, it } from "vitest" +import { withMapleProxy } from "./server" + +const PAGE = "https://acme.test/projects/1" +const BROWSER_TRACEPARENT = "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01" +const event = {} as NextFetchEvent + +const pageRequest = (headers: Record = {}) => + new NextRequest(PAGE, { headers: { accept: "text/html", cookie: "session=abc", ...headers } }) + +/** Run `fn` inside a span, as Next.js runs the proxy inside its `middleware` span. */ +const inMiddlewareSpan = (fn: (traceparent: string) => Promise): Promise => + trace.getTracer("next.js").startActiveSpan("middleware GET", async (span) => { + const { traceId, spanId } = span.spanContext() + try { + return await fn(`00-${traceId}-${spanId}-01`) + } finally { + span.end() + } + }) + +/** The request headers the render sees, the way Next.js applies a proxy's response. */ +function renderHeaders(request: NextRequest, response: Response): Record { + const overridden = response.headers.get("x-middleware-override-headers") + if (overridden === null) return Object.fromEntries(request.headers) + return Object.fromEntries( + overridden + .split(",") + .map((name) => [name, response.headers.get(`x-middleware-request-${name}`) ?? ""]), + ) +} + +describe("withMapleProxy", () => { + describe("without server OpenTelemetry", () => { + it("changes nothing", async () => { + expect(await withMapleProxy()(pageRequest(), event)).toBeUndefined() + const own = NextResponse.next() + expect(await withMapleProxy(() => own)(pageRequest(), event)).toBe(own) + }) + }) + + describe("with server OpenTelemetry", () => { + beforeEach(() => { + context.setGlobalContextManager(new AsyncLocalStorageContextManager().enable()) + trace.setGlobalTracerProvider(new BasicTracerProvider()) + }) + + afterEach(() => { + trace.disable() + context.disable() + }) + + it("hands the middleware span to the render and the browser", async () => { + const request = pageRequest() + await inMiddlewareSpan(async (traceparent) => { + const response = await withMapleProxy()(request, event) + expect(response).toBeInstanceOf(NextResponse) + if (!response) return + expect(response.headers.get("x-middleware-next")).toBe("1") + expect(response.headers.get("server-timing")).toBe(`traceparent;desc="${traceparent}"`) + expect(renderHeaders(request, response)).toEqual({ + accept: "text/html", + cookie: "session=abc", + traceparent, + }) + }) + }) + + it("produces the headers the hand-written proxy did", async () => { + const request = pageRequest() + await inMiddlewareSpan(async (traceparent) => { + const response = await withMapleProxy()(request, event) + const headers = new Headers(request.headers) + headers.set("traceparent", traceparent) + const expected = NextResponse.next({ request: { headers } }) + expected.headers.set("server-timing", `traceparent;desc="${traceparent}"`) + const sorted = (value: Response | null | undefined | void) => + [...(value?.headers ?? [])].map(([name, v]) => + name === "x-middleware-override-headers" + ? [name, v.split(",").sort().join(",")] + : [name, v], + ) + expect(sorted(response)).toEqual(sorted(expected)) + }) + }) + + it("leaves a client navigation's request, which carries the browser's traceparent, alone", async () => { + await inMiddlewareSpan(async () => { + const request = pageRequest({ traceparent: BROWSER_TRACEPARENT, "sec-fetch-dest": "empty" }) + expect(await withMapleProxy()(request, event)).toBeUndefined() + const own = NextResponse.next() + expect(await withMapleProxy(() => own)(request, event)).toBe(own) + expect(own.headers.has("x-middleware-override-headers")).toBe(false) + expect(own.headers.has("server-timing")).toBe(false) + }) + }) + + it("keeps a traceparent a load balancer added to a page load, and still tells the browser", async () => { + await inMiddlewareSpan(async (traceparent) => { + const request = pageRequest({ + traceparent: BROWSER_TRACEPARENT, + "sec-fetch-dest": "document", + }) + const created = await withMapleProxy()(request, event) + if (!created) throw new Error("expected a response") + expect(created.headers.get("x-middleware-next")).toBe("1") + expect(created.headers.has("x-middleware-override-headers")).toBe(false) + expect(created.headers.get("server-timing")).toBe(`traceparent;desc="${traceparent}"`) + + const own = NextResponse.next() + expect(await withMapleProxy(() => own)(request, event)).toBe(own) + expect(own.headers.has("x-middleware-override-headers")).toBe(false) + expect(own.headers.get("server-timing")).toBe(`traceparent;desc="${traceparent}"`) + }) + }) + + it("forwards its own when the proxy's request headers leave the incoming traceparent out", async () => { + await inMiddlewareSpan(async (traceparent) => { + const request = pageRequest({ + traceparent: BROWSER_TRACEPARENT, + "sec-fetch-dest": "document", + }) + const own = NextResponse.next({ request: { headers: new Headers({ "x-tenant": "acme" }) } }) + expect(await withMapleProxy(() => own)(request, event)).toBe(own) + expect(renderHeaders(request, own)).toEqual({ "x-tenant": "acme", traceparent }) + expect(own.headers.get("server-timing")).toBe(`traceparent;desc="${traceparent}"`) + }) + }) + + it("does nothing when no span is active", async () => { + expect(await withMapleProxy()(pageRequest(), event)).toBeUndefined() + }) + + it("keeps the request headers the proxy set, and its response headers", async () => { + const request = pageRequest() + await inMiddlewareSpan(async (traceparent) => { + const headers = new Headers(request.headers) + headers.set("x-tenant", "acme") + headers.delete("cookie") + const own = NextResponse.next({ request: { headers } }) + own.headers.set("server-timing", "db;dur=12") + own.cookies.set("seen", "1") + expect(await withMapleProxy(() => own)(request, event)).toBe(own) + expect(renderHeaders(request, own)).toEqual({ + accept: "text/html", + "x-tenant": "acme", + traceparent, + }) + expect(own.headers.get("server-timing")).toBe(`db;dur=12, traceparent;desc="${traceparent}"`) + expect(own.cookies.get("seen")?.value).toBe("1") + }) + }) + + it("adds to a plain next() the proxy returned, keeping every request header", async () => { + const request = pageRequest() + await inMiddlewareSpan(async (traceparent) => { + const own = NextResponse.next() + own.headers.set("x-frame-options", "DENY") + const response = await withMapleProxy(() => own)(request, event) + expect(response).toBe(own) + expect(own.headers.get("x-frame-options")).toBe("DENY") + expect(renderHeaders(request, own)).toEqual({ + accept: "text/html", + cookie: "session=abc", + traceparent, + }) + }) + }) + + it("waits for an async proxy and passes it the request and event", async () => { + const request = pageRequest() + let seen: [NextRequest, NextFetchEvent] | undefined + await inMiddlewareSpan(async () => { + const response = await withMapleProxy(async (incoming, fetchEvent) => { + seen = [incoming, fetchEvent] + await new Promise((resolve) => setTimeout(resolve, 1)) + return undefined + })(request, event) + expect(response?.headers.get("x-middleware-next")).toBe("1") + }) + expect(seen).toEqual([request, event]) + }) + + it("follows a rewrite within the app", async () => { + const request = pageRequest() + await inMiddlewareSpan(async (traceparent) => { + const own = NextResponse.rewrite(new URL("/tenants/acme/projects/1", request.url)) + const response = await withMapleProxy(() => own)(request, event) + expect(response).toBe(own) + expect(own.headers.get("x-middleware-request-traceparent")).toBe(traceparent) + expect(own.headers.get("server-timing")).toBe(`traceparent;desc="${traceparent}"`) + }) + }) + + it("does not send the trace to another origin a rewrite proxies to", async () => { + await inMiddlewareSpan(async () => { + const own = NextResponse.rewrite("https://third-party.test/projects/1") + expect(await withMapleProxy(() => own)(pageRequest(), event)).toBe(own) + expect(own.headers.has("x-middleware-override-headers")).toBe(false) + expect(own.headers.has("server-timing")).toBe(false) + }) + }) + + it("passes the proxy's own responses through untouched, even immutable ones", async () => { + await inMiddlewareSpan(async () => { + for (const own of [ + NextResponse.redirect(new URL("/login", PAGE)), + Response.redirect(new URL("/login", PAGE)), + new Response("blocked", { status: 403 }), + NextResponse.json({ ok: true }), + ]) { + expect(await withMapleProxy(() => own)(pageRequest(), event)).toBe(own) + expect(own.headers.has("server-timing")).toBe(false) + } + }) + }) + + it("rethrows the proxy's error", async () => { + const error = new Error("proxy failed") + await inMiddlewareSpan(async () => { + await expect( + withMapleProxy(() => { + throw error + })(pageRequest(), event), + ).rejects.toBe(error) + }) + }) + }) +}) diff --git a/packages/browser/src/nextjs/server.ts b/packages/browser/src/nextjs/server.ts new file mode 100644 index 000000000..40ca6d0df --- /dev/null +++ b/packages/browser/src/nextjs/server.ts @@ -0,0 +1,84 @@ +// `@maple-dev/browser/nextjs/server`: joins the page load to the server render. +// +// Pages can't set response headers, but `proxy.ts` (`middleware.ts` before +// Next.js 16) runs before the render, inside Next.js's `middleware` span. The +// proxy hands that span's context to the render in the `traceparent` request +// header, which Next.js reads when it starts the render's root span, and to the +// browser in `Server-Timing`, which the `pageload` span reads. Depends on +// `@opentelemetry/api` and `next/server` only: safe in the edge runtime. +import { type NextFetchEvent, type NextRequest, NextResponse } from "next/server" +import { activeTraceparent, serverTimingEntry } from "../traceparent" + +type ProxyResult = Response | null | undefined | void +type ProxyFunction = (request: NextRequest, event: NextFetchEvent) => ProxyResult | Promise + +/** How `NextResponse.next({ request: { headers } })` tells Next.js which request headers the render sees. */ +const OVERRIDE_HEADERS = "x-middleware-override-headers" +const REQUEST_HEADER = "x-middleware-request-" + +/** + * Wrap your proxy (or middleware), or create one: `export const proxy = withMapleProxy()`. + * Joins the page load to the server render's trace. Your proxy's responses pass through. + */ +export function withMapleProxy(proxy?: ProxyFunction): ProxyFunction { + return async (request, event) => { + const traceparent = activeTraceparent() + const response = await proxy?.(request, event) + if (!traceparent || (response && !rendersHere(response, request))) return response + // A traceparent the render already receives stays, and the render joins it on + // its own. A client navigation's RSC request carries the browser's (replacing + // it would cut the render off from the `fetch` span that made it) and needs + // nothing back. A page load can carry one a load balancer added, whose trace + // the middleware span is in too: the browser still joins through the header. + const carried = renderReceivesTraceparent(response, request) + if (carried && request.headers.get("sec-fetch-dest") !== "document") return response + const result = + response ?? NextResponse.next(carried ? undefined : withTraceparent(request, traceparent)) + if (response && !carried) forwardToRender(response, request, traceparent) + result.headers.append("server-timing", serverTimingEntry(traceparent)) + return result + } +} + +/** The request's own `traceparent` reaches the render, unless your proxy's `next({ request })` left it out. */ +function renderReceivesTraceparent(response: ProxyResult, request: NextRequest): boolean { + const overridden = response?.headers.get(OVERRIDE_HEADERS) + return typeof overridden === "string" + ? overridden.split(",").includes("traceparent") + : request.headers.has("traceparent") +} + +/** `next()` options that add `traceparent` to the request headers the render sees. */ +function withTraceparent(request: NextRequest, traceparent: string) { + const headers = new Headers(request.headers) + headers.set("traceparent", traceparent) + return { request: { headers } } +} + +/** + * Whether the request goes on to a render in this app: `next()`, or a rewrite + * to the same origin. A redirect or your own response has no render to join, + * and a rewrite elsewhere must not leak the trace id to another origin. + */ +function rendersHere(response: Response, request: NextRequest): boolean { + if (response.headers.has("x-middleware-next")) return true + const rewrite = response.headers.get("x-middleware-rewrite") + return rewrite !== null && new URL(rewrite, request.url).origin === new URL(request.url).origin +} + +/** + * `withTraceparent` for a response your proxy made, keeping the request headers + * it set: the headers `NextResponse.next({ request: { headers } })` writes, + * extended the way Next.js extends them with its own router headers. + */ +function forwardToRender(response: Response, request: NextRequest, traceparent: string): void { + const overridden = response.headers.get(OVERRIDE_HEADERS) + // Without the list, the render sees the request's own headers: list them all + const names = overridden === null ? [...request.headers.keys()] : overridden.split(",") + if (overridden === null) { + for (const [name, value] of request.headers) response.headers.set(REQUEST_HEADER + name, value) + } + if (!names.includes("traceparent")) names.push("traceparent") + response.headers.set(OVERRIDE_HEADERS, names.join(",")) + response.headers.set(`${REQUEST_HEADER}traceparent`, traceparent) +} diff --git a/packages/browser/src/server.test.ts b/packages/browser/src/server.test.ts new file mode 100644 index 000000000..4aaf9f804 --- /dev/null +++ b/packages/browser/src/server.test.ts @@ -0,0 +1,346 @@ +import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks" +import { + context, + INVALID_SPAN_CONTEXT, + ROOT_CONTEXT, + SpanStatusCode, + trace, + TraceFlags, + type Span, +} from "@opentelemetry/api" +import { + BasicTracerProvider, + InMemorySpanExporter, + type ReadableSpan, + SimpleSpanProcessor, +} from "@opentelemetry/sdk-trace-base" +import { afterEach, beforeEach, describe, expect, it } from "vitest" +import { captureException } from "./errors" +import { resetReportedErrorsForTests } from "./failures" +import { MapleBrowser } from "./index" +import { serverTiming, traced } from "./server" + +const exporter = new InMemorySpanExporter() + +/** What a server's own OpenTelemetry setup (`@vercel/otel`, the Node SDK) registers. */ +function registerServerOtel(): void { + context.setGlobalContextManager(new AsyncLocalStorageContextManager().enable()) + trace.setGlobalTracerProvider( + new BasicTracerProvider({ spanProcessors: [new SimpleSpanProcessor(exporter)] }), + ) +} + +const tick = () => new Promise((resolve) => setTimeout(resolve, 1)) +const spans = () => exporter.getFinishedSpans() +const named = (name: string): ReadableSpan => { + const span = spans().find((candidate) => candidate.name === name) + if (!span) + throw new Error( + `no span named ${name}: ${spans() + .map((s) => s.name) + .join(", ")}`, + ) + return span +} +const parentOf = (span: ReadableSpan) => span.parentSpanContext?.spanId +const idOf = (span: ReadableSpan) => span.spanContext().spanId +const exceptionEvents = () => + spans().flatMap((span) => span.events.filter((event) => event.name === "exception")) + +/** A request: the server span the framework's instrumentation would have open. */ +const request = (fn: (span: Span) => Promise): Promise => + trace.getTracer("framework").startActiveSpan("GET /projects/[id]", async (span) => { + try { + return await fn(span) + } finally { + span.end() + } + }) + +beforeEach(() => { + exporter.reset() + resetReportedErrorsForTests() +}) + +afterEach(() => { + trace.disable() + context.disable() +}) + +describe("traced without server OpenTelemetry", () => { + it("only runs fn, passing its result and error through unchanged", async () => { + const value = { ok: true } + await expect(traced("loader", async () => value)).resolves.toBe(value) + const error = new Error("loader failed") + await expect( + traced("loader", async () => { + throw error + }), + ).rejects.toBe(error) + }) + + it("does not claim the error, so a server set up later still records it", async () => { + const error = new Error("before setup") + await expect( + traced("loader", async () => { + throw error + }), + ).rejects.toBe(error) + + registerServerOtel() + await expect( + request(() => + traced("loader", async () => { + throw error + }), + ), + ).rejects.toBe(error) + expect(exceptionEvents()).toHaveLength(1) + }) +}) + +describe("traced with server OpenTelemetry", () => { + beforeEach(registerServerOtel) + + it("spans fn under the active server span and returns its value unchanged", async () => { + const value = { ok: true } + await expect(request(() => traced("load project", async () => value))).resolves.toBe(value) + const span = named("load project") + expect(parentOf(span)).toBe(idOf(named("GET /projects/[id]"))) + expect(span.status.code).toBe(SpanStatusCode.UNSET) + expect(span.instrumentationScope.name).toBe("maple-browser") + }) + + it("keeps itself the parent across await, unlike the browser", async () => { + await request(() => + traced("load project", async () => { + await tick() + trace.getTracer("db").startSpan("db.query").end() + await tick() + trace.getTracer("db").startSpan("db.query 2").end() + }), + ) + expect(parentOf(named("db.query"))).toBe(idOf(named("load project"))) + expect(parentOf(named("db.query 2"))).toBe(idOf(named("load project"))) + }) + + it("nests a traced call inside another under that one", async () => { + await request(() => traced("outer", async () => traced("inner", async () => tick()))) + expect(parentOf(named("inner"))).toBe(idOf(named("outer"))) + }) + + it("keeps concurrent requests apart", async () => { + const handle = (id: string) => + trace.getTracer("framework").startActiveSpan(`request ${id}`, async (span) => { + await traced(`load ${id}`, async () => { + await tick() + trace.getTracer("db").startSpan(`query ${id}`).end() + }) + span.end() + }) + await Promise.all([handle("a"), handle("b")]) + for (const id of ["a", "b"]) { + const load = named(`load ${id}`) + expect(parentOf(load)).toBe(idOf(named(`request ${id}`))) + expect(parentOf(named(`query ${id}`))).toBe(idOf(load)) + } + }) + + it("is a root span when no server span is active", async () => { + await traced("loader", async () => undefined) + expect(parentOf(named("loader"))).toBeUndefined() + }) + + it("records a throw, marks the span Error and rethrows the same error", async () => { + const error = new TypeError("loader failed") + await expect( + request(() => + traced("loader", async () => { + throw error + }), + ), + ).rejects.toBe(error) + const span = named("loader") + expect(span.status).toEqual({ code: SpanStatusCode.ERROR, message: "loader failed" }) + expect(span.events.map((event) => event.name)).toEqual(["exception"]) + expect(span.events[0]?.attributes?.["exception.type"]).toBe("TypeError") + expect(span.events[0]?.attributes?.["exception.stacktrace"]).toBeTypeOf("string") + expect(span.ended).toBe(true) + }) + + it("turns a synchronous throw into a rejection with the same error", async () => { + const error = new Error("sync") + // Not an async function: the throw happens synchronously, inside `traced` + const fn = (): Promise => { + throw error + } + await expect(traced("loader", fn)).rejects.toBe(error) + expect(named("loader").status.code).toBe(SpanStatusCode.ERROR) + }) + + it("records an error-like object by its message and rethrows it unchanged", async () => { + const thrown = { message: "not an Error instance", code: 42 } + await expect( + traced("loader", async () => { + throw thrown + }), + ).rejects.toBe(thrown) + expect(named("loader").status.message).toBe("not an Error instance") + expect(exceptionEvents()[0]?.attributes?.["exception.message"]).toBe("not an Error instance") + }) + + it("records a thrown primitive", async () => { + await expect( + traced("loader", async () => { + throw "plain string" + }), + ).rejects.toBe("plain string") + expect(named("loader").status.message).toBe("plain string") + }) + + it("leaves the span Ok and the error unclaimed when isFailure returns false", async () => { + const redirect = new Error("NEXT_REDIRECT") + await expect( + traced( + "loader", + async () => { + throw redirect + }, + { isFailure: () => false }, + ), + ).rejects.toBe(redirect) + expect(named("loader").status.code).toBe(SpanStatusCode.UNSET) + expect(exceptionEvents()).toHaveLength(0) + // Unclaimed: whatever reports it next still records it + captureException(redirect) + expect(exceptionEvents()).toHaveLength(1) + }) + + it("rethrows the original error when isFailure itself throws", async () => { + const error = new Error("loader failed") + await expect( + traced( + "loader", + async () => { + throw error + }, + { + isFailure: () => { + throw new Error("predicate bug") + }, + }, + ), + ).rejects.toBe(error) + expect(named("loader").status.code).toBe(SpanStatusCode.ERROR) + }) + + it("puts the exception event on the innermost span only when traced calls nest", async () => { + const error = new Error("inner failed") + await expect( + request(() => + traced("outer", () => + traced("inner", async () => { + throw error + }), + ), + ), + ).rejects.toBe(error) + expect(named("inner").events).toHaveLength(1) + expect(named("outer").events).toHaveLength(0) + expect(named("outer").status.code).toBe(SpanStatusCode.ERROR) + }) + + it("records an error object shared by requests on the first only", async () => { + // A memoized promise's rejection, which every request awaiting it rethrows + const shared = new Error("service unavailable") + const fail = () => + request(() => + traced("loader", async () => { + throw shared + }), + ).catch(() => undefined) + await Promise.all([fail(), fail()]) + expect(exceptionEvents()).toHaveLength(1) + // Both spans still fail + expect(spans().filter((span) => span.status.code === SpanStatusCode.ERROR)).toHaveLength(2) + }) + + it("is not reported again by captureException in the same process", async () => { + const error = new Error("resolver failed") + await request(() => + traced("resolver", async () => { + throw error + }), + ).catch(() => undefined) + captureException(error) + expect(exceptionEvents()).toHaveLength(1) + }) +}) + +describe("MapleBrowser.traced on the server", () => { + beforeEach(registerServerOtel) + + it("spans through the server's tracer, like the /server entry", async () => { + expect(typeof window).toBe("undefined") + const value = { ok: true } + await expect(request(() => MapleBrowser.traced("load project", async () => value))).resolves.toBe( + value, + ) + expect(parentOf(named("load project"))).toBe(idOf(named("GET /projects/[id]"))) + }) +}) + +describe("serverTiming", () => { + it("is undefined without server OpenTelemetry", () => { + expect(serverTiming()).toBeUndefined() + }) + + describe("with server OpenTelemetry", () => { + beforeEach(registerServerOtel) + + it("carries the active span's context", async () => { + await request(async (span) => { + const { traceId, spanId } = span.spanContext() + expect(serverTiming()).toBe(`traceparent;desc="00-${traceId}-${spanId}-01"`) + }) + }) + + it("names the innermost active span, across await", async () => { + const value = await request(() => + traced("render", async () => { + await tick() + return serverTiming() + }), + ) + const render = named("render") + expect(value).toBe(`traceparent;desc="00-${render.spanContext().traceId}-${idOf(render)}-01"`) + }) + + it("keeps an unsampled trace unsampled", () => { + const spanContext = { + traceId: "0af7651916cd43dd8448eb211c80319c", + spanId: "b7ad6b7169203331", + traceFlags: TraceFlags.NONE, + } + context.with(trace.setSpanContext(ROOT_CONTEXT, spanContext), () => { + expect(serverTiming()).toBe( + 'traceparent;desc="00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-00"', + ) + }) + }) + + it("is undefined when no span is active", () => { + expect(serverTiming()).toBeUndefined() + }) + + it("is undefined for an invalid span context", () => { + context.with(trace.setSpanContext(ROOT_CONTEXT, INVALID_SPAN_CONTEXT), () => { + expect(serverTiming()).toBeUndefined() + }) + const malformed = { traceId: 'x"; injected', spanId: "b7ad6b7169203331", traceFlags: 1 } + context.with(trace.setSpanContext(ROOT_CONTEXT, malformed), () => { + expect(serverTiming()).toBeUndefined() + }) + }) + }) +}) diff --git a/packages/browser/src/server.ts b/packages/browser/src/server.ts new file mode 100644 index 000000000..eb84d7df6 --- /dev/null +++ b/packages/browser/src/server.ts @@ -0,0 +1,33 @@ +// `@maple-dev/browser/server`: the server half of frontend tracing. +// +// Server-side data loading (Server Components, SSR loaders and resolvers) runs +// in spans from the global tracer, so it nests under whatever OpenTelemetry +// setup the server registered, and the rendered page tells the browser which +// trace to join. Depends on `@opentelemetry/api` only: safe in Node, edge +// runtimes and Workers. Without server OpenTelemetry, both do nothing. +import { trace } from "@opentelemetry/api" +import { runTraced, type TracedOptions } from "./failures" +import { activeTraceparent, serverTimingEntry } from "./traceparent" +import { SDK_NAME, SDK_VERSION } from "./version" + +export type { TracedOptions } from "./failures" + +/** + * Run server-side data loading in a span under the active one. Errors are recorded once and rethrown. + * Without server OpenTelemetry it only runs `fn`. + */ +export function traced(name: string, fn: () => Promise, options: TracedOptions = {}): Promise { + // Per call, not cached: the app may register its provider after this module loads + return trace + .getTracer(SDK_NAME, SDK_VERSION) + .startActiveSpan(name, (span) => runTraced(span, fn, options)) +} + +/** + * The `Server-Timing` header value that joins the browser's page load to the active server trace, + * or `undefined` when no span is active: `headers.append("server-timing", value)`. + */ +export function serverTiming(): string | undefined { + const traceparent = activeTraceparent() + return traceparent && serverTimingEntry(traceparent) +} diff --git a/packages/browser/src/traceparent.ts b/packages/browser/src/traceparent.ts new file mode 100644 index 000000000..5e3e2d360 --- /dev/null +++ b/packages/browser/src/traceparent.ts @@ -0,0 +1,31 @@ +// W3C `traceparent`: `version-traceid-parentid-flags`. Read and written here +// rather than through the global propagator, which the host app may own, may +// have configured for another format, or may not have registered at all. +import { context, isSpanContextValid, type SpanContext, trace } from "@opentelemetry/api" + +const TRACEPARENT = /^([\da-f]{2})-([\da-f]{32})-([\da-f]{16})-([\da-f]{2})(-.*)?$/ + +export function parseTraceparent(value: string | undefined): SpanContext | undefined { + const match = value?.trim().match(TRACEPARENT) + // Version ff is invalid; version 00 has exactly four fields + if (!match || match[1] === "ff" || (match[1] === "00" && match[5] !== undefined)) return undefined + const spanContext = { + traceId: match[2], + spanId: match[3], + traceFlags: Number.parseInt(match[4], 16), + isRemote: true, + } + // All-zero ids are well-formed but invalid; rejecting them lets the meta tag stand in + return isSpanContextValid(spanContext) ? spanContext : undefined +} + +/** The active span's context as a `traceparent`, sampled flag included; `undefined` without a valid one. */ +export function activeTraceparent(): string | undefined { + const spanContext = trace.getSpanContext(context.active()) + if (!spanContext || !isSpanContextValid(spanContext)) return undefined + const flags = (spanContext.traceFlags & 0xff).toString(16).padStart(2, "0") + return `00-${spanContext.traceId}-${spanContext.spanId}-${flags}` +} + +/** The `Server-Timing` entry the browser's `pageload` span reads its parent from. */ +export const serverTimingEntry = (traceparent: string): string => `traceparent;desc="${traceparent}"` diff --git a/packages/browser/tsdown.config.ts b/packages/browser/tsdown.config.ts index 8938a40e3..0a3e217b7 100644 --- a/packages/browser/tsdown.config.ts +++ b/packages/browser/tsdown.config.ts @@ -3,6 +3,9 @@ import { defineConfig } from "tsdown" export default defineConfig({ entry: { index: "./src/index.ts", + server: "./src/server.ts", + nextjs: "./src/nextjs/index.ts", + "nextjs-server": "./src/nextjs/server.ts", }, format: "esm", // Types are emitted by tsgo in one pass rooted at the tsconfig's directory, @@ -11,4 +14,7 @@ export default defineConfig({ // that package's sources as roots. Same as packages/effect-sdk. dts: { tsconfig: "../tsconfig.browser.dts.json" }, outDir: "dist", + // Rolldown can't promise to keep a module's "use client" through bundling in + // general, but it keeps an entry module's: `dist/nextjs.mjs` starts with it. + suppressWarnings: /module level directive "use client" in "src\/nextjs\/index\.ts"/, })