Skip to content
Merged
42 changes: 42 additions & 0 deletions docs/browser-sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ Every field accepted by `MapleBrowser.init`:
| `errors` | `ErrorFilterOptions` | see [Filtering errors](#filtering-errors) | Drop captured errors by message, script URL, or a `beforeCapture` hook. |
| `replay.enabled` | `boolean` | `true` | Enable rrweb session recording. |
| `replay.sampleRate` | `number` | `1` | Fraction of sessions to record, `0` to `1`. Out-of-range values are clamped with a warning. See [Sampling](#sampling). |
| `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.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 @@ -495,6 +498,45 @@ Sampled traces carry the W3C `tracestate` threshold (`ot=th:…`), so Maple weig
inverse of the rate and request counts stay realistic. A trace joined from a server-rendered
`traceparent` follows the server's decision instead.

## Request and response detail

Headers go on the spans, as the HTTP semantic conventions define them. List the ones you want:

```ts
MapleBrowser.init({
// ...
tracing: { captureHeaders: { request: ["x-request-id"], response: ["x-cache", "server-timing"] } },
})
```

Each becomes a string-array attribute, e.g. `http.response.header.x-cache: ["HIT"]`.
`authorization`, `proxy-authorization`, `cookie` and `set-cookie` are never recorded, even when
listed. XHR spans get response headers only (the browser does not expose an XHR's request headers),
and a cross-origin response only exposes the headers its server lists in
`Access-Control-Expose-Headers`.

Bodies have no semantic-convention attribute, so they stay on the session replay's network events,
and only for the URLs you list:

```ts
replay: {
networkBodies: {
urls: [/^https:\/\/api\.example\.com\/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`, 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.

### Canvas

`replay: { canvasFps: 2 }` records `<canvas>` content (charts, maps, games) as WebP frames at up to
that rate. It costs CPU and upload size, so it is off by default. It is never recorded with
`privacy.maskAllText`, since text drawn into a canvas cannot be masked.

## Offline

The OTLP exporters already retry a failed export a few times (about 10 seconds in all). With
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 @@ -42,6 +42,7 @@ export { getObservedTraceIds, publishSessionSink, readSessionSink, recordTraceId
export type { TrackProps } from "./events/track"
export { track } from "./events/track"
export { isLikelyBot, parseUserAgent } from "./platform/user-agent"
export type { NetworkBodyOptions } from "./platform/transport"
export { ingestHeaders, SDK_HINT_HEADER, sdkHint } from "./platform/transport"
export { getVisitorId, isVisitorIdPersisted, setVisitorTracking } from "./identity/visitor"
export type { MapleRegion } from "./platform/region"
Expand Down
11 changes: 11 additions & 0 deletions packages/browser-session/src/platform/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,17 @@ export interface IngestConfig {
* follow it — the same source the session metadata row reads.
*/
readonly getIdentity?: (() => EventIdentity | undefined) | undefined
/** Record `<canvas>` content at this many frames per second. Off when unset or 0. */
readonly canvasFps?: number | undefined
/** Keep request and response bodies of these URLs on replay network events. */
readonly networkBodies?: NetworkBodyOptions | undefined
}

export interface NetworkBodyOptions {
/** Matched against the full request URL. Nothing is captured for other URLs. */
readonly urls: ReadonlyArray<string | RegExp>
/** Each body is cut to this many characters. */
readonly maxLength: number
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { afterEach, describe, expect, it } from "vitest"
import { afterEach, describe, expect, it, vi } from "vitest"
import { noteStartedTraceId } from "../../events/trace-id"
import type { SessionEvent } from "../../events/events-sink"
import { installNetworkCapture } from "./network"
Expand Down Expand Up @@ -48,4 +48,76 @@ describe("installNetworkCapture", () => {
expect(network?.traceId).toBe("0af7651916cd43dd8448eb211c80319c")
expect(network?.net?.method).toBe("GET")
})

it("keeps text bodies of listed URLs only, cut to the limit", async () => {
const realFetch = window.fetch
// Under the capture, like the network would be.
window.fetch = async () =>
new Response('{"order":"12345678"}', { headers: { "content-type": "application/json" } })
try {
const events: SessionEvent[] = []
uninstall = installNetworkCapture(
(event) => events.push(event),
() => false,
{
// Global on purpose: a stateful regex must still match every request.
urls: [/\/orders/g],
maxLength: 8,
},
)
await fetch("https://api.test/orders")
const response = await fetch("https://api.test/orders", {
method: "POST",
body: "request payload",
})
expect(await response.text()).toBe('{"order":"12345678"}')
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")
expect(listed?.attrs).toEqual({ "request.body": "request …", "response.body": '{"order"…' })
expect(other?.attrs).toBeUndefined()
} 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
const chunk = new TextEncoder().encode("x".repeat(1_000))
window.fetch = async () =>
new Response(
new ReadableStream({
pull(controller) {
pulled++
if (pulled > 1_000) controller.close()
else controller.enqueue(chunk)
},
}),
{ headers: { "content-type": "text/plain" } },
)
try {
const events: SessionEvent[] = []
uninstall = installNetworkCapture(
(event) => events.push(event),
() => false,
{
urls: ["https://api.test/"],
maxLength: 2_500,
},
)
await fetch("https://api.test/big")
await vi.waitFor(() => expect(events.some((event) => event.type === "network")).toBe(true))
const body = events.find((event) => event.type === "network")?.attrs?.["response.body"] ?? ""
expect(body).toHaveLength(2_501)
expect(pulled).toBeLessThan(10)
} finally {
uninstall?.()
uninstall = undefined
window.fetch = realFetch
}
})
})
101 changes: 96 additions & 5 deletions packages/browser-session/src/replay/capture/network.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,40 @@
import { type Emit, safeEmit } from "../../capture/shared"
import { activeTraceId, withStartedTraceId } from "../../events/trace-id"
import type { NetworkBodyOptions } from "../../platform/transport"

/** Only text is worth keeping; a body that is an image or a stream is not read. */
const TEXT_CONTENT = /^(text\/|application\/(json|xml|x-www-form-urlencoded|[\w.+-]+\+(json|xml)))/i

const matchesUrl = (url: string, patterns: ReadonlyArray<string | RegExp>): boolean =>
patterns.some((pattern) => {
if (typeof pattern === "string") return url.includes(pattern)
// A `g`/`y` regex is stateful: `test` advances `lastIndex`, so reset it first.
pattern.lastIndex = 0
return pattern.test(url)
})

const cut = (text: string, maxLength: number): string =>
text.length > maxLength ? `${text.slice(0, maxLength)}…` : text

/**
* Capture fetch + XHR requests as session events, tagged with the active trace
* id so each request links to its backend trace. `ignoreUrl` skips Maple's own
* ingest endpoints (otherwise capturing the session-events POST would loop).
* `bodies`, when set, keeps text request/response bodies of the URLs it lists.
*/
export function installNetworkCapture(emit: Emit, ignoreUrl: (url: string) => boolean): () => void {
export function installNetworkCapture(
emit: Emit,
ignoreUrl: (url: string) => boolean,
bodies?: NetworkBodyOptions,
): () => void {
const wantsBody = (url: string): boolean => bodies !== undefined && matchesUrl(url, bodies.urls)
const bodyAttrs = (
request: string | undefined,
response: string | undefined,
): Record<string, string> => ({
...(request && bodies ? { "request.body": cut(request, bodies.maxLength) } : undefined),
...(response && bodies ? { "response.body": cut(response, bodies.maxLength) } : undefined),
})
const origFetch = typeof window !== "undefined" ? window.fetch : undefined

if (origFetch) {
Expand All @@ -22,7 +50,32 @@
const call = withStartedTraceId(() => origFetch(input, init))
traceId = call.traceId ?? ambientTraceId
const res = await call.result
record(url, method, res.status, start, traceId)
if (!wantsBody(url)) {
record(url, method, res.status, start, traceId)
return res
}
// Read a clone in the background: the app gets its response untouched and unwaited.
const requestBody = typeof init?.body === "string" ? init.body : undefined
const done = performance.now()
const contentType = res.headers.get("content-type") ?? ""
void (

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fetch network event is deferred to the body read, so a streamed response records none

F6 · Warning · correctness

When networkBodies matches the URL, record runs only inside the .then after readPrefix, which loops until the accumulated text exceeds maxLength (1,000 by default) and awaits reader.read() in between (network.ts:157). A text/event-stream or long-poll response that stays open without delivering 1,000 characters never resolves the read, so the request never appears in the replay network panel at all — before this change the event was recorded as soon as the fetch settled. TEXT_CONTENT matches text/event-stream because of its text/ prefix.

Emit the network event as soon as the response settles and attach the bodies only to the read that has finished, or exclude `text/event-stream` from `TEXT_CONTENT` so streams take the plain path.
Prompt for an AI agent
In `packages/browser-session/src/replay/capture/network.ts:61-78`: Fetch network event is deferred to the body read, so a streamed response records none.

When `networkBodies` matches the URL, `record` runs only inside the `.then` after `readPrefix`, which loops until the accumulated text exceeds `maxLength` (1,000 by default) and awaits `reader.read()` in between (network.ts:157). A `text/event-stream` or long-poll response that stays open without delivering 1,000 characters never resolves the read, so the request never appears in the replay network panel at all — before this change the event was recorded as soon as the fetch settled. `TEXT_CONTENT` matches `text/event-stream` because of its `text/` prefix.

Suggested fix: Emit the network event as soon as the response settles and attach the bodies only to the read that has finished, or exclude `text/event-stream` from `TEXT_CONTENT` so streams take the plain path.

Verify the problem exists at that location before changing it, and keep the fix to those lines.

TEXT_CONTENT.test(contentType)
? readPrefix(res.clone(), bodies?.maxLength ?? 0)
: Promise.resolve(undefined)
)
.catch(() => undefined)
.then((responseBody) =>
record(
url,
method,
res.status,
start,
traceId,
undefined,
bodyAttrs(requestBody, responseBody),
done,
),
)

Check warning on line 78 in packages/browser-session/src/replay/capture/network.ts

View check run for this annotation

Maple Review Bot / Maple / review

correctness: Fetch network event is deferred to the body read, so a streamed response records none

When `networkBodies` matches the URL, `record` runs only inside the `.then` after `readPrefix`, which loops until the accumulated text exceeds `maxLength` (1,000 by default) and awaits `reader.read()` in between (network.ts:157). A `text/event-stream` or long-poll response that stays open without delivering 1,000 characters never resolves the read, so the request never appears in the replay network panel at all — before this change the event was recorded as soon as the fetch settled. `TEXT_CONTENT` matches `text/event-stream` because of its `text/` prefix.
return res
} catch (error) {
record(url, method, 0, start, traceId, String(error))
Expand All @@ -38,13 +91,16 @@
start: number,
traceId: string | undefined,
error?: string,
extra?: Record<string, string>,
end = performance.now(),
): void => {
if (ignoreUrl(url)) return
const attrs = { ...extra, ...(error ? { error } : undefined) }
safeEmit(emit, {
type: "network",
net: { method, url, status, durationMs: Math.round(performance.now() - start) },
net: { method, url, status, durationMs: Math.round(end - start) },
traceId,
...(error ? { attrs: { error } } : undefined),
...(Object.keys(attrs).length > 0 ? { attrs } : undefined),
})
}

Expand All @@ -70,8 +126,11 @@
const meta = this as XhrMeta
const start = performance.now()
let traceId = meta.__mapleTraceId ?? activeTraceId()
const url = meta.__mapleUrl ?? ""
const requestBody = typeof args[0] === "string" ? args[0] : undefined
this.addEventListener("loadend", () => {
record(meta.__mapleUrl ?? "", meta.__mapleMethod ?? "GET", this.status, start, traceId)
const extra = wantsBody(url) ? bodyAttrs(requestBody, xhrResponseText(this)) : undefined
record(url, meta.__mapleMethod ?? "GET", this.status, start, traceId, undefined, extra)
})
const call = withStartedTraceId(() => origSend.apply(this, args as never))
traceId = call.traceId ?? traceId
Expand All @@ -86,6 +145,38 @@
}
}

/**
* Up to `maxLength` characters of a response body (one more, so `cut` marks it
* cut), then the stream is cancelled: a large payload is never read in full.
*/
async function readPrefix(response: Response, maxLength: number): Promise<string | undefined> {
const reader = response.body?.getReader()
if (!reader) return undefined
const decoder = new TextDecoder()
let text = ""
while (text.length <= maxLength) {
const { done, value } = await reader.read()
if (done) return text + decoder.decode()
text += decoder.decode(value, { stream: true })
}
void reader.cancel().catch(() => {})
return text
}

/** A text or JSON XHR response, as text; the browser already holds it, so this only slices. */
function xhrResponseText(xhr: XMLHttpRequest): string | undefined {
if (!TEXT_CONTENT.test(xhr.getResponseHeader("content-type") ?? "")) return undefined
if (xhr.responseType === "" || xhr.responseType === "text") return xhr.responseText
if (xhr.responseType === "json") {
try {
return JSON.stringify(xhr.response)
} catch {
return undefined
}
}
return undefined
}

interface XhrMeta extends XMLHttpRequest {
__mapleMethod?: string
__mapleUrl?: string
Expand Down
5 changes: 4 additions & 1 deletion packages/browser-session/src/replay/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,10 @@ export function startEventCapture(config: IngestConfig, sessionId: string): Even
const sink: SessionEventSink = startEventSink(config, sessionId)
const emit = sink.emit

const uninstall = [installConsoleCapture(emit), installNetworkCapture(emit, sink.ignoreUrl)]
const uninstall = [
installConsoleCapture(emit),
installNetworkCapture(emit, sink.ignoreUrl, config.maskAllText ? undefined : config.networkBodies),
]

return {
// Only the capture listeners stop here — the sink outlives them, so
Expand Down
15 changes: 14 additions & 1 deletion packages/browser-session/src/replay/record.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@ let emitRef: EmitFn | undefined
const takeFullSnapshot = vi.fn()
const stopFn = vi.fn()

let recordOptions: Record<string, unknown> | undefined
vi.mock("rrweb", () => {
const record = (options: { emit: EmitFn }) => {
const record = (options: { emit: EmitFn } & Record<string, unknown>) => {
emitRef = options.emit
recordOptions = options
return stopFn
}
record.takeFullSnapshot = takeFullSnapshot
Expand Down Expand Up @@ -262,3 +264,14 @@ describe("startBufferedRecording", () => {
expect(stopFn).toHaveBeenCalled()
})
})

describe("canvas capture", () => {
it("is off by default and samples frames at canvasFps when asked", () => {
startRecording(CONFIG, "session-1").stop()
expect(recordOptions?.recordCanvas).toBeUndefined()
startBufferedRecording({ ...CONFIG, canvasFps: 2 }, "session-1").stop()
expect(recordOptions).toMatchObject({ recordCanvas: true, sampling: { canvas: 2 } })
startRecording({ ...CONFIG, canvasFps: 2, maskAllText: true }, "session-1").stop()
expect(recordOptions?.recordCanvas).toBeUndefined()
})
})
13 changes: 13 additions & 0 deletions packages/browser-session/src/replay/record.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ export function startRecording(config: IngestConfig, sessionId: string): Recorde
// rrweb has no `maskAllText` flag; selecting all elements masks every text node.
...(config.maskAllText ? { maskTextSelector: "*" } : undefined),
checkoutEveryNms: CHECKOUT_EVERY_MS,
...canvasOptions(config),
})

// The periodic flush yields to idle time so it never competes with an
Expand Down Expand Up @@ -261,6 +262,17 @@ export function startRecording(config: IngestConfig, sessionId: string): Recorde
}
}

/** rrweb options for `<canvas>` capture: sampled frames as WebP, off unless asked for. */
function canvasOptions(config: IngestConfig) {
// Canvas pixels can carry text (chart labels, grids) that maskAllText cannot reach.
if (config.maskAllText || !config.canvasFps || config.canvasFps <= 0) return undefined
return {
recordCanvas: true,
sampling: { canvas: config.canvasFps },
dataURLOptions: { type: "image/webp", quality: 0.6 },
}
}

/** Buffer mode checks out often, so the retained window stays near a minute. */
const BUFFER_CHECKOUT_MS = 30_000

Expand Down Expand Up @@ -327,6 +339,7 @@ export function startBufferedRecording(config: IngestConfig, sessionId: string):
blockSelector: BLOCK_SELECTOR,
...(config.maskAllText ? { maskTextSelector: "*" } : undefined),
checkoutEveryNms: BUFFER_CHECKOUT_MS,
...canvasOptions(config),
})

return {
Expand Down
Loading
Loading