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 @@ + + +
+ + + +