diff --git a/.size-limit.js b/.size-limit.js
index 880f91cbeb54..aca0f6ed7eaf 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)',
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0fe8bcae2ea4..87ac1c8b9f16 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,7 +4,22 @@
- "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott
-- feat(nextjs): Add tree-shaking configuration to `webpack` build config ([#18359](https://github.com/getsentry/sentry-javascript/pull/18359))
+- **feat(nextjs): Add tree-shaking configuration to `webpack` build config ([#18359](https://github.com/getsentry/sentry-javascript/pull/18359))**
+
+- **feat(replay): Add Request body with `attachRawBodyFromRequest` option ([#18501](https://github.com/getsentry/sentry-javascript/pull/18501))**
+
+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.31.0
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,
+ },
+ );
+});
diff --git a/packages/browser-utils/src/networkUtils.ts b/packages/browser-utils/src/networkUtils.ts
index b8df5886e7ee..1a71fddab5fe 100644
--- a/packages/browser-utils/src/networkUtils.ts
+++ b/packages/browser-utils/src/networkUtils.ts
@@ -2,6 +2,9 @@ import { debug } from '@sentry/core';
import { DEBUG_BUILD } from './debug-build';
import type { NetworkMetaWarning } from './types';
+// 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.
*
@@ -45,14 +48,28 @@ 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.
+ * 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 {
- if (fetchArgs.length !== 2 || typeof fetchArgs[1] !== 'object') {
- return undefined;
+ // 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;
+ }
+
+ if (fetchArgs.length >= 1 && fetchArgs[0] instanceof Request) {
+ const request = fetchArgs[0];
+ /* 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_REQ_BODY];
+ if (originalBody !== undefined) {
+ return originalBody;
+ }
+
+ return undefined; // Fall back to returning undefined (as we don't want to return a ReadableStream)
}
- return (fetchArgs[1] as RequestInit).body;
+ return undefined;
}
/**
diff --git a/packages/browser-utils/test/networkUtils.test.ts b/packages/browser-utils/test/networkUtils.test.ts
index 84d1c635e844..c13eb7aa6209 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,120 @@ 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);
+ });
+
+ 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);
+ });
+ });
+ });
});
describe('serializeFormData', () => {
diff --git a/packages/replay-internal/src/integration.ts b/packages/replay-internal/src/integration.ts
index 17129bd57445..a940ef746979 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,44 @@ const MEDIA_SELECTORS =
const DEFAULT_NETWORK_HEADERS = ['content-length', 'content-type', 'accept'];
+// Symbol to store the original body on Request objects
+const ORIGINAL_BODY = Symbol.for('sentry__originalRequestBody');
+
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 {
+ const SentryRequest = function (input: RequestInfo | URL, init?: RequestInit): Request {
+ const request = new OriginalRequest(input, init);
+ 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;
+ };
+
+ 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 {
+ // Fail silently if Request is frozen
+ }
+}
/**
* Sentry integration for [Session Replay](https://sentry.io/for/session-replay/).
@@ -105,6 +142,7 @@ export class Replay implements Integration {
beforeAddRecordingEvent,
beforeErrorSampling,
onError,
+ attachRawBodyFromRequest = false,
}: ReplayConfiguration = {}) {
this.name = 'Replay';
@@ -177,6 +215,7 @@ export class Replay implements Integration {
beforeAddRecordingEvent,
beforeErrorSampling,
onError,
+ attachRawBodyFromRequest,
_experiments,
};
@@ -215,6 +254,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/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);
+ });
+});
diff --git a/packages/replay-internal/test/unit/coreHandlers/handleNetworkBreadcrumbs.test.ts b/packages/replay-internal/test/unit/coreHandlers/handleNetworkBreadcrumbs.test.ts
index 9894b928c1b8..b88b4d1a2358 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,229 @@ 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: undefined,
+ 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;