diff --git a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/contentLengthHeader/test.ts b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/contentLengthHeader/test.ts index 1ffeb360c650..27c429c9be98 100644 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/contentLengthHeader/test.ts +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/contentLengthHeader/test.ts @@ -2,7 +2,11 @@ import { expect } from '@playwright/test'; import { sentryTest } from '../../../../../utils/fixtures'; import { envelopeRequestParser, waitForErrorRequest } from '../../../../../utils/helpers'; -import { shouldSkipReplayTest } from '../../../../../utils/replayHelpers'; +import { + getCustomRecordingEvents, + shouldSkipReplayTest, + waitForReplayRequest, +} from '../../../../../utils/replayHelpers'; sentryTest('parses response_body_size from Content-Length header if available', async ({ getLocalTestPath, page }) => { if (shouldSkipReplayTest()) { @@ -22,7 +26,17 @@ sentryTest('parses response_body_size from Content-Length header if available', }); }); + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); + }); + const requestPromise = waitForErrorRequest(page); + const replayRequestPromise1 = waitForReplayRequest(page, 0); + const url = await getLocalTestPath({ testDir: __dirname }); await page.goto(url); @@ -58,4 +72,20 @@ sentryTest('parses response_body_size from Content-Length header if available', url: 'http://localhost:7654/foo', }, }); + + const replayReq1 = await replayRequestPromise1; + const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); + expect(performanceSpans1.filter(span => span.op === 'resource.fetch')).toEqual([ + { + data: { + method: 'GET', + responseBodySize: 789, + statusCode: 200, + }, + description: 'http://localhost:7654/foo', + endTimestamp: expect.any(Number), + op: 'resource.fetch', + startTimestamp: expect.any(Number), + }, + ]); }); diff --git a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/noContentLengthHeader/test.ts b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/noContentLengthHeader/test.ts index 8248b4799480..31f8d65bc7e7 100644 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/noContentLengthHeader/test.ts +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/noContentLengthHeader/test.ts @@ -2,7 +2,11 @@ import { expect } from '@playwright/test'; import { sentryTest } from '../../../../../utils/fixtures'; import { envelopeRequestParser, waitForErrorRequest } from '../../../../../utils/helpers'; -import { shouldSkipReplayTest } from '../../../../../utils/replayHelpers'; +import { + getCustomRecordingEvents, + shouldSkipReplayTest, + waitForReplayRequest, +} from '../../../../../utils/replayHelpers'; sentryTest('does not capture response_body_size without Content-Length header', async ({ getLocalTestPath, page }) => { if (shouldSkipReplayTest()) { @@ -22,7 +26,17 @@ sentryTest('does not capture response_body_size without Content-Length header', }); }); + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); + }); + const requestPromise = waitForErrorRequest(page); + const replayRequestPromise1 = waitForReplayRequest(page, 0); + const url = await getLocalTestPath({ testDir: __dirname }); await page.goto(url); @@ -57,4 +71,20 @@ sentryTest('does not capture response_body_size without Content-Length header', url: 'http://localhost:7654/foo', }, }); + + const replayReq1 = await replayRequestPromise1; + const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); + expect(performanceSpans1.filter(span => span.op === 'resource.fetch')).toEqual([ + { + data: { + method: 'GET', + responseBodySize: 29, + statusCode: 200, + }, + description: 'http://localhost:7654/foo', + endTimestamp: expect.any(Number), + op: 'resource.fetch', + startTimestamp: expect.any(Number), + }, + ]); }); diff --git a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/nonTextBody/test.ts b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/nonTextBody/test.ts index a293df49b366..d2c167110a8a 100644 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/nonTextBody/test.ts +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/nonTextBody/test.ts @@ -2,7 +2,11 @@ import { expect } from '@playwright/test'; import { sentryTest } from '../../../../../utils/fixtures'; import { envelopeRequestParser, waitForErrorRequest } from '../../../../../utils/helpers'; -import { shouldSkipReplayTest } from '../../../../../utils/replayHelpers'; +import { + getCustomRecordingEvents, + shouldSkipReplayTest, + waitForReplayRequest, +} from '../../../../../utils/replayHelpers'; sentryTest('calculates body sizes for non-string bodies', async ({ getLocalTestPath, page }) => { if (shouldSkipReplayTest()) { @@ -19,7 +23,17 @@ sentryTest('calculates body sizes for non-string bodies', async ({ getLocalTestP }); }); + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); + }); + const requestPromise = waitForErrorRequest(page); + const replayRequestPromise1 = waitForReplayRequest(page, 0); + const url = await getLocalTestPath({ testDir: __dirname }); await page.goto(url); @@ -60,4 +74,21 @@ sentryTest('calculates body sizes for non-string bodies', async ({ getLocalTestP url: 'http://localhost:7654/foo', }, }); + + const replayReq1 = await replayRequestPromise1; + const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); + expect(performanceSpans1.filter(span => span.op === 'resource.fetch')).toEqual([ + { + data: { + method: 'POST', + requestBodySize: 26, + responseBodySize: 24, + statusCode: 200, + }, + description: 'http://localhost:7654/foo', + endTimestamp: expect.any(Number), + op: 'resource.fetch', + startTimestamp: expect.any(Number), + }, + ]); }); diff --git a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/requestBody/test.ts b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/requestBody/test.ts index baac9005fd35..0f77394b6e5d 100644 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/requestBody/test.ts +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/requestBody/test.ts @@ -2,7 +2,11 @@ import { expect } from '@playwright/test'; import { sentryTest } from '../../../../../utils/fixtures'; import { envelopeRequestParser, waitForErrorRequest } from '../../../../../utils/helpers'; -import { shouldSkipReplayTest } from '../../../../../utils/replayHelpers'; +import { + getCustomRecordingEvents, + shouldSkipReplayTest, + waitForReplayRequest, +} from '../../../../../utils/replayHelpers'; sentryTest('captures request_body_size when body is sent', async ({ getLocalTestPath, page }) => { if (shouldSkipReplayTest()) { @@ -12,16 +16,23 @@ sentryTest('captures request_body_size when body is sent', async ({ getLocalTest await page.route('**/foo', route => { return route.fulfill({ status: 200, - body: JSON.stringify({ - userNames: ['John', 'Jane'], - }), headers: { 'Content-Type': 'application/json', }, }); }); + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); + }); + const requestPromise = waitForErrorRequest(page); + const replayRequestPromise1 = waitForReplayRequest(page, 0); + const url = await getLocalTestPath({ testDir: __dirname }); await page.goto(url); @@ -48,5 +59,32 @@ sentryTest('captures request_body_size when body is sent', async ({ getLocalTest expect(eventData.exception?.values).toHaveLength(1); expect(eventData?.breadcrumbs?.length).toBe(1); + expect(eventData!.breadcrumbs![0]).toEqual({ + timestamp: expect.any(Number), + category: 'fetch', + type: 'http', + data: { + method: 'POST', + request_body_size: 13, + status_code: 200, + url: 'http://localhost:7654/foo', + }, + }); expect(eventData!.breadcrumbs![0].data!.request_body_size).toEqual(13); + + const replayReq1 = await replayRequestPromise1; + const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); + expect(performanceSpans1.filter(span => span.op === 'resource.fetch')).toEqual([ + { + data: { + method: 'POST', + requestBodySize: 13, + statusCode: 200, + }, + description: 'http://localhost:7654/foo', + endTimestamp: expect.any(Number), + op: 'resource.fetch', + startTimestamp: expect.any(Number), + }, + ]); }); diff --git a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/contentLengthHeader/test.ts b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/contentLengthHeader/test.ts index b5f517f77352..4ee170939530 100644 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/contentLengthHeader/test.ts +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/contentLengthHeader/test.ts @@ -2,7 +2,11 @@ import { expect } from '@playwright/test'; import { sentryTest } from '../../../../../utils/fixtures'; import { envelopeRequestParser, waitForErrorRequest } from '../../../../../utils/helpers'; -import { shouldSkipReplayTest } from '../../../../../utils/replayHelpers'; +import { + getCustomRecordingEvents, + shouldSkipReplayTest, + waitForReplayRequest, +} from '../../../../../utils/replayHelpers'; sentryTest( 'parses response_body_size from Content-Length header if available', @@ -25,7 +29,17 @@ sentryTest( }); }); + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); + }); + const requestPromise = waitForErrorRequest(page); + const replayRequestPromise1 = waitForReplayRequest(page, 0); + const url = await getLocalTestPath({ testDir: __dirname }); await page.goto(url); @@ -65,5 +79,21 @@ sentryTest( url: 'http://localhost:7654/foo', }, }); + + const replayReq1 = await replayRequestPromise1; + const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); + expect(performanceSpans1.filter(span => span.op === 'resource.xhr')).toEqual([ + { + data: { + method: 'GET', + responseBodySize: 789, + statusCode: 200, + }, + description: 'http://localhost:7654/foo', + endTimestamp: expect.any(Number), + op: 'resource.xhr', + startTimestamp: expect.any(Number), + }, + ]); }, ); diff --git a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/noContentLengthHeader/test.ts b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/noContentLengthHeader/test.ts index 9ea10831afab..9a9bd633c71f 100644 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/noContentLengthHeader/test.ts +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/noContentLengthHeader/test.ts @@ -2,7 +2,11 @@ import { expect } from '@playwright/test'; import { sentryTest } from '../../../../../utils/fixtures'; import { envelopeRequestParser, waitForErrorRequest } from '../../../../../utils/helpers'; -import { shouldSkipReplayTest } from '../../../../../utils/replayHelpers'; +import { + getCustomRecordingEvents, + shouldSkipReplayTest, + waitForReplayRequest, +} from '../../../../../utils/replayHelpers'; sentryTest( 'captures response_body_size without Content-Length header', @@ -25,7 +29,17 @@ sentryTest( }); }); + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); + }); + const requestPromise = waitForErrorRequest(page); + const replayRequestPromise1 = waitForReplayRequest(page, 0); + const url = await getLocalTestPath({ testDir: __dirname }); await page.goto(url); @@ -65,5 +79,21 @@ sentryTest( url: 'http://localhost:7654/foo', }, }); + + const replayReq1 = await replayRequestPromise1; + const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); + expect(performanceSpans1.filter(span => span.op === 'resource.xhr')).toEqual([ + { + data: { + method: 'GET', + responseBodySize: 29, + statusCode: 200, + }, + description: 'http://localhost:7654/foo', + endTimestamp: expect.any(Number), + op: 'resource.xhr', + startTimestamp: expect.any(Number), + }, + ]); }, ); diff --git a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/nonTextBody/test.ts b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/nonTextBody/test.ts index 5142f2e6be82..0210283fea60 100644 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/nonTextBody/test.ts +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/nonTextBody/test.ts @@ -2,7 +2,11 @@ import { expect } from '@playwright/test'; import { sentryTest } from '../../../../../utils/fixtures'; import { envelopeRequestParser, waitForErrorRequest } from '../../../../../utils/helpers'; -import { shouldSkipReplayTest } from '../../../../../utils/replayHelpers'; +import { + getCustomRecordingEvents, + shouldSkipReplayTest, + waitForReplayRequest, +} from '../../../../../utils/replayHelpers'; sentryTest('calculates body sizes for non-string bodies', async ({ getLocalTestPath, page, browserName }) => { // These are a bit flaky on non-chromium browsers @@ -20,7 +24,17 @@ sentryTest('calculates body sizes for non-string bodies', async ({ getLocalTestP }); }); + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); + }); + const requestPromise = waitForErrorRequest(page); + const replayRequestPromise1 = waitForReplayRequest(page, 0); + const url = await getLocalTestPath({ testDir: __dirname }); await page.goto(url); @@ -63,4 +77,21 @@ sentryTest('calculates body sizes for non-string bodies', async ({ getLocalTestP url: 'http://localhost:7654/foo', }, }); + + const replayReq1 = await replayRequestPromise1; + const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); + expect(performanceSpans1.filter(span => span.op === 'resource.xhr')).toEqual([ + { + data: { + method: 'POST', + requestBodySize: 26, + responseBodySize: 24, + statusCode: 200, + }, + description: 'http://localhost:7654/foo', + endTimestamp: expect.any(Number), + op: 'resource.xhr', + startTimestamp: expect.any(Number), + }, + ]); }); diff --git a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/requestBody/test.ts b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/requestBody/test.ts index fd3cc426f9fd..470fe57c51ba 100644 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/requestBody/test.ts +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/requestBody/test.ts @@ -2,7 +2,11 @@ import { expect } from '@playwright/test'; import { sentryTest } from '../../../../../utils/fixtures'; import { envelopeRequestParser, waitForErrorRequest } from '../../../../../utils/helpers'; -import { shouldSkipReplayTest } from '../../../../../utils/replayHelpers'; +import { + getCustomRecordingEvents, + shouldSkipReplayTest, + waitForReplayRequest, +} from '../../../../../utils/replayHelpers'; sentryTest('captures request_body_size when body is sent', async ({ getLocalTestPath, page, browserName }) => { // These are a bit flaky on non-chromium browsers @@ -13,9 +17,6 @@ sentryTest('captures request_body_size when body is sent', async ({ getLocalTest await page.route('**/foo', route => { return route.fulfill({ status: 200, - body: JSON.stringify({ - userNames: ['John', 'Jane'], - }), headers: { 'Content-Type': 'application/json', 'Content-Length': '', @@ -23,7 +24,17 @@ sentryTest('captures request_body_size when body is sent', async ({ getLocalTest }); }); + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); + }); + const requestPromise = waitForErrorRequest(page); + const replayRequestPromise1 = waitForReplayRequest(page, 0); + const url = await getLocalTestPath({ testDir: __dirname }); await page.goto(url); @@ -52,5 +63,31 @@ sentryTest('captures request_body_size when body is sent', async ({ getLocalTest expect(eventData.exception?.values).toHaveLength(1); expect(eventData?.breadcrumbs?.length).toBe(1); - expect(eventData!.breadcrumbs![0].data!.request_body_size).toEqual(13); + expect(eventData!.breadcrumbs![0]).toEqual({ + timestamp: expect.any(Number), + category: 'xhr', + type: 'http', + data: { + method: 'POST', + request_body_size: 13, + status_code: 200, + url: 'http://localhost:7654/foo', + }, + }); + + const replayReq1 = await replayRequestPromise1; + const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); + expect(performanceSpans1.filter(span => span.op === 'resource.xhr')).toEqual([ + { + data: { + method: 'POST', + requestBodySize: 13, + statusCode: 200, + }, + description: 'http://localhost:7654/foo', + endTimestamp: expect.any(Number), + op: 'resource.xhr', + startTimestamp: expect.any(Number), + }, + ]); }); diff --git a/packages/browser-integration-tests/suites/replay/privacyInput/test.ts b/packages/browser-integration-tests/suites/replay/privacyInput/test.ts index f95e857d5637..dc071b9bf487 100644 --- a/packages/browser-integration-tests/suites/replay/privacyInput/test.ts +++ b/packages/browser-integration-tests/suites/replay/privacyInput/test.ts @@ -24,10 +24,34 @@ sentryTest( sentryTest.skip(); } + // We want to ensure to check the correct event payloads + const inputMutationSegmentIds: number[] = []; const reqPromise0 = waitForReplayRequest(page, 0); - const reqPromise1 = waitForReplayRequest(page, 1); - const reqPromise2 = waitForReplayRequest(page, 2); - const reqPromise3 = waitForReplayRequest(page, 3); + const reqPromise1 = waitForReplayRequest(page, (event, res) => { + const check = inputMutationSegmentIds.length === 0 && getIncrementalRecordingSnapshots(res).some(isInputMutation); + + if (check) { + inputMutationSegmentIds.push(event.segment_id); + } + + return check; + }); + const reqPromise2 = waitForReplayRequest(page, (event, res) => { + const check = + inputMutationSegmentIds.length === 1 && + inputMutationSegmentIds[0] < event.segment_id && + getIncrementalRecordingSnapshots(res).some(isInputMutation); + + if (check) { + inputMutationSegmentIds.push(event.segment_id); + } + + return check; + }); + const reqPromise3 = waitForReplayRequest(page, event => { + // This one should not have any input mutations + return inputMutationSegmentIds.length === 2 && inputMutationSegmentIds[1] < event.segment_id; + }); await page.route('https://dsn.ingest.sentry.io/**/*', route => { return route.fulfill({ @@ -72,10 +96,34 @@ sentryTest( sentryTest.skip(); } + // We want to ensure to check the correct event payloads + const inputMutationSegmentIds: number[] = []; const reqPromise0 = waitForReplayRequest(page, 0); - const reqPromise1 = waitForReplayRequest(page, 1); - const reqPromise2 = waitForReplayRequest(page, 2); - const reqPromise3 = waitForReplayRequest(page, 3); + const reqPromise1 = waitForReplayRequest(page, (event, res) => { + const check = inputMutationSegmentIds.length === 0 && getIncrementalRecordingSnapshots(res).some(isInputMutation); + + if (check) { + inputMutationSegmentIds.push(event.segment_id); + } + + return check; + }); + const reqPromise2 = waitForReplayRequest(page, (event, res) => { + const check = + inputMutationSegmentIds.length === 1 && + inputMutationSegmentIds[0] < event.segment_id && + getIncrementalRecordingSnapshots(res).some(isInputMutation); + + if (check) { + inputMutationSegmentIds.push(event.segment_id); + } + + return check; + }); + const reqPromise3 = waitForReplayRequest(page, event => { + // This one should not have any input mutations + return inputMutationSegmentIds.length === 2 && inputMutationSegmentIds[1] < event.segment_id; + }); await page.route('https://dsn.ingest.sentry.io/**/*', route => { return route.fulfill({ diff --git a/packages/replay/src/coreHandlers/handleNetworkBreadcrumbs.ts b/packages/replay/src/coreHandlers/handleNetworkBreadcrumbs.ts index 0ee72f9edc9c..e9ad5d9ea209 100644 --- a/packages/replay/src/coreHandlers/handleNetworkBreadcrumbs.ts +++ b/packages/replay/src/coreHandlers/handleNetworkBreadcrumbs.ts @@ -51,7 +51,7 @@ export function handleNetworkBreadcrumbs(replay: ReplayContainer): void { }; if (client && client.on) { - client.on('beforeAddBreadcrumb', (breadcrumb, hint) => handleNetworkBreadcrumb(options, breadcrumb, hint)); + client.on('beforeAddBreadcrumb', (breadcrumb, hint) => beforeAddNetworkBreadcrumb(options, breadcrumb, hint)); } else { // Fallback behavior addInstrumentationHandler('fetch', handleFetchSpanListener(replay)); @@ -63,7 +63,7 @@ export function handleNetworkBreadcrumbs(replay: ReplayContainer): void { } /** just exported for tests */ -export function handleNetworkBreadcrumb( +export function beforeAddNetworkBreadcrumb( options: ExtendedNetworkBreadcrumbsOptions, breadcrumb: Breadcrumb, hint?: BreadcrumbHint, @@ -74,27 +74,76 @@ export function handleNetworkBreadcrumb( try { if (_isXhrBreadcrumb(breadcrumb) && _isXhrHint(hint)) { - // Enriches the breadcrumb overall - _enrichXhrBreadcrumb(breadcrumb, hint, options); - - // Create a replay performance entry from this breadcrumb - const result = _makeNetworkReplayBreadcrumb('resource.xhr', breadcrumb, hint); - addNetworkBreadcrumb(options.replay, result); + _handleXhrBreadcrumb(breadcrumb, hint, options); } if (_isFetchBreadcrumb(breadcrumb) && _isFetchHint(hint)) { - // Enriches the breadcrumb overall + // This has to be sync, as we need to ensure the breadcrumb is enriched in the same tick + // Because the hook runs synchronously, and the breadcrumb is afterwards passed on + // So any async mutations to it will not be reflected in the final breadcrumb _enrichFetchBreadcrumb(breadcrumb, hint, options); - // Create a replay performance entry from this breadcrumb - const result = _makeNetworkReplayBreadcrumb('resource.fetch', breadcrumb, hint); - addNetworkBreadcrumb(options.replay, result); + void _handleFetchBreadcrumb(breadcrumb, hint, options); } } catch (e) { __DEBUG_BUILD__ && logger.warn('Error when enriching network breadcrumb'); } } +function _handleXhrBreadcrumb( + breadcrumb: Breadcrumb & { data: XhrBreadcrumbData }, + hint: XhrHint, + options: ExtendedNetworkBreadcrumbsOptions, +): void { + // Enriches the breadcrumb overall + _enrichXhrBreadcrumb(breadcrumb, hint, options); + + // Create a replay performance entry from this breadcrumb + const result = _makeNetworkReplayBreadcrumb('resource.xhr', breadcrumb, hint); + addNetworkBreadcrumb(options.replay, result); +} + +async function _handleFetchBreadcrumb( + breadcrumb: Breadcrumb & { data: FetchBreadcrumbData }, + hint: FetchHint, + options: ExtendedNetworkBreadcrumbsOptions, +): Promise { + const fullBreadcrumb = await _parseFetchResponse(breadcrumb, hint, options); + + // Create a replay performance entry from this breadcrumb + const result = _makeNetworkReplayBreadcrumb('resource.fetch', fullBreadcrumb, hint); + addNetworkBreadcrumb(options.replay, result); +} + +// This does async operations on the breadcrumb for replay +async function _parseFetchResponse( + breadcrumb: Breadcrumb & { data: FetchBreadcrumbData }, + hint: FetchBreadcrumbHint, + options: ExtendedNetworkBreadcrumbsOptions, +): Promise { + if (breadcrumb.data.response_body_size || !hint.response) { + return breadcrumb; + } + + // If no Content-Length header exists, we try to get the size from the response body + try { + // We have to clone this, as the body can only be read once + const response = (hint.response as Response).clone(); + const body = await response.text(); + + if (body.length) { + return { + ...breadcrumb, + data: { ...breadcrumb.data, response_body_size: getBodySize(body, options.textEncoder) }, + }; + } + } catch { + // just ignore if something fails here + } + + return breadcrumb; +} + function _makeNetworkReplayBreadcrumb( type: string, breadcrumb: Breadcrumb & { data: FetchBreadcrumbData | XhrBreadcrumbData }, diff --git a/packages/replay/test/unit/coreHandlers/handleNetworkBreadcrumbs.test.ts b/packages/replay/test/unit/coreHandlers/handleNetworkBreadcrumbs.test.ts index d0a7b5cedca1..1a1c5ac13d6b 100644 --- a/packages/replay/test/unit/coreHandlers/handleNetworkBreadcrumbs.test.ts +++ b/packages/replay/test/unit/coreHandlers/handleNetworkBreadcrumbs.test.ts @@ -9,8 +9,8 @@ import { TextEncoder } from 'util'; import { BASE_TIMESTAMP } from '../..'; import { + beforeAddNetworkBreadcrumb, getBodySize, - handleNetworkBreadcrumb, parseContentSizeHeader, } from '../../../src/coreHandlers/handleNetworkBreadcrumbs'; import type { EventBufferArray } from '../../../src/eventBuffer/EventBufferArray'; @@ -78,7 +78,7 @@ describe('Unit | coreHandlers | handleNetworkBreadcrumbs', () => { }); }); - describe('handleNetworkBreadcrumb()', () => { + describe('beforeAddNetworkBreadcrumb()', () => { let options: { replay: ReplayContainer; textEncoder: TextEncoderInternal; @@ -98,7 +98,7 @@ describe('Unit | coreHandlers | handleNetworkBreadcrumbs', () => { it('ignores breadcrumb without data', () => { const breadcrumb: Breadcrumb = {}; const hint: BreadcrumbHint = {}; - handleNetworkBreadcrumb(options, breadcrumb, hint); + beforeAddNetworkBreadcrumb(options, breadcrumb, hint); expect(breadcrumb).toEqual({}); expect((options.replay.eventBuffer as EventBufferArray).events).toEqual([]); @@ -110,7 +110,7 @@ describe('Unit | coreHandlers | handleNetworkBreadcrumbs', () => { data: {}, }; const hint: BreadcrumbHint = {}; - handleNetworkBreadcrumb(options, breadcrumb, hint); + beforeAddNetworkBreadcrumb(options, breadcrumb, hint); expect(breadcrumb).toEqual({ category: 'foo', @@ -138,7 +138,7 @@ describe('Unit | coreHandlers | handleNetworkBreadcrumbs', () => { startTimestamp: BASE_TIMESTAMP + 1000, endTimestamp: BASE_TIMESTAMP + 2000, }; - handleNetworkBreadcrumb(options, breadcrumb, hint); + beforeAddNetworkBreadcrumb(options, breadcrumb, hint); expect(breadcrumb).toEqual({ category: 'xhr', @@ -192,7 +192,7 @@ describe('Unit | coreHandlers | handleNetworkBreadcrumbs', () => { startTimestamp: BASE_TIMESTAMP + 1000, endTimestamp: BASE_TIMESTAMP + 2000, }; - handleNetworkBreadcrumb(options, breadcrumb, hint); + beforeAddNetworkBreadcrumb(options, breadcrumb, hint); expect(breadcrumb).toEqual({ category: 'xhr', @@ -246,7 +246,7 @@ describe('Unit | coreHandlers | handleNetworkBreadcrumbs', () => { startTimestamp: BASE_TIMESTAMP + 1000, endTimestamp: BASE_TIMESTAMP + 2000, }; - handleNetworkBreadcrumb(options, breadcrumb, hint); + beforeAddNetworkBreadcrumb(options, breadcrumb, hint); expect(breadcrumb).toEqual({ category: 'fetch', @@ -260,6 +260,7 @@ describe('Unit | coreHandlers | handleNetworkBreadcrumbs', () => { }); jest.runAllTimers(); + await Promise.resolve(); expect((options.replay.eventBuffer as EventBufferArray).events).toEqual([ { @@ -305,7 +306,7 @@ describe('Unit | coreHandlers | handleNetworkBreadcrumbs', () => { startTimestamp: BASE_TIMESTAMP + 1000, endTimestamp: BASE_TIMESTAMP + 2000, }; - handleNetworkBreadcrumb(options, breadcrumb, hint); + beforeAddNetworkBreadcrumb(options, breadcrumb, hint); expect(breadcrumb).toEqual({ category: 'fetch', @@ -316,6 +317,7 @@ describe('Unit | coreHandlers | handleNetworkBreadcrumbs', () => { }); jest.runAllTimers(); + await Promise.resolve(); expect((options.replay.eventBuffer as EventBufferArray).events).toEqual([ { @@ -336,5 +338,63 @@ describe('Unit | coreHandlers | handleNetworkBreadcrumbs', () => { }, ]); }); + + it('parses fetch response body if necessary', async () => { + const breadcrumb: Breadcrumb = { + category: 'fetch', + data: { + url: 'https://example.com', + status_code: 200, + }, + }; + + const mockResponse = { + headers: { + get: () => '', + }, + clone: () => mockResponse, + text: () => Promise.resolve('test response'), + } as unknown as Response; + + const hint: FetchBreadcrumbHint = { + input: [], + response: mockResponse, + startTimestamp: BASE_TIMESTAMP + 1000, + endTimestamp: BASE_TIMESTAMP + 2000, + }; + beforeAddNetworkBreadcrumb(options, breadcrumb, hint); + + expect(breadcrumb).toEqual({ + category: 'fetch', + data: { + status_code: 200, + url: 'https://example.com', + }, + }); + + await Promise.resolve(); + jest.runAllTimers(); + await Promise.resolve(); + + expect((options.replay.eventBuffer as EventBufferArray).events).toEqual([ + { + type: 5, + timestamp: (BASE_TIMESTAMP + 1000) / 1000, + data: { + tag: 'performanceSpan', + payload: { + data: { + statusCode: 200, + responseBodySize: 13, + }, + description: 'https://example.com', + endTimestamp: (BASE_TIMESTAMP + 2000) / 1000, + op: 'resource.fetch', + startTimestamp: (BASE_TIMESTAMP + 1000) / 1000, + }, + }, + }, + ]); + }); }); });