Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions apps/landing/src/content/docs/session-replay/browser-sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,81 @@ export const config = {
- `withMapleProxy` keeps a `traceparent` the request already carries, as client navigations do, and only changes responses that go on to a render in your app. Redirects and responses your proxy builds itself pass through unchanged. If a shared cache such as a CDN stores prerendered pages, leave them out of the matcher, or every visitor joins the same trace.
- Server Components can time database and SDK calls with `traced` from `@maple-dev/browser/server`. Pass `isFailure: () => false`: Next.js records an error thrown from a Server Component on its render span, and `redirect()` and `notFound()` work by throwing.

## React Router integration

`@maple-dev/browser/react-router` connects React Router 7.15 or later to the navigation spans, with a span for every loader and action, and `@maple-dev/browser/react-router/server` traces framework mode's server render. In data mode, pass the instrumentation to the router you create, and subscribe to it right after:

```tsx
// src/main.tsx
import {
dataRouterInstrumentation,
reportRouteError,
traceNavigations,
} from "@maple-dev/browser/react-router"
import { createBrowserRouter } from "react-router"
import { RouterProvider } from "react-router/dom"

const router = createBrowserRouter(routes, { instrumentations: [dataRouterInstrumentation] })
traceNavigations(router)

createRoot(document.getElementById("root")!).render(
<RouterProvider router={router} onError={reportRouteError} />,
)
```

In framework mode, React Router creates the router, so pass the framework instrumentation to `HydratedRouter`, and end the page load in the root route's `Layout`, which also wraps the root `ErrorBoundary`:

```tsx
// app/entry.client.tsx
import { frameworkInstrumentation, reportRouteError } from "@maple-dev/browser/react-router"

startTransition(() => {
hydrateRoot(
document,
<HydratedRouter instrumentations={[frameworkInstrumentation]} onError={reportRouteError} />,
)
})
```

```tsx
// app/root.tsx
import { useMaplePageload } from "@maple-dev/browser/react-router"

export function Layout({ children }: { children: React.ReactNode }) {
useMaplePageload()
// ...the rest of the generated Layout
}
```

Then trace the server render and hand its trace to the page load, in `app/entry.server.tsx`:

```tsx
// app/entry.server.tsx
import { serverInstrumentation } from "@maple-dev/browser/react-router/server"
import { serverTiming } from "@maple-dev/browser/server"

export { handleError } from "@maple-dev/browser/react-router/server"
export const instrumentations = [serverInstrumentation]

export default function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers /* , ... */,
) {
const timing = serverTiming()
if (timing) responseHeaders.append("server-timing", timing)
// ...the rest of the generated handleRequest
}
```

- Spans are named after the route pattern, like `navigate /projects/:id`, and loader and action spans after the route id, like `loader routes/project`. In data mode, route ids default to positions like `0-1`, so give routes with loaders an `id`. A URL no route matches is named after the root route, `/`.
- In data mode, a redirect or a click while loaders run continues the same span, named after the route the user lands on. In framework mode, a click still loading ends as interrupted when the next click, back or forward replaces it. A redirected navigation is named after the route that was clicked, and back and forward themselves aren't traced.
- Hash changes, revalidations and fetcher loads start no span. Only a thrown `Error` fails a loader span: `redirect()`, `data()` and thrown responses don't.
- `reportRouteError` skips thrown responses, like a loader's 404, and errors a loader span already recorded. In production, a server loader's error reaches the browser as `Unexpected Server Error`, so it's recorded on the server's span only. If you have your own `onError`, call it from there.
- `handleError` logs like React Router's default and records render errors on the request span, which fails with the 500. If you have your own `handleError`, call it from there instead of logging.
- `serverInstrumentation` needs the server's OpenTelemetry setup, like the Node SDK loaded with `--import`. The request span nests under its HTTP span and is active while `handleRequest` runs.
- Framework mode names navigations and server requests after the route since React Router 8.1. Earlier versions don't report the matched pattern, so those spans keep their plain names.

## Custom events

`track(name, props)` records a product event against the current session. It appears inline in the session transcript next to the clicks and network calls around it, and counts as a [product event](/docs/product-events/overview).
Expand Down Expand Up @@ -473,6 +548,8 @@ import { App } from "./App"
createRoot(document.getElementById("root")!).render(<App />)
```

With React Router, see [React Router integration](#react-router-integration) for navigation and loader spans. In framework mode, import `./maple` first in `app/entry.client.tsx`.

### Next.js

Next.js exposes `NEXT_PUBLIC_*` variables through `process.env`, not `import.meta.env`. Initialize from a client component and render it in the root layout:
Expand Down
7 changes: 7 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 8 additions & 1 deletion knip.json
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,14 @@
},
"packages/browser": {
// One source module per `exports` subpath; the built `dist/` names don't map back to them.
"entry": ["src/index.ts", "src/server.ts", "src/nextjs/index.ts", "src/nextjs/server.ts"],
"entry": [
"src/index.ts",
"src/server.ts",
"src/nextjs/index.ts",
"src/nextjs/server.ts",
"src/react-router/index.ts",
"src/react-router/server.ts"
],
"ignoreDependencies": ["rrweb"]
}
},
Expand Down
72 changes: 72 additions & 0 deletions packages/browser/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,78 @@ export const config = { matcher: ["/((?!api|_next/static|_next/image|favicon.ico
- The browser follows the server's sampling decision: a page load under an
unsampled server trace isn't recorded.

## React Router

`@maple-dev/browser/react-router` wires React Router 7.15+ to the navigation
spans, with a span per loader and action. Data mode (`createBrowserRouter`):

```tsx
import {
dataRouterInstrumentation,
reportRouteError,
traceNavigations,
} from "@maple-dev/browser/react-router"

const router = createBrowserRouter(routes, { instrumentations: [dataRouterInstrumentation] })
traceNavigations(router)

createRoot(root).render(<RouterProvider router={router} onError={reportRouteError} />)
```

Framework mode:

```tsx
// app/entry.client.tsx
import { frameworkInstrumentation, reportRouteError } from "@maple-dev/browser/react-router"

hydrateRoot(
document,
<HydratedRouter instrumentations={[frameworkInstrumentation]} onError={reportRouteError} />,
)
```

```tsx
// app/root.tsx: ends the page load once the page hydrates
import { useMaplePageload } from "@maple-dev/browser/react-router"

export function Layout({ children }: { children: React.ReactNode }) {
useMaplePageload()
// ...
}
```

```tsx
// app/entry.server.tsx
import { serverInstrumentation } from "@maple-dev/browser/react-router/server"
import { serverTiming } from "@maple-dev/browser/server"

export { handleError } from "@maple-dev/browser/react-router/server"
export const instrumentations = [serverInstrumentation]

export default function handleRequest(request: Request, status: number, headers: Headers /* , ... */) {
const timing = serverTiming() // joins the page load to this request's trace
if (timing) headers.append("server-timing", timing)
// ...
}
```

- Spans are named after the route pattern, like `navigate /projects/:id`, and
loader spans after the route id, like `loader routes/project`. A URL no route
matches is named after the root, `/`.
- Data mode keeps a redirect or a click during loading in one span, named after
the route it landed on. In framework mode, a click still loading ends as
`app.navigation.interrupted` when the next click, back or forward replaces
it, a redirect is named after the route clicked, and back and forward
themselves aren't traced.
- Hash changes, revalidations and fetcher loads start no span. Only thrown
`Error`s fail a loader span: `redirect()` and thrown responses don't.
- `reportRouteError` skips thrown responses and errors a loader span recorded.
A server loader's error reaches the browser without its message in
production, so only the server's span records it. `handleError` logs like
React Router's default and records render errors on the request span.
- Framework mode names navigations and server requests after the route since
React Router 8.1, which added the matched pattern to its instrumentation.

## Linking a marketing site to your app

The visitor id lives in localStorage **and** a cookie scoped to your registered
Expand Down
15 changes: 14 additions & 1 deletion packages/browser/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,14 @@
"./nextjs/server": {
"types": "./dist/nextjs-server.d.mts",
"import": "./dist/nextjs-server.mjs"
},
"./react-router": {
"types": "./dist/react-router.d.mts",
"import": "./dist/react-router.mjs"
},
"./react-router/server": {
"types": "./dist/react-router-server.d.mts",
"import": "./dist/react-router-server.mjs"
}
},
"publishConfig": {
Expand Down Expand Up @@ -74,20 +82,25 @@
"playwright": "catalog:",
"react": "catalog:react",
"react-dom": "catalog:react",
"react-router": "^8.4.0",
"tsdown": "^0.23.0",
"typescript": "catalog:tooling",
"vitest": "catalog:"
},
"peerDependencies": {
"next": "*",
"react": "*"
"react": "*",
"react-router": "*"
},
"peerDependenciesMeta": {
"next": {
"optional": true
},
"react": {
"optional": true
},
"react-router": {
"optional": true
}
}
}
7 changes: 7 additions & 0 deletions packages/browser/src/failures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,13 @@ export function recordFailure(span: Span, error: unknown): void {
span.setStatus({ code: SpanStatusCode.ERROR, message: normalized.message })
}

/** `recordFailure`'s exception event alone, for a span whose status follows something else, like a response's. */
export function recordExceptionOnce(span: Span, error: unknown): void {
if (alreadyReported(error)) return
if (span.isRecording() && isObject(error)) reported.add(error)
span.recordException(asError(error))
}

/** Run `fn` as `span`'s work: its result or error passes through unchanged, and the span ends either way. */
export async function runTraced<T>(span: Span, fn: () => Promise<T>, options: TracedOptions): Promise<T> {
try {
Expand Down
Loading
Loading