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;