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
61 changes: 61 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,67 @@ 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.

## Angular integration

`@maple-dev/browser/angular` connects the Angular Router (Angular 19 or later) to the navigation spans and reports the errors Angular catches. It's plain functions and a class without decorators, so it needs no Angular compiler step. Import the file that calls `MapleBrowser.init()` first in `src/main.ts`, then add the providers next to `provideRouter`:

```ts
// src/app/app.config.ts
import { provideHttpClient } from "@angular/common/http"
import { type ApplicationConfig, ErrorHandler } from "@angular/core"
import { provideRouter } from "@angular/router"
import { MapleErrorHandler, provideMapleTracing } from "@maple-dev/browser/angular"
import { routes } from "./app.routes"

export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
// Angular 21 and older: provideHttpClient(withFetch()), since the SDK traces fetch, not XMLHttpRequest
provideHttpClient(),
provideMapleTracing(),
{ provide: ErrorHandler, useClass: MapleErrorHandler },
],
}
```

Run each resolver's data loading in `tracedResolver`:

```ts
// src/app/projects/project.resolver.ts
import { HttpClient } from "@angular/common/http"
import { inject } from "@angular/core"
import type { ResolveFn } from "@angular/router"
import { tracedResolver } from "@maple-dev/browser/angular"
import { firstValueFrom } from "rxjs"

export const projectResolver: ResolveFn<Project> = (route) => {
// inject() only works synchronously, before the first await
const http = inject(HttpClient)
return tracedResolver("loader /projects/:id", () =>
firstValueFrom(http.get<Project>(`/api/projects/${route.paramMap.get("id")}`)),
)
}
```

With `@angular/ssr`, render in a span and join the page load to it, in the render middleware of `src/server.ts`:

```ts
// src/server.ts
import { tracedRender } from "@maple-dev/browser/angular/server"

app.use((req, res, next) => {
tracedRender(req, () => angularApp.handle(req))
.then((response) => (response ? writeResponseToNodeResponse(response, res) : next()))
.catch(next)
})
```

- Spans are named after the route template, joined from the `path` of each matched route: `navigate /projects/:id`, and `/**` for the wildcard route. A route matched by a `matcher` function has no `path`, so it adds nothing to the name.
- A guard's `UrlTree` or a resolver's `RedirectCommand` stays in one span, named after the route it lands on, with the requested path in `url.path`. A navigation replaced by a newer one, including a click on the URL on screen, ends as interrupted. Query changes, fragments, back and forward are navigations. A navigation to the URL on screen starts no span.
- `tracedResolver` records an error once and rethrows it; a thrown `RedirectCommand` isn't an error. Only requests started before the first `await` nest under its span, and `firstValueFrom` starts an `HttpClient` request right away.
- `MapleErrorHandler` reports errors from templates, lifecycle hooks and listeners as `angular.error`, then logs them like Angular's own handler. An error a resolver already recorded isn't reported again, nor is one `provideBrowserGlobalErrorListeners()` forwards that the SDK's own handler already saw. If your app has its own `ErrorHandler`, call `reportAngularError(error)` in it instead. Call it in `onViewError` too if you implement it, since Angular calls that instead of `handleError` for `@boundary` blocks, and in a `withNavigationErrorHandler` that returns a `RedirectCommand`, since the error then never reaches the `ErrorHandler`.
- `provideMapleTracing()` does nothing during the server render. There, `tracedRender` runs the render in an `ssr` span, under the request span of the OpenTelemetry Node SDK, and adds `Server-Timing` to the response. The CLI bundles Express into `server.mjs`, so the HTTP instrumentation creates that request span, not the Express one. Resolvers and `MapleErrorHandler` record into the same trace on the server. If a shared cache such as a CDN stores your HTML, render those pages without `tracedRender`, or every visitor joins the same trace.

## 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
25 changes: 25 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/angular/index.ts",
"src/angular/server.ts"
],
"ignoreDependencies": ["rrweb"]
}
},
Expand Down
51 changes: 51 additions & 0 deletions packages/browser/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,57 @@ 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.

## Angular

`@maple-dev/browser/angular` wires the Angular Router (Angular 19+) to the
navigation spans. Plain functions and an undecorated class: no Angular compiler
step needed.

```ts
// src/app/app.config.ts
import { MapleErrorHandler, provideMapleTracing } from "@maple-dev/browser/angular"

export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideMapleTracing(),
{ provide: ErrorHandler, useClass: MapleErrorHandler },
],
}
```

```ts
// a resolver: its data loading in a span under the navigation
export const projectResolver: ResolveFn<Project> = (route) => {
const http = inject(HttpClient) // before the first await
return tracedResolver("loader /projects/:id", () =>
firstValueFrom(http.get<Project>(`/api/projects/${route.paramMap.get("id")}`)),
)
}
```

```ts
// src/server.ts (@angular/ssr): the render in an `ssr` span, joined by the page load
import { tracedRender } from "@maple-dev/browser/angular/server"

app.use((req, res, next) => {
tracedRender(req, () => angularApp.handle(req))
.then((response) => (response ? writeResponseToNodeResponse(response, res) : next()))
.catch(next)
})
```

- Spans are named after the matched routes' paths, like `navigate /projects/:id`
(`/**` for the wildcard route). A guard or resolver redirect stays in one span,
named after where it lands; a navigation replaced by another ends as interrupted.
- `tracedResolver` doesn't count a thrown `RedirectCommand` as an error.
- `MapleErrorHandler` reports as `angular.error`, skips errors a resolver already
recorded, and still logs like Angular's own handler. With your own
`ErrorHandler`, call `reportAngularError(error)` from it instead.
- `provideMapleTracing()` does nothing on the server. `/angular/server` depends
on `@opentelemetry/api` only. Leave pages a shared cache (CDN) stores out of
`tracedRender`, or every visitor joins one trace.

## Linking a marketing site to your app

The visitor id lives in localStorage **and** a cookie scoped to your registered
Expand Down
29 changes: 28 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"
},
"./angular": {
"types": "./dist/angular.d.mts",
"import": "./dist/angular.mjs"
},
"./angular/server": {
"types": "./dist/angular-server.d.mts",
"import": "./dist/angular-server.mjs"
}
},
"publishConfig": {
Expand All @@ -65,6 +73,11 @@
"rrweb": "^2.0.0-alpha.18"
},
"devDependencies": {
"@angular/common": "^22.2.0",
"@angular/compiler": "^22.2.0",
"@angular/core": "^22.2.0",
"@angular/platform-browser": "^22.2.0",
"@angular/router": "^22.2.0",
"@maple/browser-session": "workspace:*",
"@opentelemetry/context-async-hooks": "^2.11.0",
"@types/react": "catalog:react",
Expand All @@ -74,15 +87,29 @@
"playwright": "catalog:",
"react": "catalog:react",
"react-dom": "catalog:react",
"rxjs": "^7.8.2",
"tsdown": "^0.23.0",
"typescript": "catalog:tooling",
"vitest": "catalog:"
"vitest": "catalog:",
"zone.js": "~0.16.0"
},
"peerDependencies": {
"@angular/common": "*",
"@angular/core": "*",
"@angular/router": "*",
"next": "*",
"react": "*"
},
"peerDependenciesMeta": {
"@angular/common": {
"optional": true
},
"@angular/core": {
"optional": true
},
"@angular/router": {
"optional": true
},
"next": {
"optional": true
},
Expand Down
Loading
Loading