From e06910ffe25b9477459ac17f5181a16f80da8f41 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 29 Sep 2026 20:40:49 +0200 Subject: [PATCH 1/4] feat(browser): request/response headers on spans, replay bodies and canvas - tracing.captureHeaders records allowlisted headers on fetch/XHR spans as the HTTP semconv attributes http.request.header. / http.response.header. (string arrays). authorization, proxy-authorization, cookie and set-cookie are never recorded, even when listed. XHR spans get response headers only. - replay.networkBodies keeps text/JSON request and response bodies of the listed URLs on the replay's network events (bodies have no semconv attribute, so they stay off spans), cut to maxLength (default 10,000). The response is read from a clone in the background, so the app gets it untouched and unwaited. Nothing is captured with privacy.maskAllText. - replay.canvasFps records content as WebP frames through rrweb's canvas capture, in both the streaming and buffered recorders. Off by default. Eager budget 43.5 -> 44 kB, first-party 18 -> 18.5 kB for the header hooks. --- docs/browser-sdk.md | 37 ++++++++++ packages/browser-session/src/index.ts | 1 + .../browser-session/src/platform/transport.ts | 11 +++ .../replay/capture/network.browser.test.ts | 35 ++++++++- .../src/replay/capture/network.ts | 74 +++++++++++++++++-- packages/browser-session/src/replay/events.ts | 5 +- .../browser-session/src/replay/record.test.ts | 13 +++- packages/browser-session/src/replay/record.ts | 12 +++ .../src/session/replay-session.ts | 8 +- packages/browser/scripts/size.ts | 9 ++- packages/browser/src/config.test.ts | 14 ++++ packages/browser/src/config.ts | 36 +++++++++ packages/browser/src/http-headers.ts | 32 ++++++++ packages/browser/src/init.ts | 2 + packages/browser/src/navigation.test.ts | 3 + packages/browser/src/tracing.browser.test.ts | 27 +++++++ packages/browser/src/tracing.ts | 36 ++++++++- 17 files changed, 339 insertions(+), 16 deletions(-) create mode 100644 packages/browser/src/http-headers.ts diff --git a/docs/browser-sdk.md b/docs/browser-sdk.md index de48772699..42c0838f93 100644 --- a/docs/browser-sdk.md +++ b/docs/browser-sdk.md @@ -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.` / `http.response.header.`. See [Request and response detail](#request-and-response-detail). | +| `replay.canvasFps` | `number` | off | Record `` 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 `` values in the recording. | @@ -515,6 +518,40 @@ 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/], maxLength: 10_000 } } +``` + +Only text and JSON bodies are kept, each cut to `maxLength` characters. The response is read from a +clone in the background, 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 `` content (charts, maps, games) as WebP frames at up to +that rate. It costs CPU and upload size, so it is off by default. + ## Offline The OTLP exporters already retry a failed export a few times (about 10 seconds in all). With diff --git a/packages/browser-session/src/index.ts b/packages/browser-session/src/index.ts index 42265a7802..ed5c7beeaf 100644 --- a/packages/browser-session/src/index.ts +++ b/packages/browser-session/src/index.ts @@ -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" diff --git a/packages/browser-session/src/platform/transport.ts b/packages/browser-session/src/platform/transport.ts index f1a9b24a6e..67e222cceb 100644 --- a/packages/browser-session/src/platform/transport.ts +++ b/packages/browser-session/src/platform/transport.ts @@ -19,6 +19,17 @@ export interface IngestConfig { * follow it — the same source the session metadata row reads. */ readonly getIdentity?: (() => EventIdentity | undefined) | undefined + /** Record `` 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 + /** Each body is cut to this many characters. */ + readonly maxLength: number } /** diff --git a/packages/browser-session/src/replay/capture/network.browser.test.ts b/packages/browser-session/src/replay/capture/network.browser.test.ts index ef5f08dece..38defc3a8c 100644 --- a/packages/browser-session/src/replay/capture/network.browser.test.ts +++ b/packages/browser-session/src/replay/capture/network.browser.test.ts @@ -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" @@ -48,4 +48,37 @@ 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, + { + urls: ["https://api.test/orders"], + maxLength: 8, + }, + ) + 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(2)) + + 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 + } + }) }) diff --git a/packages/browser-session/src/replay/capture/network.ts b/packages/browser-session/src/replay/capture/network.ts index 3e7ccb3e31..0421096981 100644 --- a/packages/browser-session/src/replay/capture/network.ts +++ b/packages/browser-session/src/replay/capture/network.ts @@ -1,12 +1,35 @@ 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): boolean => + patterns.some((pattern) => (typeof pattern === "string" ? url.includes(pattern) : 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 => ({ + ...(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) { @@ -22,7 +45,28 @@ export function installNetworkCapture(emit: Emit, ignoreUrl: (url: string) => bo 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 (TEXT_CONTENT.test(contentType) ? res.clone().text() : Promise.resolve(undefined)) + .catch(() => undefined) + .then((responseBody) => + record( + url, + method, + res.status, + start, + traceId, + undefined, + bodyAttrs(requestBody, responseBody), + done, + ), + ) return res } catch (error) { record(url, method, 0, start, traceId, String(error)) @@ -38,13 +82,16 @@ export function installNetworkCapture(emit: Emit, ignoreUrl: (url: string) => bo start: number, traceId: string | undefined, error?: string, + extra?: Record, + 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), }) } @@ -70,8 +117,11 @@ export function installNetworkCapture(emit: Emit, ignoreUrl: (url: string) => bo 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 @@ -86,6 +136,20 @@ export function installNetworkCapture(emit: Emit, ignoreUrl: (url: string) => bo } } +/** A text or JSON XHR response, as text; anything else is not read. */ +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 diff --git a/packages/browser-session/src/replay/events.ts b/packages/browser-session/src/replay/events.ts index a573b94bdb..cd0aefd584 100644 --- a/packages/browser-session/src/replay/events.ts +++ b/packages/browser-session/src/replay/events.ts @@ -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 diff --git a/packages/browser-session/src/replay/record.test.ts b/packages/browser-session/src/replay/record.test.ts index b329bd3e19..e85e8682f8 100644 --- a/packages/browser-session/src/replay/record.test.ts +++ b/packages/browser-session/src/replay/record.test.ts @@ -10,9 +10,11 @@ let emitRef: EmitFn | undefined const takeFullSnapshot = vi.fn() const stopFn = vi.fn() +let recordOptions: Record | undefined vi.mock("rrweb", () => { - const record = (options: { emit: EmitFn }) => { + const record = (options: { emit: EmitFn } & Record) => { emitRef = options.emit + recordOptions = options return stopFn } record.takeFullSnapshot = takeFullSnapshot @@ -262,3 +264,12 @@ 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 } }) + }) +}) diff --git a/packages/browser-session/src/replay/record.ts b/packages/browser-session/src/replay/record.ts index 1f181d1e52..2a2ba4166c 100644 --- a/packages/browser-session/src/replay/record.ts +++ b/packages/browser-session/src/replay/record.ts @@ -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 @@ -261,6 +262,16 @@ export function startRecording(config: IngestConfig, sessionId: string): Recorde } } +/** rrweb options for `` capture: sampled frames as WebP, off unless asked for. */ +function canvasOptions(config: IngestConfig) { + if (!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 @@ -327,6 +338,7 @@ export function startBufferedRecording(config: IngestConfig, sessionId: string): blockSelector: BLOCK_SELECTOR, ...(config.maskAllText ? { maskTextSelector: "*" } : undefined), checkoutEveryNms: BUFFER_CHECKOUT_MS, + ...canvasOptions(config), }) return { diff --git a/packages/browser-session/src/session/replay-session.ts b/packages/browser-session/src/session/replay-session.ts index 0ce3aafd8c..e6238fdeca 100644 --- a/packages/browser-session/src/session/replay-session.ts +++ b/packages/browser-session/src/session/replay-session.ts @@ -14,7 +14,7 @@ import { startBufferedRecording, startRecording, } from "../replay/record" -import { postSessionMeta, type IngestConfig } from "../platform/transport" +import { type IngestConfig, type NetworkBodyOptions, postSessionMeta } from "../platform/transport" import { type SessionLifecycleHandle, type SessionLifecycleOptions, startSessionLifecycle } from "./lifecycle" import { markReplayTriggered } from "./session" import { getObservedTraceIds, publishSessionSink } from "./sink" @@ -28,6 +28,10 @@ export interface ReplaySessionOptions extends SessionLifecycleOptions { readonly sdk: string readonly maskAllInputs: boolean readonly maskAllText: boolean + /** Record `` 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 /** Notifies consumers that the session id used for span linking changed. */ readonly onSessionChange?: ((sessionId: string) => void) | undefined /** @@ -65,6 +69,8 @@ export function startReplaySession(options: ReplaySessionOptions): ReplaySession maskAllInputs: options.maskAllInputs, maskAllText: options.maskAllText, getIdentity: options.getIdentity, + canvasFps: options.canvasFps, + networkBodies: options.networkBodies, } let recorder: Recorder | undefined diff --git a/packages/browser/scripts/size.ts b/packages/browser/scripts/size.ts index d96ca417d8..4d7a813c07 100644 --- a/packages/browser/scripts/size.ts +++ b/packages/browser/scripts/size.ts @@ -24,14 +24,15 @@ import { gzipSync } from "node:zlib" /** Ceilings in gzipped KB. Raise deliberately, with the reason in the commit. */ const BUDGET = { /** - * 43.5 since 2026-09: `sendFeedback` (~0.3 kB). 43: XHR spans and the HTTP status policy, which must patch + * 44 since 2026-09: `tracing.captureHeaders` in the request hooks (~0.3 kB). + * 43.5: `sendFeedback` (~0.3 kB). 43: XHR spans and the HTTP status policy, which must patch * before the app's first request (~1.5 kB). Document timing went to the * deferred chunk instead. 42: error filters and cause chains. 41 before that: * per-session trace sampling and the `logger` queue added ~2.4 kB (~1.2 kB * code, the rest chunk-split overhead now that a second chunk shares the OTel * core). Was 38 for navigation spans. */ - eager: 43.5, + eager: 44, /** * Every page load, after `init()`: the OTel logs SDK and exporter, document * timing, and `web-vitals` (~3.3 kB, 8 -> 12). @@ -64,9 +65,9 @@ const BUDGET = { * (~0.5 kB), both needed before the deferred chunk lands. 17 for error * filters and cause chains (~0.8 kB), which run on the capture path. 17.5 * for `errors.captureHttpStatus`, applied by the span exporter. 18 for the - * `sendFeedback` API. + * `sendFeedback` API, 18.5 for `tracing.captureHeaders`. */ - firstParty: 18, + firstParty: 18.5, } /** How close to a ceiling counts as worth warning about. */ diff --git a/packages/browser/src/config.test.ts b/packages/browser/src/config.test.ts index db4a332c51..683d4073a4 100644 --- a/packages/browser/src/config.test.ts +++ b/packages/browser/src/config.test.ts @@ -119,4 +119,18 @@ describe("resolveConfig", () => { expect(warn).toHaveBeenCalledTimes(2) warn.mockRestore() }) + + it("drops credential headers from captureHeaders, lowercased", () => { + const config = resolveConfig({ + ingestKey: "k", + serviceName: "s", + tracing: { + captureHeaders: { + request: ["Authorization", "X-Request-Id"], + response: ["Set-Cookie", "X-Cache"], + }, + }, + }) + expect(config.captureHeaders).toEqual({ request: ["x-request-id"], response: ["x-cache"] }) + }) }) diff --git a/packages/browser/src/config.ts b/packages/browser/src/config.ts index 14d0e3182a..caa8923233 100644 --- a/packages/browser/src/config.ts +++ b/packages/browser/src/config.ts @@ -8,6 +8,7 @@ import { warnIfKeylessMapleIngest, } from "@maple/browser-session" import type { ErrorFilterOptions } from "./error-filters" +import { type HeaderCapture, resolveHeaderCapture } from "./http-headers" export type ConsoleLevel = "debug" | "log" | "info" | "warn" | "error" @@ -81,6 +82,16 @@ export interface MapleBrowserConfig { * always exported. */ readonly sampleRate?: number + /** + * Request and response headers to record on `fetch`/XHR spans, as + * `http.request.header.` / `http.response.header.`, e.g. + * `{ response: ["x-request-id", "x-cache"] }`. `authorization`, `cookie` + * and `set-cookie` are never recorded. XHR spans get response headers only. + */ + readonly captureHeaders?: { + readonly request?: ReadonlyArray + readonly response?: ReadonlyArray + } } /** * Report Core Web Vitals (LCP, CLS, INP, FCP, TTFB) as `browser.web_vital` @@ -123,6 +134,18 @@ export interface MapleBrowserConfig { * error happens. 0–1, default 0. */ readonly onErrorSampleRate?: number + /** Record `` content at this many frames per second, e.g. 2. Off by default: it is heavy. */ + readonly canvasFps?: number + /** + * Keep request and response bodies (text and JSON only, cut to + * `maxLength`, default 10,000 characters) on the replay's network events + * for these URLs. Nothing is captured for other URLs, or with + * `privacy.maskAllText`. + */ + readonly networkBodies?: { + readonly urls: ReadonlyArray + readonly maxLength?: number + } } readonly privacy?: { /** Mask all `` values. Default true. */ @@ -193,6 +216,11 @@ export interface ResolvedConfig { readonly replayEnabled: boolean readonly replaySampleRate: number readonly replayOnErrorSampleRate: number + readonly canvasFps: number | undefined + readonly networkBodies: + | { readonly urls: ReadonlyArray; readonly maxLength: number } + | undefined + readonly captureHeaders: HeaderCapture readonly maskAllInputs: boolean readonly maskAllText: boolean readonly persistVisitorId: boolean @@ -264,6 +292,14 @@ export function resolveConfig(config: MapleBrowserConfig): ResolvedConfig { offlineQueue: config.transport?.offline ?? false, replayEnabled: config.replay?.enabled ?? true, replaySampleRate: resolveSampleRate("replay.sampleRate", config.replay?.sampleRate), + canvasFps: config.replay?.canvasFps, + networkBodies: config.replay?.networkBodies?.urls.length + ? { + urls: config.replay.networkBodies.urls, + maxLength: config.replay.networkBodies.maxLength ?? 10_000, + } + : undefined, + captureHeaders: resolveHeaderCapture(config.tracing?.captureHeaders), replayOnErrorSampleRate: config.replay?.onErrorSampleRate === undefined ? 0 diff --git a/packages/browser/src/http-headers.ts b/packages/browser/src/http-headers.ts new file mode 100644 index 0000000000..1d2f329d89 --- /dev/null +++ b/packages/browser/src/http-headers.ts @@ -0,0 +1,32 @@ +// Allowlisted request/response headers as the HTTP semconv span attributes +// `http.request.header.` / `http.response.header.` (string arrays). +import type { Span } from "@opentelemetry/api" + +/** Never recorded, even when listed: they carry credentials. */ +const CREDENTIAL_HEADERS = new Set(["authorization", "proxy-authorization", "cookie", "set-cookie"]) + +export interface HeaderCapture { + readonly request: ReadonlyArray + readonly response: ReadonlyArray +} + +export function resolveHeaderCapture( + raw: { readonly request?: ReadonlyArray; readonly response?: ReadonlyArray } | undefined, +): HeaderCapture { + const clean = (names: ReadonlyArray | undefined) => + (names ?? []).map((name) => name.toLowerCase()).filter((name) => !CREDENTIAL_HEADERS.has(name)) + return { request: clean(raw?.request), response: clean(raw?.response) } +} + +/** Stamp the listed headers that `read` finds. Cross-origin responses expose only CORS-safelisted headers unless the server lists more in `Access-Control-Expose-Headers`. */ +export function setHeaderAttributes( + span: Span, + direction: "request" | "response", + names: ReadonlyArray, + read: (name: string) => string | null | undefined, +): void { + for (const name of names) { + const value = read(name) + if (value) span.setAttribute(`http.${direction}.header.${name}`, value.split(/,\s*/)) + } +} diff --git a/packages/browser/src/init.ts b/packages/browser/src/init.ts index a819496273..00b0da8905 100644 --- a/packages/browser/src/init.ts +++ b/packages/browser/src/init.ts @@ -184,6 +184,8 @@ export function init(rawConfig: MapleBrowserConfig): MapleBrowserHandle { maskAllInputs: config.maskAllInputs, maskAllText: config.maskAllText, mode: replayMode, + canvasFps: config.canvasFps, + networkBodies: config.networkBodies, }) }) .catch(() => { diff --git a/packages/browser/src/navigation.test.ts b/packages/browser/src/navigation.test.ts index cf08b6f6c8..d215ad35db 100644 --- a/packages/browser/src/navigation.test.ts +++ b/packages/browser/src/navigation.test.ts @@ -36,6 +36,9 @@ const CONFIG = { replayEnabled: false, replaySampleRate: 0, replayOnErrorSampleRate: 0, + canvasFps: undefined, + networkBodies: undefined, + captureHeaders: { request: [], response: [] }, maskAllInputs: true, maskAllText: false, persistVisitorId: true, diff --git a/packages/browser/src/tracing.browser.test.ts b/packages/browser/src/tracing.browser.test.ts index f8b19ac41d..cb57677ffa 100644 --- a/packages/browser/src/tracing.browser.test.ts +++ b/packages/browser/src/tracing.browser.test.ts @@ -45,6 +45,9 @@ const CONFIG = { replayEnabled: false, replaySampleRate: 0, replayOnErrorSampleRate: 0, + canvasFps: undefined, + networkBodies: undefined, + captureHeaders: { request: [], response: [] }, maskAllInputs: true, maskAllText: false, persistVisitorId: true, @@ -233,6 +236,30 @@ describe("setupTracing unload flush", () => { URL.revokeObjectURL(url) }) + it("records allowlisted headers as semconv attributes, never credentials", async () => { + vi.useFakeTimers({ toFake: ["setTimeout"] }) + const poll = { interval: 0 } + shutdown = setupTracing({ + ...CONFIG, + tracingInstrumentFetch: true, + captureHeaders: { request: ["x-request-id"], response: ["content-type"] }, + }) + const url = URL.createObjectURL(new Blob(["ok"], { type: "text/plain" })) + + await ( + await fetch(url, { headers: { "x-request-id": "req-1", authorization: "Bearer secret" } }) + ).text() + await vi.waitFor(() => expect(vi.getTimerCount()).toBe(1), poll) + window.dispatchEvent(new Event("pagehide")) + await vi.waitFor(() => expect(exported).toHaveLength(1), poll) + + const attributes = exported[0]?.attributes ?? {} + expect(attributes["http.request.header.x-request-id"]).toEqual(["req-1"]) + expect(attributes["http.response.header.content-type"]).toEqual(["text/plain"]) + expect(Object.keys(attributes).some((key) => key.includes("authorization"))).toBe(false) + URL.revokeObjectURL(url) + }) + it("does not end a fetch span the instrumentation already ended", async () => { const errors: string[] = [] const noop = (): void => {} diff --git a/packages/browser/src/tracing.ts b/packages/browser/src/tracing.ts index 6c8fc8d6ea..7c9e0e172e 100644 --- a/packages/browser/src/tracing.ts +++ b/packages/browser/src/tracing.ts @@ -25,6 +25,7 @@ import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base" import { WebTracerProvider } from "@opentelemetry/sdk-trace-web" import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from "@opentelemetry/semantic-conventions" import type { ResolvedConfig } from "./config" +import { setHeaderAttributes } from "./http-headers" import { HttpStatusExporter } from "./http-status" import { OfflineSpanExporter } from "./offline" import { SessionSampler } from "./sampling" @@ -256,11 +257,40 @@ export function setupTracing(config: ResolvedConfig): () => Promise { // `traceparent` goes to same-origin requests only, unless the app lists // the cross-origin APIs that accept it. propagateTraceHeaderCorsUrls: [...config.propagateTraceHeaderCorsUrls], - applyCustomAttributesOnSpan: noteSettled, } + const headers = config.captureHeaders const instrumentations = [ - ...(config.tracingInstrumentFetch ? [new FetchInstrumentation(requestOptions)] : []), - ...(config.tracingInstrumentXhr ? [new XMLHttpRequestInstrumentation(requestOptions)] : []), + ...(config.tracingInstrumentFetch + ? [ + new FetchInstrumentation({ + ...requestOptions, + applyCustomAttributesOnSpan: (span, request, result) => { + noteSettled(span) + const sent = + request instanceof Request ? request.headers : new Headers(request.headers) + setHeaderAttributes(span, "request", headers.request, (name) => sent.get(name)) + if (result instanceof Response) { + setHeaderAttributes(span, "response", headers.response, (name) => + result.headers.get(name), + ) + } + }, + }), + ] + : []), + ...(config.tracingInstrumentXhr + ? [ + new XMLHttpRequestInstrumentation({ + ...requestOptions, + applyCustomAttributesOnSpan: (span, xhr) => { + noteSettled(span) + setHeaderAttributes(span, "response", headers.response, (name) => + xhr.getResponseHeader(name), + ) + }, + }), + ] + : []), ] const unregisterInstrumentations = instrumentations.length > 0 From d31138f289673c9eabab7e56741954cdc3ecd055 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 29 Sep 2026 22:16:27 +0200 Subject: [PATCH 2/4] fix(browser-session): no canvas capture under maskAllText Canvas frames are pixels, so text drawn into a canvas (chart labels, grids) reached Maple even when the app asked for all text masked. canvasFps is now ignored with maskAllText. --- docs/browser-sdk.md | 3 ++- packages/browser-session/src/replay/record.test.ts | 2 ++ packages/browser-session/src/replay/record.ts | 3 ++- packages/browser/src/config.ts | 5 ++++- 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/docs/browser-sdk.md b/docs/browser-sdk.md index 42c0838f93..d5aad78332 100644 --- a/docs/browser-sdk.md +++ b/docs/browser-sdk.md @@ -550,7 +550,8 @@ allowed to record. ### Canvas `replay: { canvasFps: 2 }` records `` content (charts, maps, games) as WebP frames at up to -that rate. It costs CPU and upload size, so it is off by default. +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 diff --git a/packages/browser-session/src/replay/record.test.ts b/packages/browser-session/src/replay/record.test.ts index e85e8682f8..475bc3611d 100644 --- a/packages/browser-session/src/replay/record.test.ts +++ b/packages/browser-session/src/replay/record.test.ts @@ -271,5 +271,7 @@ describe("canvas capture", () => { 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() }) }) diff --git a/packages/browser-session/src/replay/record.ts b/packages/browser-session/src/replay/record.ts index 2a2ba4166c..567ae9db6e 100644 --- a/packages/browser-session/src/replay/record.ts +++ b/packages/browser-session/src/replay/record.ts @@ -264,7 +264,8 @@ export function startRecording(config: IngestConfig, sessionId: string): Recorde /** rrweb options for `` capture: sampled frames as WebP, off unless asked for. */ function canvasOptions(config: IngestConfig) { - if (!config.canvasFps || config.canvasFps <= 0) return undefined + // 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 }, diff --git a/packages/browser/src/config.ts b/packages/browser/src/config.ts index caa8923233..e5616c873f 100644 --- a/packages/browser/src/config.ts +++ b/packages/browser/src/config.ts @@ -134,7 +134,10 @@ export interface MapleBrowserConfig { * error happens. 0–1, default 0. */ readonly onErrorSampleRate?: number - /** Record `` content at this many frames per second, e.g. 2. Off by default: it is heavy. */ + /** + * Record `` content at this many frames per second, e.g. 2. Off by + * default: it is heavy. Never with `privacy.maskAllText`, since canvas pixels can hold text. + */ readonly canvasFps?: number /** * Keep request and response bodies (text and JSON only, cut to From 1b0d90a32478da394a5be49c081f85ae123162a9 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 29 Sep 2026 22:18:57 +0200 Subject: [PATCH 3/4] fix(browser): read only the kept part of a body, keep header values whole - Response bodies were read in full to keep maxLength characters; the cloned stream is now read until maxLength and cancelled. - Header values were split on commas, mangling values like date and cache-control. Each is one array entry; the browser has already joined repeated headers. --- docs/browser-sdk.md | 2 +- .../replay/capture/network.browser.test.ts | 37 +++++++++++++++++++ .../src/replay/capture/network.ts | 26 ++++++++++++- packages/browser/src/http-headers.ts | 4 +- 4 files changed, 65 insertions(+), 4 deletions(-) diff --git a/docs/browser-sdk.md b/docs/browser-sdk.md index d5aad78332..261e3a23ba 100644 --- a/docs/browser-sdk.md +++ b/docs/browser-sdk.md @@ -543,7 +543,7 @@ replay: { networkBodies: { urls: [/^https:\/\/api\.example\.com\/checkout/], max ``` Only text and JSON bodies are kept, each cut to `maxLength` characters. The response is read from a -clone in the background, so your code gets it untouched and unwaited. Nothing is captured with +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. diff --git a/packages/browser-session/src/replay/capture/network.browser.test.ts b/packages/browser-session/src/replay/capture/network.browser.test.ts index 38defc3a8c..7430f091a3 100644 --- a/packages/browser-session/src/replay/capture/network.browser.test.ts +++ b/packages/browser-session/src/replay/capture/network.browser.test.ts @@ -81,4 +81,41 @@ describe("installNetworkCapture", () => { 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 + } + }) }) diff --git a/packages/browser-session/src/replay/capture/network.ts b/packages/browser-session/src/replay/capture/network.ts index 0421096981..ea3e03d984 100644 --- a/packages/browser-session/src/replay/capture/network.ts +++ b/packages/browser-session/src/replay/capture/network.ts @@ -53,7 +53,11 @@ export function installNetworkCapture( const requestBody = typeof init?.body === "string" ? init.body : undefined const done = performance.now() const contentType = res.headers.get("content-type") ?? "" - void (TEXT_CONTENT.test(contentType) ? res.clone().text() : Promise.resolve(undefined)) + void ( + TEXT_CONTENT.test(contentType) + ? readPrefix(res.clone(), bodies?.maxLength ?? 0) + : Promise.resolve(undefined) + ) .catch(() => undefined) .then((responseBody) => record( @@ -136,7 +140,25 @@ export function installNetworkCapture( } } -/** A text or JSON XHR response, as text; anything else is not read. */ +/** + * 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 { + 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 diff --git a/packages/browser/src/http-headers.ts b/packages/browser/src/http-headers.ts index 1d2f329d89..ee7653358c 100644 --- a/packages/browser/src/http-headers.ts +++ b/packages/browser/src/http-headers.ts @@ -27,6 +27,8 @@ export function setHeaderAttributes( ): void { for (const name of names) { const value = read(name) - if (value) span.setAttribute(`http.${direction}.header.${name}`, value.split(/,\s*/)) + // One entry: commas are part of many values (`date`, `cache-control`), and the + // browser has already joined repeated headers into one string. + if (value) span.setAttribute(`http.${direction}.header.${name}`, [value]) } } From a38cd6e3024c8a94926a43c763c9438e2c9f7f5e Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 29 Sep 2026 23:27:46 +0200 Subject: [PATCH 4/4] fix(browser): cap replay bodies at what ingest keeps, match global regexes - /v1/sessionEvents keeps 1,024 bytes of an attribute, so a 10,000 character body arrived cut to ~1 KB anyway. maxLength now defaults to and is capped at 1,000 characters (room for the cut marker), and the docs say so. Reading the body stops there too. - A g/y RegExp in networkBodies.urls matched only every other request; lastIndex is reset before each test. --- docs/browser-sdk.md | 8 ++++++-- .../src/replay/capture/network.browser.test.ts | 8 +++++--- .../browser-session/src/replay/capture/network.ts | 7 ++++++- packages/browser/src/config.test.ts | 12 ++++++++++++ packages/browser/src/config.ts | 15 +++++++++++---- 5 files changed, 40 insertions(+), 10 deletions(-) diff --git a/docs/browser-sdk.md b/docs/browser-sdk.md index e49d005b16..cad2d9f599 100644 --- a/docs/browser-sdk.md +++ b/docs/browser-sdk.md @@ -519,10 +519,14 @@ Bodies have no semantic-convention attribute, so they stay on the session replay and only for the URLs you list: ```ts -replay: { networkBodies: { urls: [/^https:\/\/api\.example\.com\/checkout/], maxLength: 10_000 } } +replay: { + networkBodies: { + urls: [/^https:\/\/api\.example\.com\/checkout/] + } +} ``` -Only text and JSON bodies are kept, each cut to `maxLength` characters. The response is read from a +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. diff --git a/packages/browser-session/src/replay/capture/network.browser.test.ts b/packages/browser-session/src/replay/capture/network.browser.test.ts index 7430f091a3..7872e90dc6 100644 --- a/packages/browser-session/src/replay/capture/network.browser.test.ts +++ b/packages/browser-session/src/replay/capture/network.browser.test.ts @@ -60,19 +60,21 @@ describe("installNetworkCapture", () => { (event) => events.push(event), () => false, { - urls: ["https://api.test/orders"], + // 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(2)) + await vi.waitFor(() => expect(events.filter((event) => event.type === "network")).toHaveLength(3)) - const [listed, other] = events.filter((event) => event.type === "network") + const [, listed, other] = events.filter((event) => event.type === "network") expect(listed?.attrs).toEqual({ "request.body": "request …", "response.body": '{"order"…' }) expect(other?.attrs).toBeUndefined() } finally { diff --git a/packages/browser-session/src/replay/capture/network.ts b/packages/browser-session/src/replay/capture/network.ts index ea3e03d984..ab716e017f 100644 --- a/packages/browser-session/src/replay/capture/network.ts +++ b/packages/browser-session/src/replay/capture/network.ts @@ -6,7 +6,12 @@ import type { NetworkBodyOptions } from "../../platform/transport" const TEXT_CONTENT = /^(text\/|application\/(json|xml|x-www-form-urlencoded|[\w.+-]+\+(json|xml)))/i const matchesUrl = (url: string, patterns: ReadonlyArray): boolean => - patterns.some((pattern) => (typeof pattern === "string" ? url.includes(pattern) : pattern.test(url))) + 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 diff --git a/packages/browser/src/config.test.ts b/packages/browser/src/config.test.ts index 683d4073a4..507f458db0 100644 --- a/packages/browser/src/config.test.ts +++ b/packages/browser/src/config.test.ts @@ -133,4 +133,16 @@ describe("resolveConfig", () => { }) expect(config.captureHeaders).toEqual({ request: ["x-request-id"], response: ["x-cache"] }) }) + + it("caps network body length at what ingest keeps", () => { + const resolve = (maxLength?: number) => + resolveConfig({ + ingestKey: "k", + serviceName: "s", + replay: { networkBodies: { urls: ["/api"], maxLength } }, + }).networkBodies?.maxLength + expect(resolve()).toBe(1_000) + expect(resolve(200)).toBe(200) + expect(resolve(50_000)).toBe(1_000) + }) }) diff --git a/packages/browser/src/config.ts b/packages/browser/src/config.ts index e5616c873f..9ca8c820bb 100644 --- a/packages/browser/src/config.ts +++ b/packages/browser/src/config.ts @@ -10,6 +10,9 @@ import { import type { ErrorFilterOptions } from "./error-filters" import { type HeaderCapture, resolveHeaderCapture } from "./http-headers" +/** Ingest keeps 1,024 bytes of a session-event attribute; this leaves room for the cut marker. */ +const MAX_BODY_LENGTH = 1_000 + export type ConsoleLevel = "debug" | "log" | "info" | "warn" | "error" /** Public configuration for `MapleBrowser.init`. */ @@ -140,9 +143,10 @@ export interface MapleBrowserConfig { */ readonly canvasFps?: number /** - * Keep request and response bodies (text and JSON only, cut to - * `maxLength`, default 10,000 characters) on the replay's network events - * for these URLs. Nothing is captured for other URLs, or with + * Keep request and response bodies (text and JSON only) on the replay's + * network events for these URLs, cut to `maxLength` characters. Ingest + * keeps at most 1,024 bytes of each, so `maxLength` is capped at 1,000 + * (the default). Nothing is captured for other URLs, or with * `privacy.maskAllText`. */ readonly networkBodies?: { @@ -299,7 +303,10 @@ export function resolveConfig(config: MapleBrowserConfig): ResolvedConfig { networkBodies: config.replay?.networkBodies?.urls.length ? { urls: config.replay.networkBodies.urls, - maxLength: config.replay.networkBodies.maxLength ?? 10_000, + maxLength: Math.min( + MAX_BODY_LENGTH, + config.replay.networkBodies.maxLength ?? MAX_BODY_LENGTH, + ), } : undefined, captureHeaders: resolveHeaderCapture(config.tracing?.captureHeaders),