From 257e8de767f9cedac2c15feacd4c6e69978c3365 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Fri, 12 Dec 2025 16:47:44 +0100 Subject: [PATCH 1/8] feat(replay): Add `Request` body with `attachRawBodyFromRequest` option --- packages/browser-utils/src/networkUtils.ts | 24 +- packages/replay-internal/src/integration.ts | 49 +++- packages/replay-internal/src/types/replay.ts | 13 + .../handleNetworkBreadcrumbs.test.ts | 231 ++++++++++++++++++ 4 files changed, 312 insertions(+), 5 deletions(-) diff --git a/packages/browser-utils/src/networkUtils.ts b/packages/browser-utils/src/networkUtils.ts index b8df5886e7ee..ae7dbe0088bb 100644 --- a/packages/browser-utils/src/networkUtils.ts +++ b/packages/browser-utils/src/networkUtils.ts @@ -2,6 +2,10 @@ import { debug } from '@sentry/core'; import { DEBUG_BUILD } from './debug-build'; import type { NetworkMetaWarning } from './types'; +// Symbol used by replay integration to store original body on Request objects +// This must match the symbol used in @sentry-internal/replay-internal +const ORIGINAL_BODY = Symbol.for('sentry__OriginalBody'); + /** * Serializes FormData. * @@ -44,15 +48,27 @@ export function getBodyString(body: unknown, _debug: typeof debug = debug): [str /** * Parses the fetch arguments to extract the request payload. - * - * We only support getting the body from the fetch options. */ export function getFetchRequestArgBody(fetchArgs: unknown[] = []): RequestInit['body'] | undefined { - if (fetchArgs.length !== 2 || typeof fetchArgs[1] !== 'object') { + // Check if there's a second argument with options that has a body - this takes precedence + if (fetchArgs.length >= 2 && fetchArgs[1] && typeof fetchArgs[1] === 'object' && 'body' in fetchArgs[1]) { + return (fetchArgs[1] as RequestInit).body; + } + + // Check if the first argument is a Request object + if (fetchArgs.length >= 1 && fetchArgs[0] instanceof Request) { + const request = fetchArgs[0]; + // Try to get the original body of Request interface if it was stored by replay integration + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access + const originalBody = (request as any)[ORIGINAL_BODY]; + if (originalBody !== undefined) { + return originalBody; + } + // Fall back to returning undefined (as we don't want to return a ReadableStream) return undefined; } - return (fetchArgs[1] as RequestInit).body; + return undefined; } /** diff --git a/packages/replay-internal/src/integration.ts b/packages/replay-internal/src/integration.ts index 17129bd57445..1bcbe1c4acbe 100644 --- a/packages/replay-internal/src/integration.ts +++ b/packages/replay-internal/src/integration.ts @@ -1,5 +1,5 @@ import type { BrowserClientReplayOptions, Client, Integration, IntegrationFn, ReplayRecordingMode } from '@sentry/core'; -import { consoleSandbox, isBrowser, parseSampleRate } from '@sentry/core'; +import { consoleSandbox, GLOBAL_OBJ, isBrowser, parseSampleRate } from '@sentry/core'; import { DEFAULT_FLUSH_MAX_DELAY, DEFAULT_FLUSH_MIN_DELAY, @@ -24,7 +24,48 @@ const MEDIA_SELECTORS = const DEFAULT_NETWORK_HEADERS = ['content-length', 'content-type', 'accept']; +// Symbol to store the original body on Request objects +// Using Symbol.for() to create a global symbol that can be accessed from other packages +const ORIGINAL_BODY = Symbol.for('sentry__OriginalBody'); + let _initialized = false; +let _isRequestInstrumented = false; + +/** + * Instruments the global Request constructor to store the original body. + * This allows us to retrieve the original body value later, since Request + * converts string bodies to ReadableStreams. + */ +export function INTERNAL_instrumentRequestInterface(): void { + if (typeof Request === 'undefined' || _isRequestInstrumented) { + return; + } + + const OriginalRequest = Request; + + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (GLOBAL_OBJ as any).Request = function SentryRequest(input: RequestInfo | URL, init?: RequestInit): Request { + const request = new OriginalRequest(input, init); + + // Store the original body if it exists + if (init && 'body' in init && init.body !== null && init.body !== undefined) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access + (request as any)[ORIGINAL_BODY] = init.body; + } + + return request; + }; + + // Preserve the prototype + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access + (GLOBAL_OBJ as any).Request.prototype = OriginalRequest.prototype; + + _isRequestInstrumented = true; + } catch (e) { + // Silently fail if we can't patch Request (e.g., if it's frozen) + } +} /** * Sentry integration for [Session Replay](https://sentry.io/for/session-replay/). @@ -105,6 +146,7 @@ export class Replay implements Integration { beforeAddRecordingEvent, beforeErrorSampling, onError, + attachRawBodyFromRequest = false, }: ReplayConfiguration = {}) { this.name = 'Replay'; @@ -177,6 +219,7 @@ export class Replay implements Integration { beforeAddRecordingEvent, beforeErrorSampling, onError, + attachRawBodyFromRequest, _experiments, }; @@ -215,6 +258,10 @@ export class Replay implements Integration { return; } + if (this._initialOptions.attachRawBodyFromRequest) { + INTERNAL_instrumentRequestInterface(); + } + this._setup(client); this._initialize(client); } diff --git a/packages/replay-internal/src/types/replay.ts b/packages/replay-internal/src/types/replay.ts index 75a2d35e64f2..88da65e961a4 100644 --- a/packages/replay-internal/src/types/replay.ts +++ b/packages/replay-internal/src/types/replay.ts @@ -222,6 +222,19 @@ export interface ReplayPluginOptions extends ReplayNetworkOptions { */ onError?: (err: unknown) => void; + /** + * Patch the global Request() interface to store original request bodies. + * This allows Replay to capture the original body from Request objects passed to fetch(). + * + * When enabled, creates a copy of the original body before it's converted to a ReadableStream. + * This is useful for capturing request bodies in network breadcrumbs. + * + * Note: This modifies the global Request constructor. + * + * @default false + */ + attachRawBodyFromRequest?: boolean; + /** * _experiments allows users to enable experimental or internal features. * We don't consider such features as part of the public API and hence we don't guarantee semver for them. diff --git a/packages/replay-internal/test/unit/coreHandlers/handleNetworkBreadcrumbs.test.ts b/packages/replay-internal/test/unit/coreHandlers/handleNetworkBreadcrumbs.test.ts index 9894b928c1b8..74050a3ec91c 100644 --- a/packages/replay-internal/test/unit/coreHandlers/handleNetworkBreadcrumbs.test.ts +++ b/packages/replay-internal/test/unit/coreHandlers/handleNetworkBreadcrumbs.test.ts @@ -15,6 +15,7 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { NETWORK_BODY_MAX_SIZE } from '../../../src/constants'; import { beforeAddNetworkBreadcrumb } from '../../../src/coreHandlers/handleNetworkBreadcrumbs'; import type { EventBufferArray } from '../../../src/eventBuffer/EventBufferArray'; +import { INTERNAL_instrumentRequestInterface } from '../../../src/integration'; import type { ReplayContainer, ReplayNetworkOptions } from '../../../src/types'; import { BASE_TIMESTAMP } from '../..'; import { setupReplayContainer } from '../../utils/setupReplayContainer'; @@ -816,6 +817,236 @@ other-header: test`; ]); }); + describe('with Request objects - with patching Request interface', () => { + beforeAll(() => { + // keep backup of original Request + const OriginalRequest = globalThis.Request; + + return async () => { + globalThis.Request = OriginalRequest; + }; + }); + + it('extracts body from Request object when attachRawBodyFromRequest is enabled', async () => { + options.networkCaptureBodies = true; + + // Simulate what replay integration does when attachRawBodyFromRequest: true + INTERNAL_instrumentRequestInterface(); + + const request = new Request('https://example.com', { + method: 'POST', + body: 'Some example request body content', + }); + + const breadcrumb: Breadcrumb = { + category: 'fetch', + data: { + method: 'POST', + url: 'https://example.com', + status_code: 200, + }, + }; + + const mockResponse = getMockResponse('13', 'test response'); + + const hint: FetchBreadcrumbHint = { + input: [request], + response: mockResponse, + startTimestamp: BASE_TIMESTAMP + 1000, + endTimestamp: BASE_TIMESTAMP + 2000, + }; + beforeAddNetworkBreadcrumb(options, breadcrumb, hint); + + expect(breadcrumb).toEqual({ + category: 'fetch', + data: { + method: 'POST', + request_body_size: 33, + response_body_size: 13, + status_code: 200, + url: 'https://example.com', + }, + }); + + await waitForReplayEventBuffer(); + + expect((options.replay.eventBuffer as EventBufferArray).events).toEqual([ + { + type: 5, + timestamp: (BASE_TIMESTAMP + 1000) / 1000, + data: { + tag: 'performanceSpan', + payload: { + data: { + method: 'POST', + statusCode: 200, + request: { + headers: {}, + size: 33, + body: 'Some example request body content', // When body is stored via Symbol, the body text should be captured + }, + response: { + size: 13, + headers: {}, + body: 'test response', + }, + }, + description: 'https://example.com', + endTimestamp: (BASE_TIMESTAMP + 2000) / 1000, + op: 'resource.fetch', + startTimestamp: (BASE_TIMESTAMP + 1000) / 1000, + }, + }, + }, + ]); + }); + + it('uses options body when provided (overrides Request body)', async () => { + options.networkCaptureBodies = true; + + // Simulate what replay integration does when attachRawBodyFromRequest: true + INTERNAL_instrumentRequestInterface(); + + const request = new Request('https://example.com', { method: 'POST', body: 'Original body' }); + + const breadcrumb: Breadcrumb = { + category: 'fetch', + data: { + method: 'POST', + url: 'https://example.com', + status_code: 200, + }, + }; + + const mockResponse = getMockResponse('13', 'test response'); + + const hint: FetchBreadcrumbHint = { + input: [request, { body: 'Override body' }], + response: mockResponse, + startTimestamp: BASE_TIMESTAMP + 1000, + endTimestamp: BASE_TIMESTAMP + 2000, + }; + beforeAddNetworkBreadcrumb(options, breadcrumb, hint); + + expect(breadcrumb).toEqual({ + category: 'fetch', + data: { + method: 'POST', + request_body_size: 13, + response_body_size: 13, + status_code: 200, + url: 'https://example.com', + }, + }); + + await waitForReplayEventBuffer(); + + expect((options.replay.eventBuffer as EventBufferArray).events).toEqual([ + { + type: 5, + timestamp: (BASE_TIMESTAMP + 1000) / 1000, + data: { + tag: 'performanceSpan', + payload: { + data: { + method: 'POST', + statusCode: 200, + request: { + size: 13, + headers: {}, + body: 'Override body', + }, + response: { + size: 13, + headers: {}, + body: 'test response', + }, + }, + description: 'https://example.com', + endTimestamp: (BASE_TIMESTAMP + 2000) / 1000, + op: 'resource.fetch', + startTimestamp: (BASE_TIMESTAMP + 1000) / 1000, + }, + }, + }, + ]); + }); + }); + + describe('with Request objects - without patching Request interface', () => { + it('falls back to ReadableStream when attachRawBodyFromRequest is not enabled', async () => { + options.networkCaptureBodies = true; + + // Without patching Request, Request body is a ReadableStream + const request = new Request('https://example.com', { method: 'POST', body: 'Request body' }); + + const breadcrumb: Breadcrumb = { + category: 'fetch', + data: { + method: 'POST', + url: 'https://example.com', + status_code: 200, + }, + }; + + const mockResponse = getMockResponse('13', 'test response'); + + const hint: FetchBreadcrumbHint = { + input: [request], + response: mockResponse, + startTimestamp: BASE_TIMESTAMP + 1000, + endTimestamp: BASE_TIMESTAMP + 2000, + }; + beforeAddNetworkBreadcrumb(options, breadcrumb, hint); + + expect(breadcrumb).toEqual({ + category: 'fetch', + data: { + method: 'POST', + + response_body_size: 13, + status_code: 200, + url: 'https://example.com', + }, + }); + + await waitForReplayEventBuffer(); + + expect((options.replay.eventBuffer as EventBufferArray).events).toEqual([ + { + type: 5, + timestamp: (BASE_TIMESTAMP + 1000) / 1000, + data: { + tag: 'performanceSpan', + payload: { + data: { + method: 'POST', + statusCode: 200, + request: { + headers: {}, + size: undefined, + _meta: { + // Request body should not be captured since it's a ReadableStream (unparseable body type) + warnings: ['UNPARSEABLE_BODY_TYPE'], + }, + }, + response: { + size: 13, + headers: {}, + body: 'test response', + }, + }, + description: 'https://example.com', + endTimestamp: (BASE_TIMESTAMP + 2000) / 1000, + op: 'resource.fetch', + startTimestamp: (BASE_TIMESTAMP + 1000) / 1000, + }, + }, + }, + ]); + }); + }); + it('does not add xhr request/response body if URL does not match', async () => { options.networkCaptureBodies = true; From 889d18b005cedf3aaa88f3c9bff274dcca409576 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 15 Dec 2025 10:35:36 +0100 Subject: [PATCH 2/8] clean up and add tests --- packages/browser-utils/src/networkUtils.ts | 19 +-- .../browser-utils/test/networkUtils.test.ts | 124 +++++++++++++++++- packages/replay-internal/src/integration.ts | 12 +- .../handleNetworkBreadcrumbs.test.ts | 6 +- 4 files changed, 141 insertions(+), 20 deletions(-) diff --git a/packages/browser-utils/src/networkUtils.ts b/packages/browser-utils/src/networkUtils.ts index ae7dbe0088bb..1a71fddab5fe 100644 --- a/packages/browser-utils/src/networkUtils.ts +++ b/packages/browser-utils/src/networkUtils.ts @@ -2,9 +2,8 @@ import { debug } from '@sentry/core'; import { DEBUG_BUILD } from './debug-build'; import type { NetworkMetaWarning } from './types'; -// Symbol used by replay integration to store original body on Request objects -// This must match the symbol used in @sentry-internal/replay-internal -const ORIGINAL_BODY = Symbol.for('sentry__OriginalBody'); +// Symbol used by e.g. the Replay integration to store original body on Request objects +export const ORIGINAL_REQ_BODY = Symbol.for('sentry__originalRequestBody'); /** * Serializes FormData. @@ -48,24 +47,26 @@ export function getBodyString(body: unknown, _debug: typeof debug = debug): [str /** * Parses the fetch arguments to extract the request payload. + * + * In case of a Request object, this function attempts to retrieve the original body by looking for a Sentry-patched symbol. */ export function getFetchRequestArgBody(fetchArgs: unknown[] = []): RequestInit['body'] | undefined { - // Check if there's a second argument with options that has a body - this takes precedence + // Second argument with body options takes precedence if (fetchArgs.length >= 2 && fetchArgs[1] && typeof fetchArgs[1] === 'object' && 'body' in fetchArgs[1]) { return (fetchArgs[1] as RequestInit).body; } - // Check if the first argument is a Request object if (fetchArgs.length >= 1 && fetchArgs[0] instanceof Request) { const request = fetchArgs[0]; - // Try to get the original body of Request interface if it was stored by replay integration + /* The Request interface's body is a ReadableStream, which we cannot directly access. + Some integrations (e.g. Replay) patch the Request object to store the original body. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access - const originalBody = (request as any)[ORIGINAL_BODY]; + const originalBody = (request as any)[ORIGINAL_REQ_BODY]; if (originalBody !== undefined) { return originalBody; } - // Fall back to returning undefined (as we don't want to return a ReadableStream) - return undefined; + + return undefined; // Fall back to returning undefined (as we don't want to return a ReadableStream) } return undefined; diff --git a/packages/browser-utils/test/networkUtils.test.ts b/packages/browser-utils/test/networkUtils.test.ts index 84d1c635e844..6d597a795e55 100644 --- a/packages/browser-utils/test/networkUtils.test.ts +++ b/packages/browser-utils/test/networkUtils.test.ts @@ -3,7 +3,7 @@ */ import { describe, expect, it } from 'vitest'; -import { getBodyString, getFetchRequestArgBody, serializeFormData } from '../src/networkUtils'; +import { getBodyString, getFetchRequestArgBody, ORIGINAL_REQ_BODY, serializeFormData } from '../src/networkUtils'; describe('getBodyString', () => { it('works with a string', () => { @@ -81,6 +81,32 @@ describe('getFetchRequestArgBody', () => { }); }); + describe('edge cases and boundary conditions', () => { + it.each([ + ['empty array', [], undefined], + ['no arguments', undefined, undefined], + [ + 'second arg is object without body property', + ['http://example.com', { method: 'POST', headers: {} }], + undefined, + ], + ['second arg has body: null', ['http://example.com', { body: null }], null], + ['second arg has body: undefined', ['http://example.com', { body: undefined }], undefined], + ['second arg has body: 0', ['http://example.com', { body: 0 as any }], 0], + ['second arg has body: false', ['http://example.com', { body: false as any }], false], + ['second arg is not an object', ['http://example.com', 'not-an-object'], undefined], + ['second arg is null', ['http://example.com', null], undefined], + [ + 'arguments beyond the second one', + ['http://example.com', { body: 'correct' }, { body: 'ignored' }] as any, + 'correct', + ], + ])('returns correct value when %s', (_name, args, expected) => { + const actual = getFetchRequestArgBody(args); + expect(actual).toBe(expected); + }); + }); + describe('does not work without body passed as the second option', () => { it.each([ ['string URL only', ['http://example.com']], @@ -93,6 +119,102 @@ describe('getFetchRequestArgBody', () => { expect(actual).toBeUndefined(); }); }); + + describe('works with Request object as first argument (patched Symbol on Request)', () => { + // Some integrations (e.g. Replay) patch the Request object to store the original body + const addOriginalBodySymbol = (request: Request, body: any): Request => { + (request as any)[ORIGINAL_REQ_BODY] = body; + return request; + }; + + it.each([ + [ + 'Request object with body (as only arg)', + [addOriginalBodySymbol(new Request('http://example.com', { method: 'POST', body: 'Hello' }), 'Hello')], + 'Hello', + ], + [ + 'Request object with body (with undefined options arg)', + [ + addOriginalBodySymbol(new Request('http://example.com', { method: 'POST', body: 'World' }), 'World'), + undefined, + ], + 'World', + ], + [ + 'Request object with body (with overwritten options arg)', + [ + addOriginalBodySymbol(new Request('http://example.com', { method: 'POST', body: 'First' }), 'First'), + { body: 'Override' }, + ], + 'Override', + ], + [ + 'prioritizes second arg body even when it is null', + [ + addOriginalBodySymbol(new Request('http://example.com', { method: 'POST', body: 'original' }), 'First'), + { body: null }, + ], + null, + ], + ])('%s', (_name, args, expected) => { + const actual = getFetchRequestArgBody(args); + + expect(actual).toBe(expected); + }); + + describe('valid types of body (in Request)', () => { + it('works with json string', () => { + const body = { data: [1, 2, 3] }; + const jsonBody = JSON.stringify(body); + + const actual = getFetchRequestArgBody([ + addOriginalBodySymbol(new Request('http://example.com', { method: 'POST', body: jsonBody }), jsonBody), + ]); + expect(actual).toEqual(jsonBody); + }); + + it('works with URLSearchParams', () => { + const body = new URLSearchParams(); + body.append('name', 'Anne'); + body.append('age', '32'); + + const actual = getFetchRequestArgBody([ + addOriginalBodySymbol(new Request('http://example.com', { method: 'POST', body }), body), + ]); + expect(actual).toEqual(body); + }); + + it('works with FormData', () => { + const body = new FormData(); + body.append('name', 'Bob'); + body.append('age', '32'); + + const actual = getFetchRequestArgBody([ + addOriginalBodySymbol(new Request('http://example.com', { method: 'POST', body }), body), + ]); + expect(actual).toEqual(body); + }); + + it('works with Blob', () => { + const body = new Blob(['example'], { type: 'text/plain' }); + + const actual = getFetchRequestArgBody([ + addOriginalBodySymbol(new Request('http://example.com', { method: 'POST', body }), body), + ]); + expect(actual).toEqual(body); + }); + + it('works with BufferSource (ArrayBufferView | ArrayBuffer)', () => { + const body = new Uint8Array([1, 2, 3]); + + const actual = getFetchRequestArgBody([ + addOriginalBodySymbol(new Request('http://example.com', { method: 'POST', body }), body), + ]); + expect(actual).toEqual(body); + }); + }); + }); }); describe('serializeFormData', () => { diff --git a/packages/replay-internal/src/integration.ts b/packages/replay-internal/src/integration.ts index 1bcbe1c4acbe..9b288ea6ed1c 100644 --- a/packages/replay-internal/src/integration.ts +++ b/packages/replay-internal/src/integration.ts @@ -25,8 +25,7 @@ const MEDIA_SELECTORS = const DEFAULT_NETWORK_HEADERS = ['content-length', 'content-type', 'accept']; // Symbol to store the original body on Request objects -// Using Symbol.for() to create a global symbol that can be accessed from other packages -const ORIGINAL_BODY = Symbol.for('sentry__OriginalBody'); +const ORIGINAL_BODY = Symbol.for('sentry__originalRequestBody'); let _initialized = false; let _isRequestInstrumented = false; @@ -36,7 +35,7 @@ let _isRequestInstrumented = false; * This allows us to retrieve the original body value later, since Request * converts string bodies to ReadableStreams. */ -export function INTERNAL_instrumentRequestInterface(): void { +export function _INTERNAL_instrumentRequestInterface(): void { if (typeof Request === 'undefined' || _isRequestInstrumented) { return; } @@ -44,12 +43,11 @@ export function INTERNAL_instrumentRequestInterface(): void { const OriginalRequest = Request; try { - // eslint-disable-next-line @typescript-eslint/no-explicit-any + // eslint-disable-next-line @typescript-eslint/no-explicit-any,@typescript-eslint/no-unsafe-member-access (GLOBAL_OBJ as any).Request = function SentryRequest(input: RequestInfo | URL, init?: RequestInit): Request { const request = new OriginalRequest(input, init); - // Store the original body if it exists - if (init && 'body' in init && init.body !== null && init.body !== undefined) { + if (init && 'body' in init && init.body != null) { // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access (request as any)[ORIGINAL_BODY] = init.body; } @@ -259,7 +257,7 @@ export class Replay implements Integration { } if (this._initialOptions.attachRawBodyFromRequest) { - INTERNAL_instrumentRequestInterface(); + _INTERNAL_instrumentRequestInterface(); } this._setup(client); diff --git a/packages/replay-internal/test/unit/coreHandlers/handleNetworkBreadcrumbs.test.ts b/packages/replay-internal/test/unit/coreHandlers/handleNetworkBreadcrumbs.test.ts index 74050a3ec91c..ae12a13eb67d 100644 --- a/packages/replay-internal/test/unit/coreHandlers/handleNetworkBreadcrumbs.test.ts +++ b/packages/replay-internal/test/unit/coreHandlers/handleNetworkBreadcrumbs.test.ts @@ -15,7 +15,7 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { NETWORK_BODY_MAX_SIZE } from '../../../src/constants'; import { beforeAddNetworkBreadcrumb } from '../../../src/coreHandlers/handleNetworkBreadcrumbs'; import type { EventBufferArray } from '../../../src/eventBuffer/EventBufferArray'; -import { INTERNAL_instrumentRequestInterface } from '../../../src/integration'; +import { _INTERNAL_instrumentRequestInterface } from '../../../src/integration'; import type { ReplayContainer, ReplayNetworkOptions } from '../../../src/types'; import { BASE_TIMESTAMP } from '../..'; import { setupReplayContainer } from '../../utils/setupReplayContainer'; @@ -831,7 +831,7 @@ other-header: test`; options.networkCaptureBodies = true; // Simulate what replay integration does when attachRawBodyFromRequest: true - INTERNAL_instrumentRequestInterface(); + _INTERNAL_instrumentRequestInterface(); const request = new Request('https://example.com', { method: 'POST', @@ -905,7 +905,7 @@ other-header: test`; options.networkCaptureBodies = true; // Simulate what replay integration does when attachRawBodyFromRequest: true - INTERNAL_instrumentRequestInterface(); + _INTERNAL_instrumentRequestInterface(); const request = new Request('https://example.com', { method: 'POST', body: 'Original body' }); From cf74a2c86ec1dd794d1059af159224fe00953817 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 15 Dec 2025 10:40:32 +0100 Subject: [PATCH 3/8] add changelog entry --- CHANGELOG.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 15972e85dfdd..7ffd708cc19f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,21 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +- **feat(replay): Add Request body with `attachRawBodyFromRequest` option** + +To attach the raw request body (from `Request` objects passed as the first `fetch` argument) to replay events, +you can now use the `attachRawBodyFromRequest` option in the Replay integration: + +```js +Sentry.init({ + integrations: [ + Sentry.replayIntegration({ + attachRawBodyFromRequest: true, + }), + ], +}); +``` + ## 10.29.0 ### Important Changes From 529fca104f3ca03f9593bca6cd8fe749cf8df72e Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 15 Dec 2025 11:43:25 +0100 Subject: [PATCH 4/8] add integration test --- .../replay/attachRawBodyFromRequest/init.js | 22 +++ .../attachRawBodyFromRequest/template.html | 10 ++ .../replay/attachRawBodyFromRequest/test.ts | 145 ++++++++++++++++++ 3 files changed, 177 insertions(+) create mode 100644 dev-packages/browser-integration-tests/suites/replay/attachRawBodyFromRequest/init.js create mode 100644 dev-packages/browser-integration-tests/suites/replay/attachRawBodyFromRequest/template.html create mode 100644 dev-packages/browser-integration-tests/suites/replay/attachRawBodyFromRequest/test.ts diff --git a/dev-packages/browser-integration-tests/suites/replay/attachRawBodyFromRequest/init.js b/dev-packages/browser-integration-tests/suites/replay/attachRawBodyFromRequest/init.js new file mode 100644 index 000000000000..2c19109f6fd9 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/replay/attachRawBodyFromRequest/init.js @@ -0,0 +1,22 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; +window.Replay = Sentry.replayIntegration({ + flushMinDelay: 200, + flushMaxDelay: 200, + minReplayDuration: 0, + + networkDetailAllowUrls: ['http://sentry-test.io/foo'], + networkCaptureBodies: true, + attachRawBodyFromRequest: true, +}); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + sampleRate: 1, + // We ensure to sample for errors, so by default nothing is sent + replaysSessionSampleRate: 0.0, + replaysOnErrorSampleRate: 1.0, + + integrations: [window.Replay], +}); diff --git a/dev-packages/browser-integration-tests/suites/replay/attachRawBodyFromRequest/template.html b/dev-packages/browser-integration-tests/suites/replay/attachRawBodyFromRequest/template.html new file mode 100644 index 000000000000..78a7f2b37a34 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/replay/attachRawBodyFromRequest/template.html @@ -0,0 +1,10 @@ + + + + + + +

attachRawBodyFromRequest Test

+ + + diff --git a/dev-packages/browser-integration-tests/suites/replay/attachRawBodyFromRequest/test.ts b/dev-packages/browser-integration-tests/suites/replay/attachRawBodyFromRequest/test.ts new file mode 100644 index 000000000000..85e355eee57b --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/replay/attachRawBodyFromRequest/test.ts @@ -0,0 +1,145 @@ +import type { PlaywrightTestArgs } from '@playwright/test'; +import { expect } from '@playwright/test'; +import type { TestFixtures } from '../../../utils/fixtures'; +import { sentryTest } from '../../../utils/fixtures'; +import { envelopeRequestParser, waitForErrorRequest } from '../../../utils/helpers'; +import { collectReplayRequests, getReplayPerformanceSpans, shouldSkipReplayTest } from '../../../utils/replayHelpers'; + +/** + * Shared helper to run the common test flow + */ +async function runRequestFetchTest( + { page, getLocalTestUrl }: { page: PlaywrightTestArgs['page']; getLocalTestUrl: TestFixtures['getLocalTestUrl'] }, + options: { + evaluateFn: () => void; + expectedBody: any; + expectedSize: number | any; + expectedExtraReplayData?: any; + }, +) { + if (shouldSkipReplayTest()) { + sentryTest.skip(); + } + + await page.route('http://sentry-test.io/foo', route => route.fulfill({ status: 200 })); + + const requestPromise = waitForErrorRequest(page); + const replayRequestPromise = collectReplayRequests(page, recordingEvents => + getReplayPerformanceSpans(recordingEvents).some(span => span.op === 'resource.fetch'), + ); + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + await page.evaluate(options.evaluateFn); + + // Envelope/Breadcrumbs + const eventData = envelopeRequestParser(await requestPromise); + expect(eventData.exception?.values).toHaveLength(1); + + const fetchBreadcrumbs = eventData?.breadcrumbs?.filter(b => b.category === 'fetch'); + expect(fetchBreadcrumbs).toHaveLength(1); + expect(fetchBreadcrumbs![0]).toEqual({ + timestamp: expect.any(Number), + category: 'fetch', + type: 'http', + data: { + method: 'POST', + request_body_size: options.expectedSize, + status_code: 200, + url: 'http://sentry-test.io/foo', + }, + }); + + // Replay Spans + const { replayRecordingSnapshots } = await replayRequestPromise; + const fetchSpans = getReplayPerformanceSpans(replayRecordingSnapshots).filter(s => s.op === 'resource.fetch'); + expect(fetchSpans).toHaveLength(1); + + expect(fetchSpans[0]).toMatchObject({ + data: { + method: 'POST', + statusCode: 200, + request: { + body: options.expectedBody, + }, + ...options.expectedExtraReplayData, + }, + description: 'http://sentry-test.io/foo', + endTimestamp: expect.any(Number), + op: 'resource.fetch', + startTimestamp: expect.any(Number), + }); +} + +sentryTest('captures request body when using Request object with text body', async ({ page, getLocalTestUrl }) => { + await runRequestFetchTest( + { page, getLocalTestUrl }, + { + evaluateFn: () => { + const request = new Request('http://sentry-test.io/foo', { method: 'POST', body: 'Request body text' }); + // @ts-expect-error Sentry is a global + fetch(request).then(() => Sentry.captureException('test error')); + }, + expectedBody: 'Request body text', + expectedSize: 17, + }, + ); +}); + +sentryTest('captures request body when using Request object with JSON body', async ({ page, getLocalTestUrl }) => { + await runRequestFetchTest( + { page, getLocalTestUrl }, + { + evaluateFn: () => { + const request = new Request('http://sentry-test.io/foo', { + method: 'POST', + body: JSON.stringify({ name: 'John', age: 30 }), + }); + // @ts-expect-error Sentry is a global + fetch(request).then(() => Sentry.captureException('test error')); + }, + expectedBody: { name: 'John', age: 30 }, + expectedSize: expect.any(Number), + }, + ); +}); + +sentryTest('prioritizes options body over Request object body', async ({ page, getLocalTestUrl, browserName }) => { + const additionalHeaders = browserName === 'webkit' ? { 'content-type': 'text/plain' } : undefined; + + await runRequestFetchTest( + { page, getLocalTestUrl }, + { + evaluateFn: () => { + const request = new Request('http://sentry-test.io/foo', { method: 'POST', body: 'original body' }); + // Second argument body should override the Request body + // @ts-expect-error Sentry is a global + fetch(request, { body: 'override body' }).then(() => Sentry.captureException('test error')); + }, + expectedBody: 'override body', + expectedSize: 13, + expectedExtraReplayData: { + request: { size: 13, headers: {} }, // Specific override structure check + ...(additionalHeaders && { response: { headers: additionalHeaders } }), + }, + }, + ); +}); + +sentryTest('captures request body with FormData in Request object', async ({ page, getLocalTestUrl }) => { + await runRequestFetchTest( + { page, getLocalTestUrl }, + { + evaluateFn: () => { + const params = new URLSearchParams(); + params.append('key1', 'value1'); + params.append('key2', 'value2'); + const request = new Request('http://sentry-test.io/foo', { method: 'POST', body: params }); + // @ts-expect-error Sentry is a global + fetch(request).then(() => Sentry.captureException('test error')); + }, + expectedBody: 'key1=value1&key2=value2', + expectedSize: 23, + }, + ); +}); From e6a8f809629fec4dab494bc76a6d98d650e6a339 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 15 Dec 2025 14:00:30 +0100 Subject: [PATCH 5/8] add test with ReadableStream --- .../browser-utils/test/networkUtils.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/browser-utils/test/networkUtils.test.ts b/packages/browser-utils/test/networkUtils.test.ts index 6d597a795e55..c13eb7aa6209 100644 --- a/packages/browser-utils/test/networkUtils.test.ts +++ b/packages/browser-utils/test/networkUtils.test.ts @@ -213,6 +213,24 @@ describe('getFetchRequestArgBody', () => { ]); expect(actual).toEqual(body); }); + + it('works with ReadableStream', () => { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('stream data')); + controller.close(); + }, + }); + const request = new Request('http://example.com', { + method: 'POST', + body: stream, + // @ts-expect-error - Required for streaming requests https://developer.mozilla.org/en-US/docs/Web/API/Request/duplex + duplex: 'half', + }); + + const actual = getFetchRequestArgBody([addOriginalBodySymbol(request, stream)]); + expect(actual).toBe(stream); + }); }); }); }); From b29ec49a5632cbbd23f8242435f0b85a3d0359ae Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Tue, 16 Dec 2025 14:58:45 +0100 Subject: [PATCH 6/8] increase size limits --- .size-limit.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.size-limit.js b/.size-limit.js index 00b4bdbfd4d8..beb02cab643d 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -52,7 +52,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'browserTracingIntegration', 'replayIntegration'), gzip: true, - limit: '80 KB', + limit: '82 KB', }, { name: '@sentry/browser (incl. Tracing, Replay) - with treeshaking flags', @@ -89,7 +89,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'browserTracingIntegration', 'replayIntegration', 'feedbackIntegration'), gzip: true, - limit: '97 KB', + limit: '98 KB', }, { name: '@sentry/browser (incl. Feedback)', From 30a958dc24095d5556c52c23c612270e0687c8e8 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Tue, 16 Dec 2025 15:09:43 +0100 Subject: [PATCH 7/8] fix test --- .../unit/coreHandlers/handleNetworkBreadcrumbs.test.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/packages/replay-internal/test/unit/coreHandlers/handleNetworkBreadcrumbs.test.ts b/packages/replay-internal/test/unit/coreHandlers/handleNetworkBreadcrumbs.test.ts index ae12a13eb67d..b88b4d1a2358 100644 --- a/packages/replay-internal/test/unit/coreHandlers/handleNetworkBreadcrumbs.test.ts +++ b/packages/replay-internal/test/unit/coreHandlers/handleNetworkBreadcrumbs.test.ts @@ -1022,14 +1022,7 @@ other-header: test`; data: { method: 'POST', statusCode: 200, - request: { - headers: {}, - size: undefined, - _meta: { - // Request body should not be captured since it's a ReadableStream (unparseable body type) - warnings: ['UNPARSEABLE_BODY_TYPE'], - }, - }, + request: undefined, response: { size: 13, headers: {}, From c3cdc674a438f408360fdc935d1ff1ddb872cd02 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Wed, 17 Dec 2025 10:09:17 +0100 Subject: [PATCH 8/8] reduce bundle size --- packages/replay-internal/src/integration.ts | 20 +++++------ .../instrumentRequestInterface.test.ts | 35 +++++++++++++++++++ 2 files changed, 44 insertions(+), 11 deletions(-) create mode 100644 packages/replay-internal/test/integration/instrumentRequestInterface.test.ts diff --git a/packages/replay-internal/src/integration.ts b/packages/replay-internal/src/integration.ts index 9b288ea6ed1c..a940ef746979 100644 --- a/packages/replay-internal/src/integration.ts +++ b/packages/replay-internal/src/integration.ts @@ -43,25 +43,23 @@ export function _INTERNAL_instrumentRequestInterface(): void { const OriginalRequest = Request; try { - // eslint-disable-next-line @typescript-eslint/no-explicit-any,@typescript-eslint/no-unsafe-member-access - (GLOBAL_OBJ as any).Request = function SentryRequest(input: RequestInfo | URL, init?: RequestInit): Request { + const SentryRequest = function (input: RequestInfo | URL, init?: RequestInit): Request { const request = new OriginalRequest(input, init); - - if (init && 'body' in init && init.body != null) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access + if (init?.body != null) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any,@typescript-eslint/no-unsafe-member-access (request as any)[ORIGINAL_BODY] = init.body; } - return request; }; - // Preserve the prototype - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access - (GLOBAL_OBJ as any).Request.prototype = OriginalRequest.prototype; + SentryRequest.prototype = OriginalRequest.prototype; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any,@typescript-eslint/no-unsafe-member-access + (GLOBAL_OBJ as any).Request = SentryRequest; _isRequestInstrumented = true; - } catch (e) { - // Silently fail if we can't patch Request (e.g., if it's frozen) + } catch { + // Fail silently if Request is frozen } } diff --git a/packages/replay-internal/test/integration/instrumentRequestInterface.test.ts b/packages/replay-internal/test/integration/instrumentRequestInterface.test.ts new file mode 100644 index 000000000000..b209a57e8285 --- /dev/null +++ b/packages/replay-internal/test/integration/instrumentRequestInterface.test.ts @@ -0,0 +1,35 @@ +/** + * @vitest-environment jsdom + */ + +import { beforeEach, describe, expect, it } from 'vitest'; +import { _INTERNAL_instrumentRequestInterface } from '../../src/integration'; + +describe('Request instrumentation - instanceof and prototype chain', () => { + let OriginalRequest: typeof Request; + + beforeEach(() => { + OriginalRequest = Request; + }); + + it('preserves instanceof checks after instrumentation', () => { + _INTERNAL_instrumentRequestInterface(); + + const request = new Request('https://example.com', { + method: 'POST', + body: 'test body', + }); + + expect(request instanceof Request).toBe(true); + expect(request instanceof OriginalRequest).toBe(true); + }); + + it('preserves prototype chain after instrumentation', () => { + _INTERNAL_instrumentRequestInterface(); + + const request = new Request('https://example.com'); + + expect(Object.getPrototypeOf(request)).toBe(OriginalRequest.prototype); + expect(Request.prototype).toBe(OriginalRequest.prototype); + }); +});