Skip to content
Merged
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
4 changes: 2 additions & 2 deletions apps/landing/src/content/docs/session-replay/browser-sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ Every field accepted by `MapleBrowser.init`:
| `tracing.enabled` | `boolean` | `true` | Enable OpenTelemetry browser tracing. |
| `tracing.instrumentFetch` | `boolean` | `true` | Create spans for `fetch()` calls. Set `false` when another tracer (such as the Effect client SDK) already instruments requests, to avoid duplicate network spans. |
| `tracing.captureErrors` | `boolean` | `true` | Record uncaught errors and unhandled promise rejections as error spans. Turn it off only when another tool owns the page's global error handlers. |
| `tracing.propagateTraceHeaderCorsUrls` | `Array<string \| RegExp>` | `[]` | Cross-origin URLs whose `fetch()` requests carry the `traceparent` header. See [Connect browser and backend traces](#connect-browser-and-backend-traces). |
| `tracing.propagateTraceHeaderCorsUrls` | `Array<string \| RegExp>` | `[]` | Cross-origin URLs whose `fetch()` and XHR requests carry the `traceparent` header. See [Connect browser and backend traces](#connect-browser-and-backend-traces). |
| `replay.enabled` | `boolean` | `true` | Enable session recording. |
| `replay.sampleRate` | `number` | `1` | Fraction of sessions to record, `0` to `1`. See [Sampling](#sampling). |
| `privacy.maskAllInputs` | `boolean` | `true` | Mask all `<input>` values in the recording. |
Expand Down Expand Up @@ -106,7 +106,7 @@ Every span and replay event the SDK emits carries one **`session.id`** (a `crypt

The session is stored in `sessionStorage` under the key `maple.session`, so it **survives reloads within a tab**. `sessionStorage` is per tab, so **each tab or window gets its own session**. When `sessionStorage` is unavailable (for example in some private-browsing modes), the SDK keeps the session in memory for the life of the page.

Client-side route changes in a single-page app do **not** start a new session. The SDK tracks no router events. Session boundaries are purely time-based.
Client-side route changes in a single-page app do **not** start a new session. Session boundaries are purely time-based.

### Rotation

Expand Down
36 changes: 22 additions & 14 deletions docs/browser-sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,8 @@ Every field accepted by `MapleBrowser.init`:
| `tracing.instrumentFetch` | `boolean` | `true` | Auto-instrument `fetch()` to create network spans. Set `false` when another tracer (e.g. the Effect client SDK) already instruments requests. Its spans feed the session through the published sink, and turning this off avoids duplicate network spans. |
| `tracing.instrumentXhr` | `boolean` | `true` | Auto-instrument `XMLHttpRequest` (axios and older clients) like `fetch`. |
| `tracing.captureErrors` | `boolean` | `true` | Record uncaught errors and unhandled rejections as error spans. See [Errors](#errors). |
| `tracing.propagateTraceHeaderCorsUrls` | `Array<string \| RegExp>` | `[]` | Cross-origin URLs whose `fetch()` requests carry the `traceparent` header. See [Tracing across origins](#tracing-across-origins). |
| `tracing.sampleRate` | `number` | `1` | Fraction of sessions whose traces are exported, `0` to `1`. Decided per session; error spans are always exported. See [Sampling](#sampling). |
| `tracing.propagateTraceHeaderCorsUrls` | `Array<string \| RegExp>` | `[]` | Cross-origin URLs whose `fetch()` and XHR requests carry the `traceparent` header. See [Tracing across origins](#tracing-across-origins). |
| `tracing.sampleRate` | `number` | `1` | Fraction of sessions whose traces are exported, `0` to `1`. Decided per session; reported errors are always exported. See [Sampling](#sampling). |
| `webVitals` | `boolean` | `true` | Report Core Web Vitals as `browser.web_vital` log events. See [Web Vitals](#web-vitals). |
| `breadcrumbs` | `boolean` | `true` | Keep the last clicks, inputs, navigations and console lines, and export them with the next error. See [Breadcrumbs](#breadcrumbs). |
| `logs.captureConsole` | `ConsoleLevel[]` | `[]` | Console levels exported as OTel logs as they happen, e.g. `["warn", "error"]`. |
Expand All @@ -67,7 +67,7 @@ Every field accepted by `MapleBrowser.init`:
| `tracing.slowInteractions` | `boolean` | `false` | Span interactions of 200ms or more. See [Jank](#jank). |
| `tracing.captureHeaders` | `{ request?, response? }` | none | Header names recorded on `fetch`/XHR spans as `http.request.header.<name>` / `http.response.header.<name>`. See [Request and response detail](#request-and-response-detail). |
| `replay.canvasFps` | `number` | off | Record `<canvas>` content at this many frames per second. |
| `replay.networkBodies` | `{ urls, maxLength? }` | none | Keep text request/response bodies of these URLs on replay network events. |
| `replay.networkBodies` | `{ urls, maxLength? }` | none | Keep text response bodies of these URLs on replay network events, and request bodies too with `privacy.maskAllInputs: false`. |
| `replay.onErrorSampleRate` | `number` | `0` | Fraction of the sessions not recorded that buffer the last minute in memory and keep it only if an error happens. See [Sampling](#sampling). |
| `transport.offline` | `boolean` | `false` | Keep span and log batches that could not be sent in IndexedDB for up to 24 hours and send them later. See [Offline](#offline). |
| `privacy.maskAllInputs` | `boolean` | `true` | Mask all `<input>` values in the recording. |
Expand Down Expand Up @@ -123,8 +123,8 @@ session**. Sessions are never shared across them. When `sessionStorage` is unava
some private-browsing modes), the SDK falls back to an in-memory record for the life of the
page.

SPA route changes do **not** start a new session. The SDK tracks no router events, so
client-side navigation stays within the same session. Session boundaries are purely
SPA route changes do **not** start a new session: navigation spans (see
[React integration](#react-integration)) stay within it. Session boundaries are purely
time-based (see below).

### Rotation
Expand Down Expand Up @@ -308,7 +308,8 @@ MapleBrowser.init({
A matching span gets status `Error`, `error.type` set to the status code (per the HTTP semantic
conventions) and `error.message` like `POST https://api.example.com/users/42 -> 503`, without the
query string. Issues group by status and request, with ids in the path redacted. Network failures
(no response at all) are always errors.
(no response at all: offline, DNS, CORS, a timeout) are always errors, with `error.type` set to
what failed (`TypeError` for `fetch`, `error` or `timeout` for XHR). An aborted request is not.

### Breadcrumbs

Expand Down Expand Up @@ -467,7 +468,7 @@ To record only a fraction of sessions, set `replay.sampleRate` between `0` and `

`replay.onErrorSampleRate` covers the sessions `replay.sampleRate` leaves out. Those sessions run
the recorder into memory only, keeping roughly the last minute (the segments since the
second-to-last full snapshot, taken every 30s). Nothing is uploaded. When an error is recorded (an
second-to-last full snapshot, taken every 30s while the page is visible and changing). Nothing is uploaded. When an error is recorded (an
uncaught error, an unhandled rejection or `captureException`, after [filters](#filtering-errors)),
the buffered minute is uploaded and the rest of the session is recorded normally, including its
later page loads. The session is marked `maple.session.replay_trigger: "error"`, and its replay starts
Expand All @@ -485,7 +486,9 @@ memory.

`tracing.sampleRate` does the same for traces. The decision is made once per session (a hash of
`session.id`), so a sampled session keeps every one of its traces and its replay never links to a
dropped one. Spans that record an error are always exported, whatever the rate.
dropped one. Errors reported as their own spans (uncaught errors, unhandled rejections and
`captureException`) are always exported, whatever the rate; request spans of an unsampled session
are not, including ones `errors.captureHttpStatus` would have marked.

```ts
MapleBrowser.init({
Expand Down Expand Up @@ -548,10 +551,13 @@ replay: {
}
```

Only text and JSON bodies are kept, each cut to `maxLength` characters (at most and by default 1,000: ingest stores up to 1 KB per body). The response is read from a
clone in the background, only as far as `maxLength`, so your code gets it untouched and unwaited. Nothing is captured with
`privacy.maskAllText`. Bodies can hold personal data: list only endpoints whose payloads you are
allowed to record.
Patterns match the full URL, so a relative `fetch("/api/checkout")` is matched as
`https://your.app/api/checkout`. Only text and JSON bodies are kept, each cut to `maxLength` characters (at most and by default 1,000: ingest stores up to 1 KB per body). The response is read from a
clone in the background, only as far as `maxLength` and for at most 5 seconds, so your code gets it untouched and unwaited;
event streams (`text/event-stream`) are never read. Nothing is captured with `privacy.maskAllText`, and
request bodies only with `privacy.maskAllInputs: false`, since a form POST carries what was typed. Only
string request bodies are kept (not `FormData`, `Blob` or a stream). Bodies can hold personal data: list
only endpoints whose payloads you are allowed to record.

### Canvas

Expand All @@ -565,7 +571,8 @@ The OTLP exporters already retry a failed export a few times (about 10 seconds i
`transport: { offline: true }`, a batch that still fails (the browser is offline, or ingest is
down) is kept in IndexedDB, as the same OTLP JSON the exporter sends, and sent again when the
browser fires `online` and on the next page load. Batches older than 24 hours are dropped, and at
most 100 are kept. Revoking consent clears the queue. Where IndexedDB is unavailable (some private
most 100 are kept. Revoking consent clears the queue, in every tab; with `privacy.requireConsent`,
batches from an earlier page are still sent once consent is granted again, unless it was revoked in between. Where IndexedDB is unavailable (some private
windows), nothing is kept.

## Framework examples
Expand Down Expand Up @@ -644,7 +651,8 @@ createRoot(document.getElementById("root")!, {
the leaf route's full path (`navigate /projects/$projectId`). Search-only changes are not
navigations.

Both adapters return an unsubscribe. Don't also call `startNavigation`/`endNavigation` yourself.
Both adapters return an unsubscribe. Attach them after `MapleBrowser.init`, or the page load is
missed, and don't also call `startNavigation`/`endNavigation` yourself.

## Notes

Expand Down
18 changes: 18 additions & 0 deletions packages/browser-session/src/identity/consent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"
import {
configurePrivacy,
consentAllowedSince,
consentRevokedAt,
hasConsent,
mayPersistIdentifier,
onConsentChange,
Expand Down Expand Up @@ -38,6 +39,23 @@ describe("consent", () => {
expect(hasConsent()).toBe(false)
})

it("persists when consent was withdrawn, so data kept from before can be dropped on a later page", () => {
const store = new Map<string, string>()
vi.stubGlobal("localStorage", {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => store.set(key, value),
})
// A page that starts without consent has not had it revoked.
configurePrivacy({ requireConsent: true })
setConsent(false)
expect(consentRevokedAt()).toBe(0)
setConsent(true)
expect(consentRevokedAt()).toBe(0)
vi.useFakeTimers({ now: 5_000 })
setConsent(false)
expect(consentRevokedAt()).toBe(5_000)
})

it("notifies only effective transitions and advances the grant boundary", () => {
vi.useFakeTimers()
vi.setSystemTime(new Date("2026-08-02T10:00:00Z"))
Expand Down
23 changes: 22 additions & 1 deletion packages/browser-session/src/identity/consent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,24 @@ function consentState(): ConsentState {
return fresh
}

/** When consent was last withdrawn, on any page of this origin. Persisted: the grant time resets every load. */
const REVOKED_AT_KEY = "maple-consent-revoked-at"

function writeRevokedAt(at: number): void {
try {
localStorage.setItem(REVOKED_AT_KEY, String(at))
} catch {}
}

/** Epoch ms of the last consent withdrawal on this origin, 0 if never. Data kept from before it must not be sent. */
export function consentRevokedAt(): number {
try {
return Number(localStorage.getItem(REVOKED_AT_KEY)) || 0
} catch {
return 0
}
}

function updateEffectiveConsent(previous: boolean): void {
const state = consentState()
const allowed = hasConsent()
Expand Down Expand Up @@ -125,7 +143,10 @@ export function configurePrivacy(options: PrivacyOptions | undefined): void {
/** Record the user's consent decision. No-op unless `requireConsent` is set. */
export function setConsent(nextGranted: boolean): void {
const previous = hasConsent()
consentState().granted = nextGranted
const state = consentState()
// Only the user taking consent back is a revoke; a page starting without it is not.
if (state.granted && !nextGranted) writeRevokedAt(Date.now())
state.granted = nextGranted
updateEffectiveConsent(previous)
}

Expand Down
1 change: 1 addition & 0 deletions packages/browser-session/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export type { PrivacyOptions } from "./identity/consent"
export {
configurePrivacy,
consentAllowedSince,
consentRevokedAt,
hasConsent,
mayPersistIdentifier,
onConsentChange,
Expand Down
2 changes: 2 additions & 0 deletions packages/browser-session/src/platform/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ export interface NetworkBodyOptions {
readonly urls: ReadonlyArray<string | RegExp>
/** Each body is cut to this many characters. */
readonly maxLength: number
/** Keep request bodies too. Off while inputs are masked: a form POST carries what was typed. */
readonly requestBodies?: boolean | undefined
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,10 @@ describe("installNetworkCapture", () => {
await fetch("https://api.test/other")
await vi.waitFor(() => expect(events.filter((event) => event.type === "network")).toHaveLength(3))

const [, listed, other] = events.filter((event) => event.type === "network")
// Bodies are read in the background, so events can land in any order: find them by request.
const network = events.filter((event) => event.type === "network")
const listed = network.find((event) => event.net?.method === "POST")
const other = network.find((event) => event.net?.url.endsWith("/other"))
expect(listed?.attrs).toEqual({ "request.body": "request …", "response.body": '{"order"…' })
expect(other?.attrs).toBeUndefined()
} finally {
Expand All @@ -84,6 +87,44 @@ describe("installNetworkCapture", () => {
}
})

it("matches the full URL, never reads event streams, and keeps request bodies only when asked", async () => {
const realFetch = window.fetch
let streamed = false
window.fetch = async (input) =>
String(input).includes("events")
? new Response(
new ReadableStream({
start() {
streamed = true
},
}),
{ headers: { "content-type": "text/event-stream" } },
)
: new Response("ok", { headers: { "content-type": "text/plain" } })
try {
const events: SessionEvent[] = []
uninstall = installNetworkCapture(
(event) => events.push(event),
() => false,
{ urls: [new RegExp(`^${location.origin}/api/`)], maxLength: 100, requestBodies: false },
)
await fetch("/api/login", { method: "POST", body: "password=hunter2" })
await fetch("/api/events")
await vi.waitFor(() => expect(events.filter((event) => event.type === "network")).toHaveLength(2))

const byUrl = (part: string) => events.find((event) => event.net?.url.includes(part))
const login = byUrl("login")
const stream = byUrl("events")
expect(login?.attrs).toEqual({ "response.body": "ok" })
expect(stream?.attrs).toBeUndefined()
expect(streamed).toBe(true)
} finally {
uninstall?.()
uninstall = undefined
window.fetch = realFetch
}
})

it("reads only as much of a large body as it keeps", async () => {
const realFetch = window.fetch
let pulled = 0
Expand Down
Loading
Loading