diff --git a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureBodies/init.js b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureBodies/init.js new file mode 100644 index 000000000000..ff7729968b4e --- /dev/null +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureBodies/init.js @@ -0,0 +1,20 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; +window.Replay = new Sentry.Replay({ + flushMinDelay: 200, + flushMaxDelay: 200, + _experiments: { + captureNetworkBodies: 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/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureBodies/test.ts b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureBodies/test.ts new file mode 100644 index 000000000000..6acea7308b67 --- /dev/null +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureBodies/test.ts @@ -0,0 +1,101 @@ +import { expect } from '@playwright/test'; + +import { sentryTest } from '../../../../../utils/fixtures'; +import { envelopeRequestParser, waitForErrorRequest } from '../../../../../utils/helpers'; +import { + getCustomRecordingEvents, + shouldSkipReplayTest, + waitForReplayRequest, +} from '../../../../../utils/replayHelpers'; + +sentryTest( + 'captures text requestBody & responseBody when experiment is configured', + async ({ getLocalTestPath, page }) => { + if (shouldSkipReplayTest()) { + sentryTest.skip(); + } + + await page.route('**/foo', route => { + return route.fulfill({ + status: 200, + body: 'response body', + 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); + + await page.evaluate(() => { + /* eslint-disable */ + fetch('http://localhost:7654/foo', { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Cache: 'no-cache', + }, + body: 'input body', + }).then(() => { + // @ts-ignore Sentry is a global + Sentry.captureException('test error'); + }); + /* eslint-enable */ + }); + + const request = await requestPromise; + const eventData = envelopeRequestParser(request); + + 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: 10, + response_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.fetch')).toEqual([ + { + data: { + method: 'POST', + statusCode: 200, + request: { + size: 10, + body: 'input body', + }, + response: { + size: 13, + body: 'response body', + }, + }, + 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/captureBodiesJSON/init.js b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureBodiesJSON/init.js new file mode 100644 index 000000000000..ff7729968b4e --- /dev/null +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureBodiesJSON/init.js @@ -0,0 +1,20 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; +window.Replay = new Sentry.Replay({ + flushMinDelay: 200, + flushMaxDelay: 200, + _experiments: { + captureNetworkBodies: 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/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureBodiesJSON/test.ts b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureBodiesJSON/test.ts new file mode 100644 index 000000000000..b3f7715fabc3 --- /dev/null +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureBodiesJSON/test.ts @@ -0,0 +1,101 @@ +import { expect } from '@playwright/test'; + +import { sentryTest } from '../../../../../utils/fixtures'; +import { envelopeRequestParser, waitForErrorRequest } from '../../../../../utils/helpers'; +import { + getCustomRecordingEvents, + shouldSkipReplayTest, + waitForReplayRequest, +} from '../../../../../utils/replayHelpers'; + +sentryTest( + 'captures JSON requestBody & responseBody when experiment is configured', + async ({ getLocalTestPath, page }) => { + if (shouldSkipReplayTest()) { + sentryTest.skip(); + } + + await page.route('**/foo', route => { + return route.fulfill({ + status: 200, + body: JSON.stringify({ res: 'this' }), + 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); + + await page.evaluate(() => { + /* eslint-disable */ + fetch('http://localhost:7654/foo', { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Cache: 'no-cache', + }, + body: '{"foo":"bar"}', + }).then(() => { + // @ts-ignore Sentry is a global + Sentry.captureException('test error'); + }); + /* eslint-enable */ + }); + + const request = await requestPromise; + const eventData = envelopeRequestParser(request); + + 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, + response_body_size: 14, + status_code: 200, + 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', + statusCode: 200, + request: { + size: 13, + body: { foo: 'bar' }, + }, + response: { + size: 14, + body: { res: 'this' }, + }, + }, + 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/captureNonTextBodies/init.js b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureNonTextBodies/init.js new file mode 100644 index 000000000000..ff7729968b4e --- /dev/null +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureNonTextBodies/init.js @@ -0,0 +1,20 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; +window.Replay = new Sentry.Replay({ + flushMinDelay: 200, + flushMaxDelay: 200, + _experiments: { + captureNetworkBodies: 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/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureNonTextBodies/test.ts b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureNonTextBodies/test.ts new file mode 100644 index 000000000000..bfcc0bf1dd48 --- /dev/null +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureNonTextBodies/test.ts @@ -0,0 +1,104 @@ +import { expect } from '@playwright/test'; + +import { sentryTest } from '../../../../../utils/fixtures'; +import { envelopeRequestParser, waitForErrorRequest } from '../../../../../utils/helpers'; +import { + getCustomRecordingEvents, + shouldSkipReplayTest, + waitForReplayRequest, +} from '../../../../../utils/replayHelpers'; + +sentryTest( + 'captures non-text fetch requestBody & responseBody when experiment is configured', + async ({ getLocalTestPath, page }) => { + if (shouldSkipReplayTest()) { + sentryTest.skip(); + } + + await page.route('**/foo', route => { + return route.fulfill({ + status: 200, + body: Buffer.from('Hello world'), + 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); + + await page.evaluate(() => { + const body = new URLSearchParams(); + body.append('name', 'Anne'); + body.append('age', '32'); + + /* eslint-disable */ + fetch('http://localhost:7654/foo', { + method: 'POST', + headers: { + Accept: 'application/json', + Cache: 'no-cache', + }, + body: body, + }).then(() => { + // @ts-ignore Sentry is a global + Sentry.captureException('test error'); + }); + /* eslint-enable */ + }); + + const request = await requestPromise; + const eventData = envelopeRequestParser(request); + + 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: 16, + response_body_size: 24, + status_code: 200, + 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', + statusCode: 200, + request: { + size: 16, + body: 'name=Anne&age=32', + }, + response: { + size: 24, + body: 'Hello world', + }, + }, + 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/contentLengthHeader/test.ts b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/contentLengthHeader/test.ts index 27c429c9be98..85e37a5e8b6b 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 @@ -79,8 +79,10 @@ sentryTest('parses response_body_size from Content-Length header if available', { data: { method: 'GET', - responseBodySize: 789, statusCode: 200, + response: { + size: 789, + }, }, description: 'http://localhost:7654/foo', endTimestamp: 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 31f8d65bc7e7..21780e2e447a 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 @@ -78,8 +78,10 @@ sentryTest('does not capture response_body_size without Content-Length header', { data: { method: 'GET', - responseBodySize: 29, statusCode: 200, + response: { + size: 29, + }, }, description: 'http://localhost:7654/foo', endTimestamp: 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/nonTextBodySizes/test.ts similarity index 96% rename from packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/nonTextBody/test.ts rename to packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/nonTextBodySizes/test.ts index d2c167110a8a..b9d77ceda1c5 100644 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/nonTextBody/test.ts +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/nonTextBodySizes/test.ts @@ -81,9 +81,13 @@ sentryTest('calculates body sizes for non-string bodies', async ({ getLocalTestP { data: { method: 'POST', - requestBodySize: 26, - responseBodySize: 24, statusCode: 200, + request: { + size: 26, + }, + response: { + size: 24, + }, }, description: 'http://localhost:7654/foo', endTimestamp: 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/requestBodySize/test.ts similarity index 93% rename from packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/requestBody/test.ts rename to packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/requestBodySize/test.ts index 0f77394b6e5d..572fd168bff5 100644 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/requestBody/test.ts +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/requestBodySize/test.ts @@ -8,7 +8,7 @@ import { waitForReplayRequest, } from '../../../../../utils/replayHelpers'; -sentryTest('captures request_body_size when body is sent', async ({ getLocalTestPath, page }) => { +sentryTest('captures request body size when body is sent', async ({ getLocalTestPath, page }) => { if (shouldSkipReplayTest()) { sentryTest.skip(); } @@ -70,7 +70,6 @@ sentryTest('captures request_body_size when body is sent', async ({ getLocalTest url: 'http://localhost:7654/foo', }, }); - expect(eventData!.breadcrumbs![0].data!.request_body_size).toEqual(13); const replayReq1 = await replayRequestPromise1; const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); @@ -78,8 +77,10 @@ sentryTest('captures request_body_size when body is sent', async ({ getLocalTest { data: { method: 'POST', - requestBodySize: 13, statusCode: 200, + request: { + size: 13, + }, }, description: 'http://localhost:7654/foo', endTimestamp: expect.any(Number), diff --git a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureBodies/init.js b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureBodies/init.js new file mode 100644 index 000000000000..ff7729968b4e --- /dev/null +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureBodies/init.js @@ -0,0 +1,20 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; +window.Replay = new Sentry.Replay({ + flushMinDelay: 200, + flushMaxDelay: 200, + _experiments: { + captureNetworkBodies: 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/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureBodies/test.ts b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureBodies/test.ts new file mode 100644 index 000000000000..1bf9732d0c1f --- /dev/null +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureBodies/test.ts @@ -0,0 +1,99 @@ +import { expect } from '@playwright/test'; + +import { sentryTest } from '../../../../../utils/fixtures'; +import { envelopeRequestParser, waitForErrorRequest } from '../../../../../utils/helpers'; +import { + getCustomRecordingEvents, + shouldSkipReplayTest, + waitForReplayRequest, +} from '../../../../../utils/replayHelpers'; + +sentryTest( + 'captures text xhr requestBody & responseBody when experiment is configured', + async ({ getLocalTestPath, page, browserName }) => { + // These are a bit flaky on non-chromium browsers + if (shouldSkipReplayTest() || browserName !== 'chromium') { + sentryTest.skip(); + } + + await page.route('**/foo', route => { + return route.fulfill({ + status: 200, + body: 'response body', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': '', + }, + }); + }); + + 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); + + void page.evaluate(() => { + /* eslint-disable */ + const xhr = new XMLHttpRequest(); + + xhr.open('POST', 'http://localhost:7654/foo'); + xhr.setRequestHeader('Accept', 'application/json'); + xhr.setRequestHeader('Content-Type', 'application/json'); + xhr.setRequestHeader('Cache', 'no-cache'); + xhr.send('input body'); + + xhr.addEventListener('readystatechange', function () { + if (xhr.readyState === 4) { + // @ts-ignore Sentry is a global + setTimeout(() => Sentry.captureException('test error', 0)); + } + }); + /* eslint-enable */ + }); + + const request = await requestPromise; + const eventData = envelopeRequestParser(request); + + expect(eventData.exception?.values).toHaveLength(1); + + expect(eventData?.breadcrumbs?.length).toBe(1); + expect(eventData!.breadcrumbs![0]).toEqual({ + timestamp: expect.any(Number), + category: 'xhr', + type: 'http', + data: { + method: 'POST', + request_body_size: 10, + response_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', + statusCode: 200, + request: { size: 10, body: 'input body' }, + response: { size: 13, body: 'response body' }, + }, + 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/captureBodiesJSON/init.js b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureBodiesJSON/init.js new file mode 100644 index 000000000000..ff7729968b4e --- /dev/null +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureBodiesJSON/init.js @@ -0,0 +1,20 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; +window.Replay = new Sentry.Replay({ + flushMinDelay: 200, + flushMaxDelay: 200, + _experiments: { + captureNetworkBodies: 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/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureBodiesJSON/test.ts b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureBodiesJSON/test.ts new file mode 100644 index 000000000000..5a86540b450d --- /dev/null +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureBodiesJSON/test.ts @@ -0,0 +1,99 @@ +import { expect } from '@playwright/test'; + +import { sentryTest } from '../../../../../utils/fixtures'; +import { envelopeRequestParser, waitForErrorRequest } from '../../../../../utils/helpers'; +import { + getCustomRecordingEvents, + shouldSkipReplayTest, + waitForReplayRequest, +} from '../../../../../utils/replayHelpers'; + +sentryTest( + 'captures JSON xhr requestBody & responseBody when experiment is configured', + async ({ getLocalTestPath, page, browserName }) => { + // These are a bit flaky on non-chromium browsers + if (shouldSkipReplayTest() || browserName !== 'chromium') { + sentryTest.skip(); + } + + await page.route('**/foo', route => { + return route.fulfill({ + status: 200, + body: JSON.stringify({ res: 'this' }), + headers: { + 'Content-Type': 'application/json', + 'Content-Length': '', + }, + }); + }); + + 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); + + void page.evaluate(() => { + /* eslint-disable */ + const xhr = new XMLHttpRequest(); + + xhr.open('POST', 'http://localhost:7654/foo'); + xhr.setRequestHeader('Accept', 'application/json'); + xhr.setRequestHeader('Content-Type', 'application/json'); + xhr.setRequestHeader('Cache', 'no-cache'); + xhr.send('{"foo":"bar"}'); + + xhr.addEventListener('readystatechange', function () { + if (xhr.readyState === 4) { + // @ts-ignore Sentry is a global + setTimeout(() => Sentry.captureException('test error', 0)); + } + }); + /* eslint-enable */ + }); + + const request = await requestPromise; + const eventData = envelopeRequestParser(request); + + expect(eventData.exception?.values).toHaveLength(1); + + expect(eventData?.breadcrumbs?.length).toBe(1); + expect(eventData!.breadcrumbs![0]).toEqual({ + timestamp: expect.any(Number), + category: 'xhr', + type: 'http', + data: { + method: 'POST', + request_body_size: 13, + response_body_size: 14, + 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', + statusCode: 200, + request: { size: 13, body: { foo: 'bar' } }, + response: { size: 14, body: { res: 'this' } }, + }, + 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/captureNonTextBodies/init.js b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureNonTextBodies/init.js new file mode 100644 index 000000000000..ff7729968b4e --- /dev/null +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureNonTextBodies/init.js @@ -0,0 +1,20 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; +window.Replay = new Sentry.Replay({ + flushMinDelay: 200, + flushMaxDelay: 200, + _experiments: { + captureNetworkBodies: 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/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureNonTextBodies/test.ts b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureNonTextBodies/test.ts new file mode 100644 index 000000000000..054d1a970595 --- /dev/null +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureNonTextBodies/test.ts @@ -0,0 +1,102 @@ +import { expect } from '@playwright/test'; + +import { sentryTest } from '../../../../../utils/fixtures'; +import { envelopeRequestParser, waitForErrorRequest } from '../../../../../utils/helpers'; +import { + getCustomRecordingEvents, + shouldSkipReplayTest, + waitForReplayRequest, +} from '../../../../../utils/replayHelpers'; + +sentryTest( + 'captures non-text fetch requestBody & responseBody when experiment is configured', + async ({ getLocalTestPath, page, browserName }) => { + // These are a bit flaky on non-chromium browsers + if (shouldSkipReplayTest() || browserName !== 'chromium') { + sentryTest.skip(); + } + + await page.route('**/foo', async route => { + return route.fulfill({ + status: 200, + body: Buffer.from('Hello world'), + 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); + + await page.evaluate(() => { + /* eslint-disable */ + const xhr = new XMLHttpRequest(); + + const body = new URLSearchParams(); + body.append('name', 'Anne'); + body.append('age', '32'); + + xhr.open('POST', 'http://localhost:7654/foo'); + xhr.setRequestHeader('Accept', 'application/json'); + xhr.setRequestHeader('Content-Type', 'application/json'); + xhr.setRequestHeader('Cache', 'no-cache'); + xhr.send(body); + + xhr.addEventListener('readystatechange', function () { + if (xhr.readyState === 4) { + // @ts-ignore Sentry is a global + setTimeout(() => Sentry.captureException('test error', 0)); + } + }); + /* eslint-enable */ + }); + + const request = await requestPromise; + const eventData = envelopeRequestParser(request); + + expect(eventData.exception?.values).toHaveLength(1); + + expect(eventData?.breadcrumbs?.length).toBe(1); + expect(eventData!.breadcrumbs![0]).toEqual({ + timestamp: expect.any(Number), + category: 'xhr', + type: 'http', + data: { + method: 'POST', + request_body_size: 16, + response_body_size: 24, + 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', + statusCode: 200, + request: { size: 16, body: 'name=Anne&age=32' }, + response: { size: 24, body: 'Hello world' }, + }, + 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/contentLengthHeader/test.ts b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/contentLengthHeader/test.ts index 4ee170939530..0db738a467e0 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 @@ -86,8 +86,8 @@ sentryTest( { data: { method: 'GET', - responseBodySize: 789, statusCode: 200, + response: { size: 789 }, }, description: 'http://localhost:7654/foo', endTimestamp: 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 9a9bd633c71f..18284361f17a 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 @@ -86,8 +86,8 @@ sentryTest( { data: { method: 'GET', - responseBodySize: 29, statusCode: 200, + response: { size: 29 }, }, description: 'http://localhost:7654/foo', endTimestamp: 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/nonTextBodySizes/test.ts similarity index 97% rename from packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/nonTextBody/test.ts rename to packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/nonTextBodySizes/test.ts index 0210283fea60..80aadc4ebdfa 100644 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/nonTextBody/test.ts +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/nonTextBodySizes/test.ts @@ -84,9 +84,9 @@ sentryTest('calculates body sizes for non-string bodies', async ({ getLocalTestP { data: { method: 'POST', - requestBodySize: 26, - responseBodySize: 24, statusCode: 200, + request: { size: 26 }, + response: { size: 24 }, }, description: 'http://localhost:7654/foo', endTimestamp: 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/requestBodySize/test.ts similarity index 96% rename from packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/requestBody/test.ts rename to packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/requestBodySize/test.ts index 470fe57c51ba..7e473e980114 100644 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/requestBody/test.ts +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/requestBodySize/test.ts @@ -8,7 +8,7 @@ import { waitForReplayRequest, } from '../../../../../utils/replayHelpers'; -sentryTest('captures request_body_size when body is sent', async ({ getLocalTestPath, page, browserName }) => { +sentryTest('captures request body size when body is sent', async ({ getLocalTestPath, page, browserName }) => { // These are a bit flaky on non-chromium browsers if (shouldSkipReplayTest() || browserName !== 'chromium') { sentryTest.skip(); @@ -81,8 +81,8 @@ sentryTest('captures request_body_size when body is sent', async ({ getLocalTest { data: { method: 'POST', - requestBodySize: 13, statusCode: 200, + request: { size: 13 }, }, description: 'http://localhost:7654/foo', endTimestamp: expect.any(Number), diff --git a/packages/browser-integration-tests/utils/replayEventTemplates.ts b/packages/browser-integration-tests/utils/replayEventTemplates.ts index 9adc95b260cd..2c4b887032e0 100644 --- a/packages/browser-integration-tests/utils/replayEventTemplates.ts +++ b/packages/browser-integration-tests/utils/replayEventTemplates.ts @@ -156,8 +156,8 @@ export const expectedFetchPerformanceSpan = { data: { method: 'POST', statusCode: 200, - responseBodySize: 11, - requestBodySize: 3, + request: { size: 3 }, + response: { size: 11 }, }, }; @@ -169,7 +169,7 @@ export const expectedXHRPerformanceSpan = { data: { method: 'GET', statusCode: 200, - responseBodySize: 11, + response: { size: 11 }, }, }; diff --git a/packages/replay/src/constants.ts b/packages/replay/src/constants.ts index c3550cb988c3..57c30fdad662 100644 --- a/packages/replay/src/constants.ts +++ b/packages/replay/src/constants.ts @@ -28,3 +28,6 @@ export const ERROR_CHECKOUT_TIME = 60_000; export const RETRY_BASE_INTERVAL = 5000; export const RETRY_MAX_COUNT = 3; + +/* The max (uncompressed) size in bytes of a network body. Any body larger than this will be dropped. */ +export const NETWORK_BODY_MAX_SIZE = 300_000; diff --git a/packages/replay/src/coreHandlers/handleDom.ts b/packages/replay/src/coreHandlers/handleDom.ts index 8d005afa1b46..8878f2b71966 100644 --- a/packages/replay/src/coreHandlers/handleDom.ts +++ b/packages/replay/src/coreHandlers/handleDom.ts @@ -5,7 +5,7 @@ import { htmlTreeAsString } from '@sentry/utils'; import type { ReplayContainer } from '../types'; import { createBreadcrumb } from '../util/createBreadcrumb'; -import { addBreadcrumbEvent } from './addBreadcrumbEvent'; +import { addBreadcrumbEvent } from './util/addBreadcrumbEvent'; import { getAttributesToRecord } from './util/getAttributesToRecord'; interface DomHandlerData { diff --git a/packages/replay/src/coreHandlers/handleFetch.ts b/packages/replay/src/coreHandlers/handleFetch.ts index 61355be7edec..1358701ab8f3 100644 --- a/packages/replay/src/coreHandlers/handleFetch.ts +++ b/packages/replay/src/coreHandlers/handleFetch.ts @@ -1,7 +1,7 @@ import type { HandlerDataFetch } from '@sentry/types'; import type { NetworkRequestData, ReplayContainer, ReplayPerformanceEntry } from '../types'; -import { addNetworkBreadcrumb } from './addNetworkBreadcrumb'; +import { addNetworkBreadcrumb } from './util/addNetworkBreadcrumb'; /** only exported for tests */ export function handleFetch(handlerData: HandlerDataFetch): null | ReplayPerformanceEntry { @@ -11,18 +11,17 @@ export function handleFetch(handlerData: HandlerDataFetch): null | ReplayPerform return null; } - const { method, request_body_size: requestBodySize, response_body_size: responseBodySize } = fetchData; + // This is only used as a fallback, so we know the body sizes are never set here + const { method, url } = fetchData; return { type: 'resource.fetch', start: startTimestamp / 1000, end: endTimestamp / 1000, - name: fetchData.url, + name: url, data: { method, statusCode: response && (response as Response).status, - requestBodySize, - responseBodySize, }, }; } diff --git a/packages/replay/src/coreHandlers/handleNetworkBreadcrumbs.ts b/packages/replay/src/coreHandlers/handleNetworkBreadcrumbs.ts index e46ae19e160a..62351494f86d 100644 --- a/packages/replay/src/coreHandlers/handleNetworkBreadcrumbs.ts +++ b/packages/replay/src/coreHandlers/handleNetworkBreadcrumbs.ts @@ -3,41 +3,28 @@ import type { Breadcrumb, BreadcrumbHint, FetchBreadcrumbData, - FetchBreadcrumbHint, - HandlerDataFetch, - SentryWrappedXMLHttpRequest, TextEncoderInternal, XhrBreadcrumbData, - XhrBreadcrumbHint, } from '@sentry/types'; import { addInstrumentationHandler, logger } from '@sentry/utils'; -import type { NetworkRequestData, ReplayContainer, ReplayPerformanceEntry } from '../types'; -import { addNetworkBreadcrumb } from './addNetworkBreadcrumb'; +import type { FetchHint, ReplayContainer, XhrHint } from '../types'; import { handleFetchSpanListener } from './handleFetch'; import { handleXhrSpanListener } from './handleXhr'; - -type RequestBody = null | Blob | BufferSource | FormData | URLSearchParams | string; - -type XhrHint = XhrBreadcrumbHint & { xhr: XMLHttpRequest & SentryWrappedXMLHttpRequest; input?: RequestBody }; -type FetchHint = FetchBreadcrumbHint & { - input: HandlerDataFetch['args']; - response: Response; -}; +import { captureFetchBreadcrumbToReplay, enrichFetchBreadcrumb } from './util/fetchUtils'; +import { captureXhrBreadcrumbToReplay, enrichXhrBreadcrumb } from './util/xhrUtils'; interface ExtendedNetworkBreadcrumbsOptions { replay: ReplayContainer; textEncoder: TextEncoderInternal; + captureBodies: boolean; } /** - * This will enrich the xhr/fetch breadcrumbs with additional information. - * - * This adds: - * * request_body_size - * * response_body_size - * - * to the breadcrumb data. + * This method does two things: + * - It enriches the regular XHR/fetch breadcrumbs with request/response size data + * - It captures the XHR/fetch breadcrumbs to the replay + * (enriching it with further data that is _not_ added to the regular breadcrumbs) */ export function handleNetworkBreadcrumbs(replay: ReplayContainer): void { const client = getCurrentHub().getClient(); @@ -48,6 +35,7 @@ export function handleNetworkBreadcrumbs(replay: ReplayContainer): void { const options: ExtendedNetworkBreadcrumbsOptions = { replay, textEncoder, + captureBodies: replay.getOptions()._experiments.captureNetworkBodies || false, }; if (client && client.on) { @@ -74,220 +62,27 @@ export function beforeAddNetworkBreadcrumb( try { if (_isXhrBreadcrumb(breadcrumb) && _isXhrHint(hint)) { - _handleXhrBreadcrumb(breadcrumb, hint, options); + // 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 + enrichXhrBreadcrumb(breadcrumb, hint, options); + + void captureXhrBreadcrumbToReplay(breadcrumb, hint, options); } if (_isFetchBreadcrumb(breadcrumb) && _isFetchHint(hint)) { // 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); + enrichFetchBreadcrumb(breadcrumb, hint, options); - void _handleFetchBreadcrumb(breadcrumb, hint, options); + void captureFetchBreadcrumbToReplay(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 }, - hint: FetchBreadcrumbHint | XhrBreadcrumbHint, -): ReplayPerformanceEntry | null { - const { startTimestamp, endTimestamp } = hint; - - if (!endTimestamp) { - return null; - } - - const { - url, - method, - status_code: statusCode, - request_body_size: requestBodySize, - response_body_size: responseBodySize, - } = breadcrumb.data; - - if (url === undefined) { - return null; - } - - const result: ReplayPerformanceEntry = { - type, - start: startTimestamp / 1000, - end: endTimestamp / 1000, - name: url, - data: { - method, - statusCode, - }, - }; - - if (requestBodySize) { - result.data.requestBodySize = requestBodySize; - } - if (responseBodySize) { - result.data.responseBodySize = responseBodySize; - } - - return result; -} - -function _enrichXhrBreadcrumb( - breadcrumb: Breadcrumb & { data: XhrBreadcrumbData }, - hint: XhrHint, - options: ExtendedNetworkBreadcrumbsOptions, -): void { - const { xhr, input } = hint; - - const reqSize = getBodySize(input, options.textEncoder); - const resSize = xhr.getResponseHeader('content-length') - ? parseContentSizeHeader(xhr.getResponseHeader('content-length')) - : getBodySize(xhr.response, options.textEncoder); - - if (reqSize !== undefined) { - breadcrumb.data.request_body_size = reqSize; - } - if (resSize !== undefined) { - breadcrumb.data.response_body_size = resSize; - } -} - -function _enrichFetchBreadcrumb( - breadcrumb: Breadcrumb & { data: FetchBreadcrumbData }, - hint: FetchHint, - options: ExtendedNetworkBreadcrumbsOptions, -): void { - const { input, response } = hint; - - const body = getFetchBody(input); - const reqSize = getBodySize(body, options.textEncoder); - const resSize = response ? parseContentSizeHeader(response.headers.get('content-length')) : undefined; - - if (reqSize !== undefined) { - breadcrumb.data.request_body_size = reqSize; - } - if (resSize !== undefined) { - breadcrumb.data.response_body_size = resSize; - } -} - -/** only exported for tests */ -export function getBodySize( - body: RequestInit['body'], - textEncoder: TextEncoder | TextEncoderInternal, -): number | undefined { - if (!body) { - return undefined; - } - - try { - if (typeof body === 'string') { - return textEncoder.encode(body).length; - } - - if (body instanceof URLSearchParams) { - return textEncoder.encode(body.toString()).length; - } - - if (body instanceof FormData) { - // This is a bit simplified, but gives us a decent estimate - // This converts e.g. { name: 'Anne Smith', age: 13 } to 'name=Anne+Smith&age=13' - // @ts-ignore passing FormData to URLSearchParams actually works - const formDataStr = new URLSearchParams(body).toString(); - return textEncoder.encode(formDataStr).length; - } - - if (body instanceof Blob) { - return body.size; - } - - if (body instanceof ArrayBuffer) { - return body.byteLength; - } - - // Currently unhandled types: ArrayBufferView, ReadableStream - } catch { - // just return undefined - } - - return undefined; -} - -/** only exported for tests */ -export function parseContentSizeHeader(header: string | null | undefined): number | undefined { - if (!header) { - return undefined; - } - - const size = parseInt(header, 10); - return isNaN(size) ? undefined : size; -} - -function getFetchBody(fetchArgs: unknown[] = []): RequestInit['body'] | undefined { - // We only support getting the body from the fetch options - if (fetchArgs.length !== 2 || typeof fetchArgs[1] !== 'object') { - return undefined; - } - - return (fetchArgs[1] as RequestInit).body; -} - function _isXhrBreadcrumb(breadcrumb: Breadcrumb): breadcrumb is Breadcrumb & { data: XhrBreadcrumbData } { return breadcrumb.category === 'xhr'; } diff --git a/packages/replay/src/coreHandlers/handleScope.ts b/packages/replay/src/coreHandlers/handleScope.ts index 79d8aa541a37..88624e94efb8 100644 --- a/packages/replay/src/coreHandlers/handleScope.ts +++ b/packages/replay/src/coreHandlers/handleScope.ts @@ -2,7 +2,7 @@ import type { Breadcrumb, Scope } from '@sentry/types'; import type { ReplayContainer } from '../types'; import { createBreadcrumb } from '../util/createBreadcrumb'; -import { addBreadcrumbEvent } from './addBreadcrumbEvent'; +import { addBreadcrumbEvent } from './util/addBreadcrumbEvent'; let _LAST_BREADCRUMB: null | Breadcrumb = null; diff --git a/packages/replay/src/coreHandlers/handleXhr.ts b/packages/replay/src/coreHandlers/handleXhr.ts index 4079016e79ac..eca1994c80b5 100644 --- a/packages/replay/src/coreHandlers/handleXhr.ts +++ b/packages/replay/src/coreHandlers/handleXhr.ts @@ -1,7 +1,7 @@ import type { HandlerDataXhr } from '@sentry/types'; import type { NetworkRequestData, ReplayContainer, ReplayPerformanceEntry } from '../types'; -import { addNetworkBreadcrumb } from './addNetworkBreadcrumb'; +import { addNetworkBreadcrumb } from './util/addNetworkBreadcrumb'; /** only exported for tests */ export function handleXhr(handlerData: HandlerDataXhr): ReplayPerformanceEntry | null { @@ -11,13 +11,8 @@ export function handleXhr(handlerData: HandlerDataXhr): ReplayPerformanceEntry { + try { + const data = await _prepareFetchData(breadcrumb, hint, options); + + // Create a replay performance entry from this breadcrumb + const result = makeNetworkReplayBreadcrumb('resource.fetch', data); + addNetworkBreadcrumb(options.replay, result); + } catch (error) { + __DEBUG_BUILD__ && logger.error('[Replay] Failed to capture fetch breadcrumb', error); + } +} + +/** + * Enrich a breadcrumb with additional data. + * This has to be sync & mutate the given breadcrumb, + * as the breadcrumb is afterwards consumed by other handlers. + */ +export function enrichFetchBreadcrumb( + breadcrumb: Breadcrumb & { data: FetchBreadcrumbData }, + hint: FetchHint, + options: { textEncoder: TextEncoderInternal }, +): void { + const { input, response } = hint; + + const body = _getFetchRequestArgBody(input); + const reqSize = getBodySize(body, options.textEncoder); + const resSize = response ? parseContentLengthHeader(response.headers.get('content-length')) : undefined; + + if (reqSize !== undefined) { + breadcrumb.data.request_body_size = reqSize; + } + if (resSize !== undefined) { + breadcrumb.data.response_body_size = resSize; + } +} + +async function _prepareFetchData( + breadcrumb: Breadcrumb & { data: FetchBreadcrumbData }, + hint: FetchHint, + options: { captureBodies: boolean; textEncoder: TextEncoderInternal }, +): Promise { + const { startTimestamp, endTimestamp } = hint; + + const { + url, + method, + status_code: statusCode, + request_body_size: requestBodySize, + response_body_size: responseBodySize, + } = breadcrumb.data; + + const request = _getRequestInfo(options, hint.input, requestBodySize); + const response = await _getResponseInfo(options, hint.response, responseBodySize); + + return { + startTimestamp, + endTimestamp, + url, + method, + statusCode: statusCode || 0, + request, + response, + }; +} + +function _getRequestInfo( + { captureBodies }: { captureBodies: boolean }, + input: FetchHint['input'], + requestBodySize?: number, +): ReplayNetworkRequestOrResponse | undefined { + if (!captureBodies) { + return buildNetworkRequestOrResponse(requestBodySize, undefined); + } + + // We only want to transmit string or string-like bodies + const requestBody = _getFetchRequestArgBody(input); + const body = getNetworkBody(getBodyString(requestBody)); + return buildNetworkRequestOrResponse(requestBodySize, body); +} + +async function _getResponseInfo( + { captureBodies, textEncoder }: { captureBodies: boolean; textEncoder: TextEncoderInternal }, + response: Response, + responseBodySize?: number, +): Promise { + if (!captureBodies && responseBodySize !== undefined) { + return buildNetworkRequestOrResponse(responseBodySize, undefined); + } + + // Only clone the response if we need to + try { + // We have to clone this, as the body can only be read once + const res = response.clone(); + const { body, bodyText } = await _parseFetchBody(res); + + const size = + bodyText && bodyText.length && responseBodySize === undefined + ? getBodySize(bodyText, textEncoder) + : responseBodySize; + + if (captureBodies) { + return buildNetworkRequestOrResponse(size, body); + } + + return buildNetworkRequestOrResponse(size, undefined); + } catch { + // fallback + return buildNetworkRequestOrResponse(responseBodySize, undefined); + } +} + +async function _parseFetchBody( + response: Response, +): Promise<{ body?: NetworkBody | undefined; bodyText?: string | undefined }> { + let bodyText: string; + + try { + bodyText = await response.text(); + } catch { + return {}; + } + + try { + const body = JSON.parse(bodyText); + return { body, bodyText }; + } catch { + // just send bodyText + } + + return { bodyText, body: bodyText }; +} + +function _getFetchRequestArgBody(fetchArgs: unknown[] = []): RequestInit['body'] | undefined { + // We only support getting the body from the fetch options + if (fetchArgs.length !== 2 || typeof fetchArgs[1] !== 'object') { + return undefined; + } + + return (fetchArgs[1] as RequestInit).body; +} diff --git a/packages/replay/src/coreHandlers/util/networkUtils.ts b/packages/replay/src/coreHandlers/util/networkUtils.ts new file mode 100644 index 000000000000..4c5271ddf30d --- /dev/null +++ b/packages/replay/src/coreHandlers/util/networkUtils.ts @@ -0,0 +1,156 @@ +import type { TextEncoderInternal } from '@sentry/types'; +import { dropUndefinedKeys } from '@sentry/utils'; + +import { NETWORK_BODY_MAX_SIZE } from '../../constants'; +import type { + NetworkBody, + NetworkRequestData, + ReplayNetworkRequestData, + ReplayNetworkRequestOrResponse, + ReplayPerformanceEntry, +} from '../../types'; + +/** Get the size of a body. */ +export function getBodySize( + body: RequestInit['body'], + textEncoder: TextEncoder | TextEncoderInternal, +): number | undefined { + if (!body) { + return undefined; + } + + try { + if (typeof body === 'string') { + return textEncoder.encode(body).length; + } + + if (body instanceof URLSearchParams) { + return textEncoder.encode(body.toString()).length; + } + + if (body instanceof FormData) { + const formDataStr = _serializeFormData(body); + return textEncoder.encode(formDataStr).length; + } + + if (body instanceof Blob) { + return body.size; + } + + if (body instanceof ArrayBuffer) { + return body.byteLength; + } + + // Currently unhandled types: ArrayBufferView, ReadableStream + } catch { + // just return undefined + } + + return undefined; +} + +/** Convert a Content-Length header to number/undefined. */ +export function parseContentLengthHeader(header: string | null | undefined): number | undefined { + if (!header) { + return undefined; + } + + const size = parseInt(header, 10); + return isNaN(size) ? undefined : size; +} + +/** Get the string representation of a body. */ +export function getBodyString(body: unknown): string | undefined { + if (typeof body === 'string') { + return body; + } + + if (body instanceof URLSearchParams) { + return body.toString(); + } + + if (body instanceof FormData) { + return _serializeFormData(body); + } + + return undefined; +} + +/** Convert ReplayNetworkRequestData to a PerformanceEntry. */ +export function makeNetworkReplayBreadcrumb( + type: string, + data: ReplayNetworkRequestData | null, +): ReplayPerformanceEntry | null { + if (!data) { + return null; + } + + const { startTimestamp, endTimestamp, url, method, statusCode, request, response } = data; + + const result: ReplayPerformanceEntry = { + type, + start: startTimestamp / 1000, + end: endTimestamp / 1000, + name: url, + data: dropUndefinedKeys({ + method, + statusCode, + request, + response, + }), + }; + + return result; +} + +/** Get either a JSON network body, or a text representation. */ +export function getNetworkBody(bodyText: string | undefined): NetworkBody | undefined { + if (!bodyText) { + return; + } + + try { + return JSON.parse(bodyText); + } catch { + // return text + } + + return bodyText; +} + +/** Build the request or response part of a replay network breadcrumb. */ +export function buildNetworkRequestOrResponse( + bodySize: number | undefined, + body: NetworkBody | undefined, +): ReplayNetworkRequestOrResponse | undefined { + if (!bodySize) { + return undefined; + } + + if (!body) { + return { + size: bodySize, + }; + } + + const info: ReplayNetworkRequestOrResponse = { + size: bodySize, + }; + + if (bodySize < NETWORK_BODY_MAX_SIZE) { + info.body = body; + } else { + info._meta = { + errors: ['MAX_BODY_SIZE_EXCEEDED'], + }; + } + + return info; +} + +function _serializeFormData(formData: FormData): string { + // This is a bit simplified, but gives us a decent estimate + // This converts e.g. { name: 'Anne Smith', age: 13 } to 'name=Anne+Smith&age=13' + // @ts-ignore passing FormData to URLSearchParams actually works + return new URLSearchParams(formData).toString(); +} diff --git a/packages/replay/src/coreHandlers/util/xhrUtils.ts b/packages/replay/src/coreHandlers/util/xhrUtils.ts new file mode 100644 index 000000000000..efd881d85ece --- /dev/null +++ b/packages/replay/src/coreHandlers/util/xhrUtils.ts @@ -0,0 +1,97 @@ +import type { Breadcrumb, TextEncoderInternal, XhrBreadcrumbData } from '@sentry/types'; +import { logger } from '@sentry/utils'; + +import type { ReplayContainer, ReplayNetworkRequestData, XhrHint } from '../../types'; +import { addNetworkBreadcrumb } from './addNetworkBreadcrumb'; +import { + buildNetworkRequestOrResponse, + getBodySize, + getBodyString, + getNetworkBody, + makeNetworkReplayBreadcrumb, + parseContentLengthHeader, +} from './networkUtils'; + +/** + * Capture an XHR breadcrumb to a replay. + * This adds additional data (where approriate). + */ +export async function captureXhrBreadcrumbToReplay( + breadcrumb: Breadcrumb & { data: XhrBreadcrumbData }, + hint: XhrHint, + options: { replay: ReplayContainer; captureBodies: boolean }, +): Promise { + try { + const data = _prepareXhrData(breadcrumb, hint, options); + + // Create a replay performance entry from this breadcrumb + const result = makeNetworkReplayBreadcrumb('resource.xhr', data); + addNetworkBreadcrumb(options.replay, result); + } catch (error) { + __DEBUG_BUILD__ && logger.error('[Replay] Failed to capture fetch breadcrumb', error); + } +} + +/** + * Enrich a breadcrumb with additional data. + * This has to be sync & mutate the given breadcrumb, + * as the breadcrumb is afterwards consumed by other handlers. + */ +export function enrichXhrBreadcrumb( + breadcrumb: Breadcrumb & { data: XhrBreadcrumbData }, + hint: XhrHint, + options: { textEncoder: TextEncoderInternal }, +): void { + const { xhr, input } = hint; + + const reqSize = getBodySize(input, options.textEncoder); + const resSize = xhr.getResponseHeader('content-length') + ? parseContentLengthHeader(xhr.getResponseHeader('content-length')) + : getBodySize(xhr.response, options.textEncoder); + + if (reqSize !== undefined) { + breadcrumb.data.request_body_size = reqSize; + } + if (resSize !== undefined) { + breadcrumb.data.response_body_size = resSize; + } +} + +function _prepareXhrData( + breadcrumb: Breadcrumb & { data: XhrBreadcrumbData }, + hint: XhrHint, + options: { captureBodies: boolean }, +): ReplayNetworkRequestData | null { + const { startTimestamp, endTimestamp, input } = hint; + + const { + url, + method, + status_code: statusCode, + request_body_size: requestBodySize, + response_body_size: responseBodySize, + } = breadcrumb.data; + + if (!url) { + return null; + } + + const request = buildNetworkRequestOrResponse( + requestBodySize, + options.captureBodies ? getNetworkBody(getBodyString(input)) : undefined, + ); + const response = buildNetworkRequestOrResponse( + responseBodySize, + options.captureBodies ? getNetworkBody(hint.xhr.responseText) : undefined, + ); + + return { + startTimestamp, + endTimestamp, + url, + method, + statusCode: statusCode || 0, + request, + response, + }; +} diff --git a/packages/replay/src/types.ts b/packages/replay/src/types.ts index 3ea18f157bf1..f6d4566d2d7c 100644 --- a/packages/replay/src/types.ts +++ b/packages/replay/src/types.ts @@ -1,4 +1,11 @@ -import type { ReplayRecordingData, ReplayRecordingMode } from '@sentry/types'; +import type { + FetchBreadcrumbHint, + HandlerDataFetch, + ReplayRecordingData, + ReplayRecordingMode, + SentryWrappedXMLHttpRequest, + XhrBreadcrumbHint, +} from '@sentry/types'; import type { eventWithTime, recordOptions } from './types/rrweb'; @@ -236,6 +243,7 @@ export interface ReplayPluginOptions extends SessionOptions { traceInternals: boolean; mutationLimit: number; mutationBreadcrumbLimit: number; + captureNetworkBodies: boolean; }>; } @@ -469,3 +477,38 @@ export interface ReplayPerformanceEntry { */ data: T; } + +type RequestBody = null | Blob | BufferSource | FormData | URLSearchParams | string; + +export type XhrHint = XhrBreadcrumbHint & { + xhr: XMLHttpRequest & SentryWrappedXMLHttpRequest; + input?: RequestBody; +}; +export type FetchHint = FetchBreadcrumbHint & { + input: HandlerDataFetch['args']; + response: Response; +}; + +export type NetworkBody = Record | string; + +type NetworkMetaError = 'MAX_BODY_SIZE_EXCEEDED'; + +interface NetworkMeta { + errors?: NetworkMetaError[]; +} + +export interface ReplayNetworkRequestOrResponse { + size?: number; + body?: NetworkBody; + _meta?: NetworkMeta; +} + +export type ReplayNetworkRequestData = { + startTimestamp: number; + endTimestamp: number; + url: string; + method?: string; + statusCode: number; + request?: ReplayNetworkRequestOrResponse; + response?: ReplayNetworkRequestOrResponse; +}; diff --git a/packages/replay/test/unit/coreHandlers/handleFetch.test.ts b/packages/replay/test/unit/coreHandlers/handleFetch.test.ts index 55acbda699ad..3938b351d796 100644 --- a/packages/replay/test/unit/coreHandlers/handleFetch.test.ts +++ b/packages/replay/test/unit/coreHandlers/handleFetch.test.ts @@ -45,7 +45,8 @@ describe('Unit | coreHandlers | handleFetch', () => { expect(handleFetch(data)).toEqual(null); }); - it('passes request/response size through if available', function () { + // This cannot happen as of now, this test just shows the expected behavior + it('ignores request/response sizes', function () { const data = { ...DEFAULT_DATA, fetchData: { @@ -58,8 +59,6 @@ describe('Unit | coreHandlers | handleFetch', () => { expect(handleFetch(data)?.data).toEqual({ method: 'GET', statusCode: 200, - requestBodySize: 123, - responseBodySize: 456, }); }); }); diff --git a/packages/replay/test/unit/coreHandlers/handleNetworkBreadcrumbs.test.ts b/packages/replay/test/unit/coreHandlers/handleNetworkBreadcrumbs.test.ts index 1a1c5ac13d6b..6a5f344cbdc0 100644 --- a/packages/replay/test/unit/coreHandlers/handleNetworkBreadcrumbs.test.ts +++ b/packages/replay/test/unit/coreHandlers/handleNetworkBreadcrumbs.test.ts @@ -8,80 +8,29 @@ import type { import { TextEncoder } from 'util'; import { BASE_TIMESTAMP } from '../..'; -import { - beforeAddNetworkBreadcrumb, - getBodySize, - parseContentSizeHeader, -} from '../../../src/coreHandlers/handleNetworkBreadcrumbs'; +import { NETWORK_BODY_MAX_SIZE } from '../../../src/constants'; +import { beforeAddNetworkBreadcrumb } from '../../../src/coreHandlers/handleNetworkBreadcrumbs'; import type { EventBufferArray } from '../../../src/eventBuffer/EventBufferArray'; import type { ReplayContainer } from '../../../src/types'; import { setupReplayContainer } from '../../utils/setupReplayContainer'; jest.useFakeTimers(); -describe('Unit | coreHandlers | handleNetworkBreadcrumbs', () => { - describe('parseContentSizeHeader()', () => { - it.each([ - [undefined, undefined], - [null, undefined], - ['', undefined], - ['12', 12], - ['abc', undefined], - ])('works with %s header value', (headerValue, size) => { - expect(parseContentSizeHeader(headerValue)).toBe(size); - }); - }); - - describe('getBodySize()', () => { - const textEncoder = new TextEncoder(); - - it('works with empty body', () => { - expect(getBodySize(undefined, textEncoder)).toBe(undefined); - expect(getBodySize(null, textEncoder)).toBe(undefined); - expect(getBodySize('', textEncoder)).toBe(undefined); - }); - - it('works with string body', () => { - expect(getBodySize('abcd', textEncoder)).toBe(4); - // Emojis are correctly counted as mutliple characters - expect(getBodySize('With emoji: 😈', textEncoder)).toBe(16); - }); +async function waitForReplayEventBuffer() { + // Need one Promise.resolve() per await in the util functions + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +} - it('works with URLSearchParams', () => { - const params = new URLSearchParams(); - params.append('name', 'Jane'); - params.append('age', '42'); - params.append('emoji', '😈'); - - expect(getBodySize(params, textEncoder)).toBe(35); - }); - - it('works with FormData', () => { - const formData = new FormData(); - formData.append('name', 'Jane'); - formData.append('age', '42'); - formData.append('emoji', '😈'); - - expect(getBodySize(formData, textEncoder)).toBe(35); - }); - - it('works with Blob', () => { - const blob = new Blob(['Hello world: 😈'], { type: 'text/html' }); - - expect(getBodySize(blob, textEncoder)).toBe(30); - }); - - it('works with ArrayBuffer', () => { - const arrayBuffer = new ArrayBuffer(8); - - expect(getBodySize(arrayBuffer, textEncoder)).toBe(8); - }); - }); +const LARGE_BODY = 'a'.repeat(NETWORK_BODY_MAX_SIZE + 1); +describe('Unit | coreHandlers | handleNetworkBreadcrumbs', () => { describe('beforeAddNetworkBreadcrumb()', () => { let options: { replay: ReplayContainer; textEncoder: TextEncoderInternal; + captureBodies: boolean; }; beforeEach(() => { @@ -90,21 +39,25 @@ describe('Unit | coreHandlers | handleNetworkBreadcrumbs', () => { options = { textEncoder: new TextEncoder(), replay: setupReplayContainer(), + captureBodies: false, }; jest.runAllTimers(); }); - it('ignores breadcrumb without data', () => { + it('ignores breadcrumb without data', async () => { const breadcrumb: Breadcrumb = {}; const hint: BreadcrumbHint = {}; beforeAddNetworkBreadcrumb(options, breadcrumb, hint); expect(breadcrumb).toEqual({}); + + await waitForReplayEventBuffer(); + expect((options.replay.eventBuffer as EventBufferArray).events).toEqual([]); }); - it('ignores non-network breadcrumbs', () => { + it('ignores non-network breadcrumbs', async () => { const breadcrumb: Breadcrumb = { category: 'foo', data: {}, @@ -116,6 +69,9 @@ describe('Unit | coreHandlers | handleNetworkBreadcrumbs', () => { category: 'foo', data: {}, }); + + await waitForReplayEventBuffer(); + expect((options.replay.eventBuffer as EventBufferArray).events).toEqual([]); }); @@ -151,7 +107,7 @@ describe('Unit | coreHandlers | handleNetworkBreadcrumbs', () => { }, }); - jest.runAllTimers(); + await waitForReplayEventBuffer(); expect((options.replay.eventBuffer as EventBufferArray).events).toEqual([ { @@ -162,9 +118,13 @@ describe('Unit | coreHandlers | handleNetworkBreadcrumbs', () => { payload: { data: { method: 'GET', - requestBodySize: 10, - responseBodySize: 13, statusCode: 200, + request: { + size: 10, + }, + response: { + size: 13, + }, }, description: 'https://example.com', endTimestamp: (BASE_TIMESTAMP + 2000) / 1000, @@ -202,7 +162,7 @@ describe('Unit | coreHandlers | handleNetworkBreadcrumbs', () => { }, }); - jest.runAllTimers(); + await waitForReplayEventBuffer(); expect((options.replay.eventBuffer as EventBufferArray).events).toEqual([ { @@ -259,8 +219,7 @@ describe('Unit | coreHandlers | handleNetworkBreadcrumbs', () => { }, }); - jest.runAllTimers(); - await Promise.resolve(); + await waitForReplayEventBuffer(); expect((options.replay.eventBuffer as EventBufferArray).events).toEqual([ { @@ -271,8 +230,12 @@ describe('Unit | coreHandlers | handleNetworkBreadcrumbs', () => { payload: { data: { method: 'GET', - requestBodySize: 10, - responseBodySize: 13, + request: { + size: 10, + }, + response: { + size: 13, + }, statusCode: 200, }, description: 'https://example.com', @@ -316,8 +279,7 @@ describe('Unit | coreHandlers | handleNetworkBreadcrumbs', () => { }, }); - jest.runAllTimers(); - await Promise.resolve(); + await waitForReplayEventBuffer(); expect((options.replay.eventBuffer as EventBufferArray).events).toEqual([ { @@ -372,9 +334,208 @@ describe('Unit | coreHandlers | handleNetworkBreadcrumbs', () => { }, }); - await Promise.resolve(); - jest.runAllTimers(); - await Promise.resolve(); + await waitForReplayEventBuffer(); + + expect((options.replay.eventBuffer as EventBufferArray).events).toEqual([ + { + type: 5, + timestamp: (BASE_TIMESTAMP + 1000) / 1000, + data: { + tag: 'performanceSpan', + payload: { + data: { + statusCode: 200, + response: { + size: 13, + }, + }, + description: 'https://example.com', + endTimestamp: (BASE_TIMESTAMP + 2000) / 1000, + op: 'resource.fetch', + startTimestamp: (BASE_TIMESTAMP + 1000) / 1000, + }, + }, + }, + ]); + }); + + it('adds fetch request/response body if configured', async () => { + options.captureBodies = true; + + const breadcrumb: Breadcrumb = { + category: 'fetch', + data: { + method: 'GET', + url: 'https://example.com', + status_code: 200, + }, + }; + + const mockResponse = { + headers: { + get: () => '13', + }, + clone: () => mockResponse, + text: () => Promise.resolve('test response'), + } as unknown as Response; + + const hint: FetchBreadcrumbHint = { + input: ['GET', { body: 'test input' }], + response: mockResponse, + startTimestamp: BASE_TIMESTAMP + 1000, + endTimestamp: BASE_TIMESTAMP + 2000, + }; + beforeAddNetworkBreadcrumb(options, breadcrumb, hint); + + expect(breadcrumb).toEqual({ + category: 'fetch', + data: { + method: 'GET', + request_body_size: 10, + 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: 'GET', + statusCode: 200, + request: { + size: 10, + body: 'test input', + }, + response: { + size: 13, + body: 'test response', + }, + }, + description: 'https://example.com', + endTimestamp: (BASE_TIMESTAMP + 2000) / 1000, + op: 'resource.fetch', + startTimestamp: (BASE_TIMESTAMP + 1000) / 1000, + }, + }, + }, + ]); + }); + + it('adds fetch request/response body as JSON if configured', async () => { + options.captureBodies = true; + + const breadcrumb: Breadcrumb = { + category: 'fetch', + data: { + method: 'GET', + url: 'https://example.com', + status_code: 200, + }, + }; + + const mockResponse = { + headers: { + get: () => '', + }, + clone: () => mockResponse, + text: () => Promise.resolve('{"this":"is","json":true}'), + } as unknown as Response; + + const hint: FetchBreadcrumbHint = { + input: ['GET', { body: '{"that":"is","json":true}' }], + response: mockResponse, + startTimestamp: BASE_TIMESTAMP + 1000, + endTimestamp: BASE_TIMESTAMP + 2000, + }; + beforeAddNetworkBreadcrumb(options, breadcrumb, hint); + + expect(breadcrumb).toEqual({ + category: 'fetch', + data: { + method: 'GET', + request_body_size: 25, + 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: 'GET', + statusCode: 200, + request: { + size: 25, + body: { that: 'is', json: true }, + }, + response: { + size: 25, + body: { this: 'is', json: true }, + }, + }, + description: 'https://example.com', + endTimestamp: (BASE_TIMESTAMP + 2000) / 1000, + op: 'resource.fetch', + startTimestamp: (BASE_TIMESTAMP + 1000) / 1000, + }, + }, + }, + ]); + }); + + it('skips fetch request/response body if configured & no body found', async () => { + options.captureBodies = true; + + const breadcrumb: Breadcrumb = { + category: 'fetch', + data: { + method: 'GET', + url: 'https://example.com', + status_code: 200, + }, + }; + + const mockResponse = { + headers: { + get: () => '', + }, + clone: () => mockResponse, + text: () => Promise.resolve(''), + } as unknown as Response; + + const hint: FetchBreadcrumbHint = { + input: ['GET', { body: undefined }], + response: mockResponse, + startTimestamp: BASE_TIMESTAMP + 1000, + endTimestamp: BASE_TIMESTAMP + 2000, + }; + beforeAddNetworkBreadcrumb(options, breadcrumb, hint); + + expect(breadcrumb).toEqual({ + category: 'fetch', + data: { + method: 'GET', + status_code: 200, + url: 'https://example.com', + }, + }); + + await waitForReplayEventBuffer(); expect((options.replay.eventBuffer as EventBufferArray).events).toEqual([ { @@ -384,8 +545,8 @@ describe('Unit | coreHandlers | handleNetworkBreadcrumbs', () => { tag: 'performanceSpan', payload: { data: { + method: 'GET', statusCode: 200, - responseBodySize: 13, }, description: 'https://example.com', endTimestamp: (BASE_TIMESTAMP + 2000) / 1000, @@ -396,5 +557,344 @@ describe('Unit | coreHandlers | handleNetworkBreadcrumbs', () => { }, ]); }); + + it('skips fetch request/response body if configured & too large', async () => { + options.captureBodies = true; + + const breadcrumb: Breadcrumb = { + category: 'fetch', + data: { + method: 'GET', + url: 'https://example.com', + status_code: 200, + }, + }; + + const mockResponse = { + headers: { + get: () => '', + }, + clone: () => mockResponse, + text: () => Promise.resolve(LARGE_BODY), + } as unknown as Response; + + const hint: FetchBreadcrumbHint = { + input: ['GET', { body: LARGE_BODY }], + response: mockResponse, + startTimestamp: BASE_TIMESTAMP + 1000, + endTimestamp: BASE_TIMESTAMP + 2000, + }; + beforeAddNetworkBreadcrumb(options, breadcrumb, hint); + + expect(breadcrumb).toEqual({ + category: 'fetch', + data: { + method: 'GET', + request_body_size: LARGE_BODY.length, + 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: 'GET', + statusCode: 200, + request: { + size: LARGE_BODY.length, + _meta: { + errors: ['MAX_BODY_SIZE_EXCEEDED'], + }, + }, + response: { + size: LARGE_BODY.length, + _meta: { + errors: ['MAX_BODY_SIZE_EXCEEDED'], + }, + }, + }, + description: 'https://example.com', + endTimestamp: (BASE_TIMESTAMP + 2000) / 1000, + op: 'resource.fetch', + startTimestamp: (BASE_TIMESTAMP + 1000) / 1000, + }, + }, + }, + ]); + }); + + it('adds xhr request/response body if configured', async () => { + options.captureBodies = true; + + const breadcrumb: Breadcrumb = { + category: 'xhr', + data: { + method: 'GET', + url: 'https://example.com', + status_code: 200, + }, + }; + const xhr = new XMLHttpRequest(); + Object.defineProperty(xhr, 'response', { + value: 'test response', + }); + Object.defineProperty(xhr, 'responseText', { + value: 'test response', + }); + const hint: XhrBreadcrumbHint = { + xhr, + input: 'test input', + startTimestamp: BASE_TIMESTAMP + 1000, + endTimestamp: BASE_TIMESTAMP + 2000, + }; + beforeAddNetworkBreadcrumb(options, breadcrumb, hint); + + expect(breadcrumb).toEqual({ + category: 'xhr', + data: { + method: 'GET', + request_body_size: 10, + 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: 'GET', + statusCode: 200, + request: { + size: 10, + body: 'test input', + }, + response: { + size: 13, + body: 'test response', + }, + }, + description: 'https://example.com', + endTimestamp: (BASE_TIMESTAMP + 2000) / 1000, + op: 'resource.xhr', + startTimestamp: (BASE_TIMESTAMP + 1000) / 1000, + }, + }, + }, + ]); + }); + + it('adds xhr JSON request/response body if configured', async () => { + options.captureBodies = true; + + const breadcrumb: Breadcrumb = { + category: 'xhr', + data: { + method: 'GET', + url: 'https://example.com', + status_code: 200, + }, + }; + const xhr = new XMLHttpRequest(); + Object.defineProperty(xhr, 'response', { + value: '{"this":"is","json":true}', + }); + Object.defineProperty(xhr, 'responseText', { + value: '{"this":"is","json":true}', + }); + const hint: XhrBreadcrumbHint = { + xhr, + input: '{"that":"is","json":true}', + startTimestamp: BASE_TIMESTAMP + 1000, + endTimestamp: BASE_TIMESTAMP + 2000, + }; + beforeAddNetworkBreadcrumb(options, breadcrumb, hint); + + expect(breadcrumb).toEqual({ + category: 'xhr', + data: { + method: 'GET', + request_body_size: 25, + response_body_size: 25, + 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: 'GET', + statusCode: 200, + request: { + size: 25, + body: { that: 'is', json: true }, + }, + response: { + size: 25, + body: { this: 'is', json: true }, + }, + }, + description: 'https://example.com', + endTimestamp: (BASE_TIMESTAMP + 2000) / 1000, + op: 'resource.xhr', + startTimestamp: (BASE_TIMESTAMP + 1000) / 1000, + }, + }, + }, + ]); + }); + + it('skips xhr request/response body if configured & no body found', async () => { + options.captureBodies = true; + + const breadcrumb: Breadcrumb = { + category: 'xhr', + data: { + method: 'GET', + url: 'https://example.com', + status_code: 200, + }, + }; + const xhr = new XMLHttpRequest(); + Object.defineProperty(xhr, 'response', { + value: '', + }); + Object.defineProperty(xhr, 'responseText', { + value: '', + }); + const hint: XhrBreadcrumbHint = { + xhr, + input: '', + startTimestamp: BASE_TIMESTAMP + 1000, + endTimestamp: BASE_TIMESTAMP + 2000, + }; + beforeAddNetworkBreadcrumb(options, breadcrumb, hint); + + expect(breadcrumb).toEqual({ + category: 'xhr', + data: { + method: 'GET', + 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: 'GET', + statusCode: 200, + }, + description: 'https://example.com', + endTimestamp: (BASE_TIMESTAMP + 2000) / 1000, + op: 'resource.xhr', + startTimestamp: (BASE_TIMESTAMP + 1000) / 1000, + }, + }, + }, + ]); + }); + + it('skip xhr request/response body if configured & body too large', async () => { + options.captureBodies = true; + + const breadcrumb: Breadcrumb = { + category: 'xhr', + data: { + method: 'GET', + url: 'https://example.com', + status_code: 200, + }, + }; + const xhr = new XMLHttpRequest(); + Object.defineProperty(xhr, 'response', { + value: LARGE_BODY, + }); + Object.defineProperty(xhr, 'responseText', { + value: LARGE_BODY, + }); + const hint: XhrBreadcrumbHint = { + xhr, + input: LARGE_BODY, + startTimestamp: BASE_TIMESTAMP + 1000, + endTimestamp: BASE_TIMESTAMP + 2000, + }; + beforeAddNetworkBreadcrumb(options, breadcrumb, hint); + + expect(breadcrumb).toEqual({ + category: 'xhr', + data: { + method: 'GET', + request_body_size: LARGE_BODY.length, + response_body_size: LARGE_BODY.length, + 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: 'GET', + statusCode: 200, + request: { + size: LARGE_BODY.length, + _meta: { + errors: ['MAX_BODY_SIZE_EXCEEDED'], + }, + }, + response: { + size: LARGE_BODY.length, + _meta: { + errors: ['MAX_BODY_SIZE_EXCEEDED'], + }, + }, + }, + description: 'https://example.com', + endTimestamp: (BASE_TIMESTAMP + 2000) / 1000, + op: 'resource.xhr', + startTimestamp: (BASE_TIMESTAMP + 1000) / 1000, + }, + }, + }, + ]); + }); }); }); diff --git a/packages/replay/test/unit/coreHandlers/handleXhr.test.ts b/packages/replay/test/unit/coreHandlers/handleXhr.test.ts index 536b6edc6896..0d19ba10c59d 100644 --- a/packages/replay/test/unit/coreHandlers/handleXhr.test.ts +++ b/packages/replay/test/unit/coreHandlers/handleXhr.test.ts @@ -38,7 +38,8 @@ describe('Unit | coreHandlers | handleXhr', () => { expect(handleXhr(data)).toEqual(null); }); - it('passes request/response size through if available', function () { + // This cannot happen as of now, this test just shows the expected behavior + it('ignores request/response sizes', function () { const data: HandlerDataXhr = { ...DEFAULT_DATA, xhr: { @@ -54,8 +55,6 @@ describe('Unit | coreHandlers | handleXhr', () => { expect(handleXhr(data)?.data).toEqual({ method: 'GET', statusCode: 200, - requestBodySize: 123, - responseBodySize: 456, }); }); }); diff --git a/packages/replay/test/unit/coreHandlers/util/networkUtils.test.ts b/packages/replay/test/unit/coreHandlers/util/networkUtils.test.ts new file mode 100644 index 000000000000..2d407221b77a --- /dev/null +++ b/packages/replay/test/unit/coreHandlers/util/networkUtils.test.ts @@ -0,0 +1,65 @@ +import { TextEncoder } from 'util'; + +import { getBodySize, parseContentLengthHeader } from '../../../../src/coreHandlers/util/networkUtils'; + +jest.useFakeTimers(); + +describe('Unit | coreHandlers | util | networkUtils', () => { + describe('parseContentLengthHeader()', () => { + it.each([ + [undefined, undefined], + [null, undefined], + ['', undefined], + ['12', 12], + ['abc', undefined], + ])('works with %s header value', (headerValue, size) => { + expect(parseContentLengthHeader(headerValue)).toBe(size); + }); + }); + + describe('getBodySize()', () => { + const textEncoder = new TextEncoder(); + + it('works with empty body', () => { + expect(getBodySize(undefined, textEncoder)).toBe(undefined); + expect(getBodySize(null, textEncoder)).toBe(undefined); + expect(getBodySize('', textEncoder)).toBe(undefined); + }); + + it('works with string body', () => { + expect(getBodySize('abcd', textEncoder)).toBe(4); + // Emojis are correctly counted as mutliple characters + expect(getBodySize('With emoji: 😈', textEncoder)).toBe(16); + }); + + it('works with URLSearchParams', () => { + const params = new URLSearchParams(); + params.append('name', 'Jane'); + params.append('age', '42'); + params.append('emoji', '😈'); + + expect(getBodySize(params, textEncoder)).toBe(35); + }); + + it('works with FormData', () => { + const formData = new FormData(); + formData.append('name', 'Jane'); + formData.append('age', '42'); + formData.append('emoji', '😈'); + + expect(getBodySize(formData, textEncoder)).toBe(35); + }); + + it('works with Blob', () => { + const blob = new Blob(['Hello world: 😈'], { type: 'text/html' }); + + expect(getBodySize(blob, textEncoder)).toBe(30); + }); + + it('works with ArrayBuffer', () => { + const arrayBuffer = new ArrayBuffer(8); + + expect(getBodySize(arrayBuffer, textEncoder)).toBe(8); + }); + }); +});