Skip to content

feat(browser): request/response headers on spans, replay bodies and canvas - #1157

Merged
Makisuo merged 8 commits into
feat/browser-sdk-offline-queuefrom
feat/browser-sdk-network-canvas
Sep 29, 2026
Merged

Makisuo merged 8 commits into
feat/browser-sdk-offline-queuefrom
feat/browser-sdk-network-canvas

Conversation

@Makisuo

@Makisuo Makisuo commented Sep 29, 2026 •

Copy link
Copy Markdown
Collaborator

Part 11 of the browser SDK stack. Based on the offline queue PR.

What changes

  • tracing.captureHeaders records allowlisted headers on fetch/XHR spans as the HTTP semconv attributes http.request.header.<name> / http.response.header.<name> (string arrays).
    • authorization, proxy-authorization, cookie and set-cookie are never recorded, even when listed.
    • XHR spans get response headers only, since the browser doesn't expose an XHR's request headers.
  • 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.
    • Each body is 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.
    • This lives in @maple/browser-session's network capture, so the Effect SDK can adopt it later.
  • replay.canvasFps records <canvas> 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, which must live in the instrumentation config.

Follow-up

The session replay network panel doesn't render attrs bodies yet; the data is stored on the session event rows.

Testing

  • Browser: allowlisted request/response headers land as semconv string arrays while authorization doesn't.
  • Config: credential headers are removed and names are lowercased.
  • Network capture (browser, fake fetch under the capture): listed URLs keep cut bodies; unlisted URLs keep none.
  • Recorder: canvas options are passed only when canvasFps is set.

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

…anvas

- tracing.captureHeaders records allowlisted headers on fetch/XHR spans as
  the HTTP semconv attributes http.request.header.<name> /
  http.response.header.<name> (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 <canvas> 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.
@coderabbitai

coderabbitai Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 54 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 4 included reviews currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 5a13cb6f-34d7-46a0-8a9f-a44c707f2490

📥 Commits

Reviewing files that changed from the base of the PR and between e5c997c and a38cd6e.

📒 Files selected for processing (17)
  • docs/browser-sdk.md
  • packages/browser-session/src/index.ts
  • packages/browser-session/src/platform/transport.ts
  • packages/browser-session/src/replay/capture/network.browser.test.ts
  • packages/browser-session/src/replay/capture/network.ts
  • packages/browser-session/src/replay/events.ts
  • packages/browser-session/src/replay/record.test.ts
  • packages/browser-session/src/replay/record.ts
  • packages/browser-session/src/session/replay-session.ts
  • packages/browser/scripts/size.ts
  • packages/browser/src/config.test.ts
  • packages/browser/src/config.ts
  • packages/browser/src/http-headers.ts
  • packages/browser/src/init.ts
  • packages/browser/src/navigation.test.ts
  • packages/browser/src/tracing.browser.test.ts
  • packages/browser/src/tracing.ts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@maple-review-bot

maple-review-bot Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Maple review

Confidence 3/5 · needs attention
The canvas capture is the one place the new masking rule is not applied; the header and body paths are guarded and tested.
quality 86/100 · 1 warning · 2 notes · tests covered · risk medium

Adds opt-in request/response header attributes on fetch/XHR spans, text bodies on replay network events, and rrweb canvas frames. The mechanics are sound and tested; canvas capture is the one path that ignores the masking rule the bodies path honours.

  • tracing.captureHeaders stamps allowlisted headers on fetch/XHR spans as semconv arrays
  • replay.networkBodies keeps cut text bodies on network session events
  • replay.canvasFps records <canvas> frames through rrweb's canvas capture
  • resolveHeaderCapture lowercases names and drops credential headers

Findings

Warning · F1 · Canvas capture ignores privacy.maskAllText

security · packages/browser-session/src/replay/record.ts:266-267

canvasFps turns on rrweb's canvas capture unconditionally, and rrweb serializes canvas pixels: maskTextSelector: "*" masks DOM text nodes, so text drawn into a <canvas> (a chart's labels, a grid) still reaches Maple while the same session suppresses bodies at packages/browser-session/src/replay/events.ts:36. An app that turned on maskAllText and canvasFps ships the pixels of the content it asked to mask.

function canvasOptions(config: IngestConfig) {
	if (config.maskAllText || !config.canvasFps || config.canvasFps <= 0) return undefined
Note · F2 · A listed header whose value contains a comma is split into several entries

correctness · packages/browser/src/http-headers.ts:30

value.split(/,\s*/) splits every header on commas, so a value that legitimately contains one is stored mangled: listing date records ["Wed", "21 Oct 2015 07:28:00 GMT"], cache-control records ["max-age=0", "must-revalidate"]. The HTTP semconv these attributes follow wants the values as they were sent — a single-item array when the instrumentation only sees one value.

Keep the whole value as a single-element array, or split only for headers you know repeat (the browser's `Headers.get` already joins repeats with `, `).
Note · F3 · The whole response body is materialized to keep maxLength characters

performance · packages/browser-session/src/replay/capture/network.ts:56

res.clone().text() reads and decodes the entire body before cut trims it, so a listed endpoint returning a multi-megabyte JSON payload makes the SDK materialize all of it to retain 10,000 characters; xhrResponseText at network.ts:141-149 does the same, re-running JSON.stringify over a whole parsed object. maxLength currently bounds only what is stored, not the work.

What was checked
  • Header attribute keys match the server side's http.request.header.* (apps/api/src/http/api-observability.ts:33)
  • maskAllText suppresses body capture at packages/browser-session/src/replay/events.ts:36
  • Body capture matches listed URLs only; unlisted URLs keep no attrs (network capture test)
Copy all findings (3)
Findings from an automated review of commit e06910ffe25b9477459ac17f5181a16f80da8f41. Verify each one against the current code before changing anything, fix only those that still apply, and keep each fix to the lines it names.

---

F1 · Warning · security · packages/browser-session/src/replay/record.ts:266-267
Canvas capture ignores `privacy.maskAllText`
`canvasFps` turns on rrweb's canvas capture unconditionally, and rrweb serializes canvas pixels: `maskTextSelector: "*"` masks DOM text nodes, so text drawn into a `<canvas>` (a chart's labels, a grid) still reaches Maple while the same session suppresses bodies at `packages/browser-session/src/replay/events.ts:36`. An app that turned on `maskAllText` and `canvasFps` ships the pixels of the content it asked to mask.
Replace those lines with:
function canvasOptions(config: IngestConfig) {
	if (config.maskAllText || !config.canvasFps || config.canvasFps <= 0) return undefined

---

F2 · Note · correctness · packages/browser/src/http-headers.ts:30
A listed header whose value contains a comma is split into several entries
`value.split(/,\s*/)` splits every header on commas, so a value that legitimately contains one is stored mangled: listing `date` records `["Wed", "21 Oct 2015 07:28:00 GMT"]`, `cache-control` records `["max-age=0", "must-revalidate"]`. The HTTP semconv these attributes follow wants the values as they were sent — a single-item array when the instrumentation only sees one value.
Suggested fix: Keep the whole value as a single-element array, or split only for headers you know repeat (the browser's `Headers.get` already joins repeats with `, `).

---

F3 · Note · performance · packages/browser-session/src/replay/capture/network.ts:56
The whole response body is materialized to keep `maxLength` characters
`res.clone().text()` reads and decodes the entire body before `cut` trims it, so a listed endpoint returning a multi-megabyte JSON payload makes the SDK materialize all of it to retain 10,000 characters; `xhrResponseText` at network.ts:141-149 does the same, re-running `JSON.stringify` over a whole parsed object. `maxLength` currently bounds only what is stored, not the work.

e06910f · Updated on every push. Reply "won't fix" to dismiss a finding, or mention @maple to ask about one.

@maple-review-bot maple-review-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

1 inline note from Maple's review. The score and summary are in the review comment above.

Comment thread packages/browser-session/src/replay/record.ts Outdated
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.
@maple-review-bot

maple-review-bot Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Note

A newer push replaced d31138f before its review finished. The latest commit is reviewed in a new comment.

…hole

- 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.
@maple-review-bot

maple-review-bot Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Note

A newer push replaced 1b0d90a before its review finished. The latest commit is reviewed in a new comment.

…etwork-canvas

# Conflicts:
#	packages/browser/scripts/size.ts
@maple-review-bot

maple-review-bot Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Maple review

Confidence 3/5 · needs attention
Header/body/canvas capture are opt-in and tested; the only data-fidelity gaps are the 1 KB ingest clamp and the stateful g-regex match.
quality 88/100 · 1 warning · 1 note · tests covered · risk medium · 3/3 new units observable

Adds allowlisted request/response headers on fetch/XHR spans, opt-in body capture on replay network events, and opt-in rrweb canvas frames. Both earlier findings are fixed; two data-fidelity issues remain, neither breaking.

  • tracing.captureHeaders stamps allowlisted http.*.header.* span attributes, credentials excluded
  • replay.networkBodies keeps cut text request/response bodies on replay network events
  • replay.canvasFps enables rrweb canvas frames, never under maskAllText
  • XHR spans carry response headers only; the fetch hook still calls noteSettled

Findings

Warning · F4 · networkBodies bodies past 1,024 bytes are dropped by ingest

correctness · packages/browser/src/config.ts:302

A body is cut to maxLength (default 10,000) in the browser, then sent as the request.body / response.body attribute of a session event — and /v1/sessionEvents clamps every attribute value to SESSION_EVENT_MAX_ATTRIBUTE_VALUE_BYTES = 1,024 bytes (apps/ingest/src/session_analytics.rs:67, called from apps/ingest/src/main.rs:3419). A listed endpoint's body therefore arrives cut at ~1 KB while the docs promise maxLength characters, and any maxLength above 1,024 buys nothing but upload. Default to 1_024 and document the gateway cap.

Set the default `maxLength` to `1_024` so the SDK cuts where the gateway would, and say so in the `networkBodies` doc comment and the `docs/browser-sdk.md` section (or raise the ingest cap if 10 KB bodies are wanted).
Note · F5 · A g RegExp in networkBodies.urls matches only every other request

correctness · packages/browser-session/src/replay/capture/network.ts:8-9

matchesUrl calls pattern.test(url) on the app-supplied RegExp, and a g (or y) regex keeps lastIndex between calls: with urls: [/\/api\//g] the first matching request is captured and the next one to a matching URL is silently not, and so on. Reset lastIndex before each test.

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)
	})

Fixed since the last review

  • F2 · A listed header whose value contains a comma is split into several entries
  • F3 · The whole response body is materialized to keep maxLength characters
What was checked
  • F2 fixed: a header value stays one entry, commas intact (packages/browser/src/http-headers.ts:32)
  • F3 fixed: readPrefix stops at the kept prefix and cancels the clone (replay/capture/network.ts:147)
  • Credential headers filtered and lowercased at resolve time (packages/browser/src/http-headers.ts:16)
Observability coverage: 3 of 3 changes observable
Change Kind Observable Evidence
fetch/XHR request and response header capture span attributes yes packages/browser/src/tracing.ts:267-289 sets http.request.header.* / http.response.header.* on the instrumentation span
replay network body capture session event attributes yes replay/capture/network.ts:26-32 puts bodies on the network session event; off spans by design
canvas frame capture replay recording yes replay/record.ts:266-274 enables rrweb recordCanvas in both recorders
Copy all findings (2)
Findings from an automated review of commit b536f891793cfd8b196386ec8a7a405fe977f178. Verify each one against the current code before changing anything, fix only those that still apply, and keep each fix to the lines it names.

---

F4 · Warning · correctness · packages/browser/src/config.ts:302
`networkBodies` bodies past 1,024 bytes are dropped by ingest
A body is cut to `maxLength` (default 10,000) in the browser, then sent as the `request.body` / `response.body` attribute of a session event — and `/v1/sessionEvents` clamps every attribute value to `SESSION_EVENT_MAX_ATTRIBUTE_VALUE_BYTES` = 1,024 bytes (`apps/ingest/src/session_analytics.rs:67`, called from `apps/ingest/src/main.rs:3419`). A listed endpoint's body therefore arrives cut at ~1 KB while the docs promise `maxLength` characters, and any `maxLength` above 1,024 buys nothing but upload. Default to 1_024 and document the gateway cap.
Suggested fix: Set the default `maxLength` to `1_024` so the SDK cuts where the gateway would, and say so in the `networkBodies` doc comment and the `docs/browser-sdk.md` section (or raise the ingest cap if 10 KB bodies are wanted).

---

F5 · Note · correctness · packages/browser-session/src/replay/capture/network.ts:8-9
A `g` RegExp in `networkBodies.urls` matches only every other request
`matchesUrl` calls `pattern.test(url)` on the app-supplied `RegExp`, and a `g` (or `y`) regex keeps `lastIndex` between calls: with `urls: [/\/api\//g]` the first matching request is captured and the next one to a matching URL is silently not, and so on. Reset `lastIndex` before each test.
Replace those lines with:
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)
	})

b536f89 · Updated on every push. Reply "won't fix" to dismiss a finding, or mention @maple to ask about one.

@maple-review-bot maple-review-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

1 inline note from Maple's review. The score and summary are in the review comment above.

Comment thread packages/browser/src/config.ts Outdated
@Makisuo
Makisuo added this pull request to stack #1161 September 29, 2026 21:22
…gexes

- /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.
@maple-review-bot

maple-review-bot Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Maple review

Confidence 3/5 · needs attention
The body-capture path changes when a replay network event is emitted; everything else is additive and covered by tests.
quality 90/100 · 1 warning · tests covered · risk medium · 1/2 new units observable

Adds allowlisted fetch/XHR headers as HTTP semconv span attributes, optional text bodies on replay network events, and opt-in rrweb canvas capture. Safe to merge apart from the deferred network event on streamed responses.

  • tracing.captureHeaders sets http.request.header.*/http.response.header.* on fetch and XHR spans
  • replay.networkBodies keeps cut text bodies on matched replay network events
  • replay.canvasFps records WebP canvas frames in both recorders
  • networkBodies.maxLength defaults to and is capped at 1,000

Findings

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

correctness · packages/browser-session/src/replay/capture/network.ts:61-78

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.

Fixed since the last review

  • F5 · A g RegExp in networkBodies.urls matches only every other request
What was checked
  • F5 fixed: matchesUrl resets lastIndex before pattern.test (network.ts:12), global regex matches every request
  • Credential headers removed and names lowercased at config time (http-headers.ts:17), never reaching a span
  • Body reads are bounded by maxLength and the clone reader is cancelled (network.ts:157-163)
Observability coverage: 1 of 2 changes observable
Change Kind Observable Evidence
fetch/XHR span header attributes outbound call yes http.request.header.* / http.response.header.* set on the existing instrumentation spans (tracing.ts:267-290)
replay request/response body capture background read in the browser no bodies ride on session events as request.body/response.body (network.ts:35-36); no span is expected in a replay engine
Copy all findings (1)
Findings from an automated review of commit a38cd6e3024c8a94926a43c763c9438e2c9f7f5e. Verify each one against the current code before changing anything, fix only those that still apply, and keep each fix to the lines it names.

---

F6 · Warning · correctness · 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.

a38cd6e · Updated on every push. Reply "won't fix" to dismiss a finding, or mention @maple to ask about one.

@maple-review-bot maple-review-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

1 inline note from Maple's review. The score and summary are in the review comment above.

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.

@Makisuo
Makisuo merged commit c8757b0 into main Sep 29, 2026
37 checks passed
@Makisuo
Makisuo deleted the feat/browser-sdk-network-canvas branch September 29, 2026 21:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant