diff --git a/packages/browser-integration-tests/suites/integrations/httpclient/axios/test.ts b/packages/browser-integration-tests/suites/integrations/httpclient/axios/test.ts index 89c2b768a440..ec0b54653be1 100644 --- a/packages/browser-integration-tests/suites/integrations/httpclient/axios/test.ts +++ b/packages/browser-integration-tests/suites/integrations/httpclient/axios/test.ts @@ -47,8 +47,8 @@ sentryTest( url: 'http://localhost:7654/foo', method: 'GET', headers: { - Accept: 'application/json', - Cache: 'no-cache', + accept: 'application/json', + cache: 'no-cache', }, }, contexts: { diff --git a/packages/browser-integration-tests/suites/integrations/httpclient/xhr/test.ts b/packages/browser-integration-tests/suites/integrations/httpclient/xhr/test.ts index a6dfcc755ae0..06f6bd4f0217 100644 --- a/packages/browser-integration-tests/suites/integrations/httpclient/xhr/test.ts +++ b/packages/browser-integration-tests/suites/integrations/httpclient/xhr/test.ts @@ -47,9 +47,9 @@ sentryTest( url: 'http://localhost:7654/foo', method: 'GET', headers: { - Accept: 'application/json', - Cache: 'no-cache', - 'Content-Type': 'application/json', + accept: 'application/json', + cache: 'no-cache', + 'content-type': 'application/json', }, }, contexts: { 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 deleted file mode 100644 index 6acea7308b67..000000000000 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureBodies/test.ts +++ /dev/null @@ -1,101 +0,0 @@ -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/test.ts b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureBodiesJSON/test.ts deleted file mode 100644 index b3f7715fabc3..000000000000 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureBodiesJSON/test.ts +++ /dev/null @@ -1,101 +0,0 @@ -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/test.ts b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureNonTextBodies/test.ts deleted file mode 100644 index bfcc0bf1dd48..000000000000 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureNonTextBodies/test.ts +++ /dev/null @@ -1,104 +0,0 @@ -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/captureBodies/init.js b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureRequestBody/init.js similarity index 100% rename from packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureBodies/init.js rename to packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureRequestBody/init.js diff --git a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureRequestBody/test.ts b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureRequestBody/test.ts new file mode 100644 index 000000000000..0f9bd5dfae59 --- /dev/null +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureRequestBody/test.ts @@ -0,0 +1,259 @@ +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 when experiment is configured', + async ({ getLocalTestPath, page, browserName }) => { + if (shouldSkipReplayTest()) { + sentryTest.skip(); + } + + const additionalHeaders = browserName === 'webkit' ? { 'content-type': 'text/plain' } : undefined; + + await page.route('**/foo', route => { + return route.fulfill({ + status: 200, + }); + }); + + 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', + 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, + 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, + headers: {}, + body: 'input body', + }, + response: additionalHeaders ? { headers: additionalHeaders } : undefined, + }, + description: 'http://localhost:7654/foo', + endTimestamp: expect.any(Number), + op: 'resource.fetch', + startTimestamp: expect.any(Number), + }, + ]); + }, +); + +sentryTest( + 'captures JSON requestBody when experiment is configured', + async ({ getLocalTestPath, page, browserName }) => { + if (shouldSkipReplayTest()) { + sentryTest.skip(); + } + + const additionalHeaders = browserName === 'webkit' ? { 'content-type': 'text/plain' } : undefined; + + await page.route('**/foo', route => { + return route.fulfill({ + status: 200, + }); + }); + + 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', + 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, + 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, + headers: {}, + body: { foo: 'bar' }, + }, + response: additionalHeaders ? { headers: additionalHeaders } : undefined, + }, + description: 'http://localhost:7654/foo', + endTimestamp: expect.any(Number), + op: 'resource.fetch', + startTimestamp: expect.any(Number), + }, + ]); + }, +); + +sentryTest( + 'captures non-text requestBody when experiment is configured', + async ({ getLocalTestPath, page, browserName }) => { + if (shouldSkipReplayTest()) { + sentryTest.skip(); + } + + const additionalHeaders = browserName === 'webkit' ? { 'content-type': 'text/plain' } : undefined; + + await page.route('**/foo', route => { + return route.fulfill({ + status: 200, + }); + }); + + 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', + 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, + 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, + headers: {}, + body: 'name=Anne&age=32', + }, + response: additionalHeaders ? { headers: additionalHeaders } : undefined, + }, + 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/captureBodiesJSON/init.js b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureRequestHeaders/init.js similarity index 90% rename from packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureBodiesJSON/init.js rename to packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureRequestHeaders/init.js index ff7729968b4e..ce0f253a910d 100644 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureBodiesJSON/init.js +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureRequestHeaders/init.js @@ -5,7 +5,7 @@ window.Replay = new Sentry.Replay({ flushMinDelay: 200, flushMaxDelay: 200, _experiments: { - captureNetworkBodies: true, + captureRequestHeaders: ['X-Test-Header'], }, }); diff --git a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureRequestHeaders/test.ts b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureRequestHeaders/test.ts new file mode 100644 index 000000000000..64ae5133b559 --- /dev/null +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureRequestHeaders/test.ts @@ -0,0 +1,331 @@ +import { expect } from '@playwright/test'; + +import { sentryTest } from '../../../../../utils/fixtures'; +import { envelopeRequestParser, waitForErrorRequest } from '../../../../../utils/helpers'; +import { + getCustomRecordingEvents, + shouldSkipReplayTest, + waitForReplayRequest, +} from '../../../../../utils/replayHelpers'; + +sentryTest('handles empty/missing request headers', async ({ getLocalTestPath, page, browserName }) => { + if (shouldSkipReplayTest()) { + sentryTest.skip(); + } + + const additionalHeaders = browserName === 'webkit' ? { 'content-type': 'text/plain' } : undefined; + + await page.route('**/foo', route => { + return route.fulfill({ + status: 200, + }); + }); + + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + }); + }); + + 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', + }).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', + 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, + response: additionalHeaders ? { headers: additionalHeaders } : undefined, + }, + description: 'http://localhost:7654/foo', + endTimestamp: expect.any(Number), + op: 'resource.fetch', + startTimestamp: expect.any(Number), + }, + ]); +}); + +sentryTest('captures request headers as POJO', async ({ getLocalTestPath, page, browserName }) => { + if (shouldSkipReplayTest()) { + sentryTest.skip(); + } + + const additionalHeaders = browserName === 'webkit' ? { 'content-type': 'text/plain' } : undefined; + + await page.route('**/foo', route => { + return route.fulfill({ + status: 200, + }); + }); + + 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', + 'X-Custom-Header': 'foo', + 'X-Test-Header': 'test-value', + }, + }).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', + 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: { + headers: { + accept: 'application/json', + 'content-type': 'application/json', + 'x-test-header': 'test-value', + }, + }, + response: additionalHeaders ? { headers: additionalHeaders } : undefined, + }, + description: 'http://localhost:7654/foo', + endTimestamp: expect.any(Number), + op: 'resource.fetch', + startTimestamp: expect.any(Number), + }, + ]); +}); + +sentryTest('captures request headers on Request', async ({ getLocalTestPath, page, browserName }) => { + if (shouldSkipReplayTest()) { + sentryTest.skip(); + } + + const additionalHeaders = browserName === 'webkit' ? { 'content-type': 'text/plain' } : undefined; + + await page.route('**/foo', route => { + return route.fulfill({ + status: 200, + }); + }); + + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + }); + }); + + const requestPromise = waitForErrorRequest(page); + const replayRequestPromise1 = waitForReplayRequest(page, 0); + + const url = await getLocalTestPath({ testDir: __dirname }); + await page.goto(url); + + await page.evaluate(() => { + const request = new Request('http://localhost:7654/foo', { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Cache: 'no-cache', + 'X-Custom-Header': 'foo', + }, + }); + /* eslint-disable */ + fetch(request).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', + 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: { + headers: { + accept: 'application/json', + 'content-type': 'application/json', + }, + }, + response: additionalHeaders ? { headers: additionalHeaders } : undefined, + }, + description: 'http://localhost:7654/foo', + endTimestamp: expect.any(Number), + op: 'resource.fetch', + startTimestamp: expect.any(Number), + }, + ]); +}); + +sentryTest('captures request headers as Headers instance', async ({ getLocalTestPath, page, browserName }) => { + if (shouldSkipReplayTest()) { + sentryTest.skip(); + } + + const additionalHeaders = browserName === 'webkit' ? { 'content-type': 'text/plain' } : undefined; + + await page.route('**/foo', route => { + return route.fulfill({ + status: 200, + }); + }); + + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + }); + }); + + const requestPromise = waitForErrorRequest(page); + const replayRequestPromise1 = waitForReplayRequest(page, 0); + + const url = await getLocalTestPath({ testDir: __dirname }); + await page.goto(url); + + await page.evaluate(() => { + const headers = new Headers(); + headers.append('Accept', 'application/json'); + headers.append('Content-Type', 'application/json'); + headers.append('Cache', 'no-cache'); + headers.append('X-Custom-Header', 'foo'); + + /* eslint-disable */ + fetch('http://localhost:7654/foo', { + method: 'POST', + headers, + }).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', + 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: { + headers: { + accept: 'application/json', + 'content-type': 'application/json', + }, + }, + response: additionalHeaders ? { headers: additionalHeaders } : undefined, + }, + 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/captureRequestSize/test.ts b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureRequestSize/test.ts new file mode 100644 index 000000000000..9aeb5615f628 --- /dev/null +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureRequestSize/test.ts @@ -0,0 +1,167 @@ +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 request body size when body is sent', async ({ getLocalTestPath, page, browserName }) => { + if (shouldSkipReplayTest()) { + sentryTest.skip(); + } + + const additionalHeaders = browserName === 'webkit' ? { 'content-type': 'text/plain' } : undefined; + + await page.route('**/foo', route => { + return route.fulfill({ + status: 200, + }); + }); + + 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', + 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, + 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, + headers: {}, + }, + response: additionalHeaders ? { headers: additionalHeaders } : undefined, + }, + description: 'http://localhost:7654/foo', + endTimestamp: expect.any(Number), + op: 'resource.fetch', + startTimestamp: expect.any(Number), + }, + ]); +}); + +sentryTest('captures request size from non-text request body', async ({ getLocalTestPath, page, browserName }) => { + if (shouldSkipReplayTest()) { + sentryTest.skip(); + } + + const additionalHeaders = browserName === 'webkit' ? { 'content-type': 'text/plain' } : undefined; + + await page.route('**/foo', async route => { + return route.fulfill({ + status: 200, + }); + }); + + 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 blob = new Blob(['Hello world!!'], { type: 'text/html' }); + + fetch('http://localhost:7654/foo', { + method: 'POST', + body: blob, + }).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: 26, + 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: 26, + headers: {}, + }, + response: additionalHeaders ? { headers: additionalHeaders } : undefined, + }, + 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/captureResponseBody/init.js similarity index 100% rename from packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureBodiesJSON/init.js rename to packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureResponseBody/init.js diff --git a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureResponseBody/test.ts b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureResponseBody/test.ts new file mode 100644 index 000000000000..6f0fc2c4d4f2 --- /dev/null +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureResponseBody/test.ts @@ -0,0 +1,261 @@ +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 responseBody when experiment is configured', + async ({ getLocalTestPath, page, browserName }) => { + if (shouldSkipReplayTest()) { + sentryTest.skip(); + } + + const additionalHeaders = browserName === 'webkit' ? { 'content-type': 'text/plain' } : undefined; + + await page.route('**/foo', route => { + return route.fulfill({ + status: 200, + body: 'response body', + }); + }); + + 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', + }).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', + 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, + response: { + size: 13, + headers: { + 'content-length': '13', + ...additionalHeaders, + }, + body: 'response body', + }, + }, + description: 'http://localhost:7654/foo', + endTimestamp: expect.any(Number), + op: 'resource.fetch', + startTimestamp: expect.any(Number), + }, + ]); + }, +); + +sentryTest( + 'captures JSON responseBody when experiment is configured', + async ({ getLocalTestPath, page, browserName }) => { + if (shouldSkipReplayTest()) { + sentryTest.skip(); + } + + const additionalHeaders = browserName === 'webkit' ? { 'content-type': 'text/plain' } : undefined; + + await page.route('**/foo', route => { + return route.fulfill({ + status: 200, + body: JSON.stringify({ res: 'this' }), + }); + }); + + 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', + }).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', + 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, + response: { + size: 14, + headers: { + 'content-length': '14', + ...additionalHeaders, + }, + body: { res: 'this' }, + }, + }, + description: 'http://localhost:7654/foo', + endTimestamp: expect.any(Number), + op: 'resource.fetch', + startTimestamp: expect.any(Number), + }, + ]); + }, +); + +sentryTest( + 'captures non-text responseBody when experiment is configured', + async ({ getLocalTestPath, page, browserName }) => { + if (shouldSkipReplayTest()) { + sentryTest.skip(); + } + + const additionalHeaders = browserName === 'webkit' ? { 'content-type': 'application/octet-stream' } : {}; + + await page.route('**/foo', route => { + return route.fulfill({ + status: 200, + body: Buffer.from('Hello world'), + }); + }); + + 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', + }).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', + 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, + response: { + size: 24, + headers: { + 'content-length': '24', + ...additionalHeaders, + }, + 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/captureResponseHeaders/init.js b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureResponseHeaders/init.js new file mode 100644 index 000000000000..e675af70ba91 --- /dev/null +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureResponseHeaders/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: { + captureResponseHeaders: ['X-Test-Header'], + }, +}); + +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/captureResponseHeaders/test.ts b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureResponseHeaders/test.ts new file mode 100644 index 000000000000..177dfa8efd57 --- /dev/null +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureResponseHeaders/test.ts @@ -0,0 +1,160 @@ +import { expect } from '@playwright/test'; + +import { sentryTest } from '../../../../../utils/fixtures'; +import { envelopeRequestParser, waitForErrorRequest } from '../../../../../utils/helpers'; +import { + getCustomRecordingEvents, + shouldSkipReplayTest, + waitForReplayRequest, +} from '../../../../../utils/replayHelpers'; + +sentryTest('handles empty headers', async ({ getLocalTestPath, page, browserName }) => { + if (shouldSkipReplayTest()) { + sentryTest.skip(); + } + + const additionalHeaders = browserName === 'webkit' ? { 'content-type': 'text/plain' } : undefined; + + await page.route('**/foo', route => { + return route.fulfill({ + status: 200, + }); + }); + + 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').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: 'GET', + 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: 'GET', + statusCode: 200, + response: additionalHeaders ? { headers: additionalHeaders } : undefined, + }, + description: 'http://localhost:7654/foo', + endTimestamp: expect.any(Number), + op: 'resource.fetch', + startTimestamp: expect.any(Number), + }, + ]); +}); + +sentryTest('captures response headers', async ({ getLocalTestPath, page }) => { + if (shouldSkipReplayTest()) { + sentryTest.skip(); + } + + await page.route('**/foo', route => { + return route.fulfill({ + status: 200, + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'X-Test-Header': 'test-value', + 'X-Other-Header': 'test-value-2', + 'access-control-expose-headers': '*', + }, + }); + }); + + 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').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: 'GET', + 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: 'GET', + statusCode: 200, + response: { + headers: { + accept: 'application/json', + 'content-type': 'application/json', + 'x-test-header': 'test-value', + }, + }, + }, + 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/captureResponseSize/test.ts b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureResponseSize/test.ts new file mode 100644 index 000000000000..eeb51d16ee9a --- /dev/null +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureResponseSize/test.ts @@ -0,0 +1,253 @@ +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 response size from Content-Length header if available', async ({ getLocalTestPath, page }) => { + if (shouldSkipReplayTest()) { + sentryTest.skip(); + } + + await page.route('**/foo', route => { + return route.fulfill({ + status: 200, + body: JSON.stringify({ + userNames: ['John', 'Jane'], + }), + headers: { + 'Content-Type': 'application/json', + 'Content-Length': '789', + }, + }); + }); + + 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').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: 'GET', + response_body_size: 789, + 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: 'GET', + statusCode: 200, + response: { + headers: { + 'content-length': '789', + 'content-type': 'application/json', + }, + size: 789, + }, + }, + description: 'http://localhost:7654/foo', + endTimestamp: expect.any(Number), + op: 'resource.fetch', + startTimestamp: expect.any(Number), + }, + ]); +}); + +sentryTest('captures response size without Content-Length header', async ({ getLocalTestPath, page }) => { + if (shouldSkipReplayTest()) { + sentryTest.skip(); + } + + await page.route('**/foo', route => { + return route.fulfill({ + status: 200, + body: JSON.stringify({ + userNames: ['John', 'Jane'], + }), + 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); + + await page.evaluate(() => { + /* eslint-disable */ + fetch('http://localhost:7654/foo').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: 'GET', + status_code: 200, + // NOT set here from body, as this would be async + 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', + statusCode: 200, + response: { + headers: { + 'content-type': 'application/json', + }, + size: 29, + }, + }, + description: 'http://localhost:7654/foo', + endTimestamp: expect.any(Number), + op: 'resource.fetch', + startTimestamp: expect.any(Number), + }, + ]); +}); + +sentryTest('captures response size from non-text response body', async ({ getLocalTestPath, page, browserName }) => { + if (shouldSkipReplayTest()) { + sentryTest.skip(); + } + + const additionalHeaders = browserName === 'webkit' ? { 'content-type': 'application/octet-stream' } : {}; + + await page.route('**/foo', async route => { + return route.fulfill({ + status: 200, + body: Buffer.from('Hello world'), + headers: { + '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); + + await page.evaluate(() => { + /* eslint-disable */ + fetch('http://localhost:7654/foo', { + method: 'POST', + }).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', + 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, + response: { + size: 24, + headers: { + ...additionalHeaders, + }, + }, + }, + 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 deleted file mode 100644 index 85e37a5e8b6b..000000000000 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/contentLengthHeader/test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { expect } from '@playwright/test'; - -import { sentryTest } from '../../../../../utils/fixtures'; -import { envelopeRequestParser, waitForErrorRequest } from '../../../../../utils/helpers'; -import { - getCustomRecordingEvents, - shouldSkipReplayTest, - waitForReplayRequest, -} from '../../../../../utils/replayHelpers'; - -sentryTest('parses response_body_size from Content-Length header if available', async ({ getLocalTestPath, page }) => { - if (shouldSkipReplayTest()) { - sentryTest.skip(); - } - - await page.route('**/foo', route => { - return route.fulfill({ - status: 200, - body: JSON.stringify({ - userNames: ['John', 'Jane'], - }), - headers: { - 'Content-Type': 'application/json', - 'Content-Length': '789', - }, - }); - }); - - 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', { - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - Cache: 'no-cache', - }, - }).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: 'GET', - response_body_size: 789, - 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: 'GET', - statusCode: 200, - response: { - size: 789, - }, - }, - 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 deleted file mode 100644 index 21780e2e447a..000000000000 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/noContentLengthHeader/test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { expect } from '@playwright/test'; - -import { sentryTest } from '../../../../../utils/fixtures'; -import { envelopeRequestParser, waitForErrorRequest } from '../../../../../utils/helpers'; -import { - getCustomRecordingEvents, - shouldSkipReplayTest, - waitForReplayRequest, -} from '../../../../../utils/replayHelpers'; - -sentryTest('does not capture response_body_size without Content-Length header', async ({ getLocalTestPath, page }) => { - if (shouldSkipReplayTest()) { - sentryTest.skip(); - } - - await page.route('**/foo', route => { - return route.fulfill({ - status: 200, - body: JSON.stringify({ - userNames: ['John', 'Jane'], - }), - 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); - - await page.evaluate(() => { - /* eslint-disable */ - fetch('http://localhost:7654/foo', { - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - Cache: 'no-cache', - }, - }).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: 'GET', - 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: 'GET', - statusCode: 200, - response: { - size: 29, - }, - }, - 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/nonTextBodySizes/test.ts b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/nonTextBodySizes/test.ts deleted file mode 100644 index b9d77ceda1c5..000000000000 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/nonTextBodySizes/test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { expect } from '@playwright/test'; - -import { sentryTest } from '../../../../../utils/fixtures'; -import { envelopeRequestParser, waitForErrorRequest } from '../../../../../utils/helpers'; -import { - getCustomRecordingEvents, - shouldSkipReplayTest, - waitForReplayRequest, -} from '../../../../../utils/replayHelpers'; - -sentryTest('calculates body sizes for non-string bodies', async ({ getLocalTestPath, page }) => { - if (shouldSkipReplayTest()) { - 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 blob = new Blob(['Hello world!!'], { type: 'text/html' }); - - fetch('http://localhost:7654/foo', { - method: 'POST', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - Cache: 'no-cache', - }, - body: blob, - }).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: 26, - 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: 26, - }, - response: { - size: 24, - }, - }, - 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/requestBodySize/test.ts b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/requestBodySize/test.ts deleted file mode 100644 index 572fd168bff5..000000000000 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/requestBodySize/test.ts +++ /dev/null @@ -1,91 +0,0 @@ -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 request body size when body is sent', async ({ getLocalTestPath, page }) => { - if (shouldSkipReplayTest()) { - sentryTest.skip(); - } - - await page.route('**/foo', route => { - return route.fulfill({ - status: 200, - 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, - 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, - }, - }, - 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/captureBodies/test.ts b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureBodies/test.ts deleted file mode 100644 index 1bf9732d0c1f..000000000000 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureBodies/test.ts +++ /dev/null @@ -1,99 +0,0 @@ -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/test.ts b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureBodiesJSON/test.ts deleted file mode 100644 index 5a86540b450d..000000000000 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureBodiesJSON/test.ts +++ /dev/null @@ -1,99 +0,0 @@ -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/test.ts b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureNonTextBodies/test.ts deleted file mode 100644 index 054d1a970595..000000000000 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureNonTextBodies/test.ts +++ /dev/null @@ -1,102 +0,0 @@ -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/captureBodies/init.js b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureRequestBody/init.js similarity index 90% rename from packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureBodies/init.js rename to packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureRequestBody/init.js index ff7729968b4e..de48fc6febee 100644 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureBodies/init.js +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureRequestBody/init.js @@ -6,6 +6,7 @@ window.Replay = new Sentry.Replay({ flushMaxDelay: 200, _experiments: { captureNetworkBodies: true, + captureResponseHeaders: ['X-Test-Header'], }, }); diff --git a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureRequestBody/test.ts b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureRequestBody/test.ts new file mode 100644 index 000000000000..567990fe55fb --- /dev/null +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureRequestBody/test.ts @@ -0,0 +1,265 @@ +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 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, + }); + }); + + 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.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, + 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, + headers: {}, + body: 'input body', + }, + }, + description: 'http://localhost:7654/foo', + endTimestamp: expect.any(Number), + op: 'resource.xhr', + startTimestamp: expect.any(Number), + }, + ]); + }, +); + +sentryTest( + 'captures JSON requestBody 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, + }); + }); + + 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.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, + 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, + headers: {}, + body: { foo: 'bar' }, + }, + }, + description: 'http://localhost:7654/foo', + endTimestamp: expect.any(Number), + op: 'resource.xhr', + startTimestamp: expect.any(Number), + }, + ]); + }, +); + +sentryTest( + 'captures non-text requestBody 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, + }); + }); + + 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.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, + 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, + headers: {}, + body: 'name=Anne&age=32', + }, + }, + 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/captureRequestHeaders/init.js similarity index 90% rename from packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureNonTextBodies/init.js rename to packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureRequestHeaders/init.js index ff7729968b4e..ce0f253a910d 100644 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureNonTextBodies/init.js +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureRequestHeaders/init.js @@ -5,7 +5,7 @@ window.Replay = new Sentry.Replay({ flushMinDelay: 200, flushMaxDelay: 200, _experiments: { - captureNetworkBodies: true, + captureRequestHeaders: ['X-Test-Header'], }, }); diff --git a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/requestBodySize/test.ts b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureRequestHeaders/test.ts similarity index 87% rename from packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/requestBodySize/test.ts rename to packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureRequestHeaders/test.ts index 7e473e980114..9be377b9ea84 100644 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/requestBodySize/test.ts +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureRequestHeaders/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 headers', async ({ getLocalTestPath, page, browserName }) => { // These are a bit flaky on non-chromium browsers if (shouldSkipReplayTest() || browserName !== 'chromium') { sentryTest.skip(); @@ -17,10 +17,6 @@ sentryTest('captures request body size when body is sent', async ({ getLocalTest await page.route('**/foo', route => { return route.fulfill({ status: 200, - headers: { - 'Content-Type': 'application/json', - 'Content-Length': '', - }, }); }); @@ -46,7 +42,8 @@ sentryTest('captures request body size when body is sent', async ({ getLocalTest xhr.setRequestHeader('Accept', 'application/json'); xhr.setRequestHeader('Content-Type', 'application/json'); xhr.setRequestHeader('Cache', 'no-cache'); - xhr.send('{"foo":"bar"}'); + xhr.setRequestHeader('X-Test-Header', 'test-value'); + xhr.send(); xhr.addEventListener('readystatechange', function () { if (xhr.readyState === 4) { @@ -69,7 +66,6 @@ sentryTest('captures request body size when body is sent', async ({ getLocalTest type: 'http', data: { method: 'POST', - request_body_size: 13, status_code: 200, url: 'http://localhost:7654/foo', }, @@ -82,7 +78,13 @@ sentryTest('captures request body size when body is sent', async ({ getLocalTest data: { method: 'POST', statusCode: 200, - request: { size: 13 }, + request: { + headers: { + 'content-type': 'application/json', + accept: 'application/json', + 'x-test-header': 'test-value', + }, + }, }, description: 'http://localhost:7654/foo', endTimestamp: expect.any(Number), diff --git a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureRequestSize/test.ts b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureRequestSize/test.ts new file mode 100644 index 000000000000..9d30f3aab3a1 --- /dev/null +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureRequestSize/test.ts @@ -0,0 +1,171 @@ +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 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(); + } + + await page.route('**/foo', route => { + return route.fulfill({ + status: 200, + }); + }); + + 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.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, + 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, + headers: {}, + }, + }, + description: 'http://localhost:7654/foo', + endTimestamp: expect.any(Number), + op: 'resource.xhr', + startTimestamp: expect.any(Number), + }, + ]); +}); + +sentryTest('captures request size from non-text request body', 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, + }); + }); + + 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 blob = new Blob(['Hello world!!'], { type: 'text/html' }); + + xhr.open('POST', 'http://localhost:7654/foo'); + xhr.send(blob); + + 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: 26, + 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: 26, + headers: {}, + }, + }, + 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/fetch/captureNonTextBodies/init.js b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureResponseBody/init.js similarity index 100% rename from packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureNonTextBodies/init.js rename to packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureResponseBody/init.js diff --git a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureResponseBody/test.ts b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureResponseBody/test.ts new file mode 100644 index 000000000000..0bfc4374faf1 --- /dev/null +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureResponseBody/test.ts @@ -0,0 +1,273 @@ +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 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-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.send(); + + 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', + 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, + response: { + size: 13, + headers: {}, + body: 'response body', + }, + }, + description: 'http://localhost:7654/foo', + endTimestamp: expect.any(Number), + op: 'resource.xhr', + startTimestamp: expect.any(Number), + }, + ]); + }, +); + +sentryTest( + 'captures JSON 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-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.send(); + + 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', + 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, + response: { + size: 14, + headers: {}, + body: { res: 'this' }, + }, + }, + description: 'http://localhost:7654/foo', + endTimestamp: expect.any(Number), + op: 'resource.xhr', + startTimestamp: expect.any(Number), + }, + ]); + }, +); + +sentryTest( + 'captures non-text 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-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); + + await page.evaluate(() => { + /* eslint-disable */ + const xhr = new XMLHttpRequest(); + + xhr.open('POST', 'http://localhost:7654/foo'); + xhr.send(); + + 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', + 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, + response: { + size: 24, + headers: {}, + 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/captureResponseHeaders/init.js b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureResponseHeaders/init.js new file mode 100644 index 000000000000..e675af70ba91 --- /dev/null +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureResponseHeaders/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: { + captureResponseHeaders: ['X-Test-Header'], + }, +}); + +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/nonTextBodySizes/test.ts b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureResponseHeaders/test.ts similarity index 76% rename from packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/nonTextBodySizes/test.ts rename to packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureResponseHeaders/test.ts index 80aadc4ebdfa..5b34368c488f 100644 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/nonTextBodySizes/test.ts +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureResponseHeaders/test.ts @@ -8,18 +8,21 @@ import { waitForReplayRequest, } from '../../../../../utils/replayHelpers'; -sentryTest('calculates body sizes for non-string bodies', async ({ getLocalTestPath, page, browserName }) => { +sentryTest('captures response headers', 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 => { + await page.route('**/foo', route => { return route.fulfill({ status: 200, - body: Buffer.from('Hello world'), headers: { 'Content-Type': 'application/json', + Accept: 'application/json', + 'X-Test-Header': 'test-value', + 'X-Other-Header': 'test-value-2', + 'access-control-expose-headers': '*', }, }); }); @@ -42,13 +45,8 @@ sentryTest('calculates body sizes for non-string bodies', async ({ getLocalTestP /* eslint-disable */ const xhr = new XMLHttpRequest(); - const blob = new Blob(['Hello world!!'], { type: 'text/html' }); - - 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(blob); + xhr.open('GET', 'http://localhost:7654/foo'); + xhr.send(); xhr.addEventListener('readystatechange', function () { if (xhr.readyState === 4) { @@ -70,9 +68,7 @@ sentryTest('calculates body sizes for non-string bodies', async ({ getLocalTestP category: 'xhr', type: 'http', data: { - method: 'POST', - request_body_size: 26, - response_body_size: 24, + method: 'GET', status_code: 200, url: 'http://localhost:7654/foo', }, @@ -83,10 +79,15 @@ sentryTest('calculates body sizes for non-string bodies', async ({ getLocalTestP expect(performanceSpans1.filter(span => span.op === 'resource.xhr')).toEqual([ { data: { - method: 'POST', + method: 'GET', statusCode: 200, - request: { size: 26 }, - response: { size: 24 }, + response: { + headers: { + accept: 'application/json', + 'content-type': 'application/json', + 'x-test-header': 'test-value', + }, + }, }, description: 'http://localhost:7654/foo', endTimestamp: expect.any(Number), diff --git a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureResponseSize/test.ts b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureResponseSize/test.ts new file mode 100644 index 000000000000..3467d1b5f04c --- /dev/null +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureResponseSize/test.ts @@ -0,0 +1,267 @@ +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 response size from Content-Length header if available', + 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, + headers: { + 'Content-Length': '789', + }, + }); + }); + + 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(); + + xhr.open('GET', 'http://localhost:7654/foo'); + xhr.send(); + + 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: 'GET', + response_body_size: 789, + 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: 'GET', + statusCode: 200, + response: { + size: 789, + headers: { + 'content-length': '789', + }, + }, + }, + description: 'http://localhost:7654/foo', + endTimestamp: expect.any(Number), + op: 'resource.xhr', + startTimestamp: expect.any(Number), + }, + ]); + }, +); + +sentryTest('captures response size without Content-Length header', 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({ + userNames: ['John', 'Jane'], + }), + headers: { + '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); + + await page.evaluate(() => { + /* eslint-disable */ + const xhr = new XMLHttpRequest(); + + xhr.open('GET', 'http://localhost:7654/foo'); + xhr.send(); + + 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: 'GET', + response_body_size: 29, + 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: 'GET', + statusCode: 200, + response: { + size: 29, + headers: {}, + }, + }, + description: 'http://localhost:7654/foo', + endTimestamp: expect.any(Number), + op: 'resource.xhr', + startTimestamp: expect.any(Number), + }, + ]); +}); + +sentryTest('captures response size for non-string bodies', 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-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); + + await page.evaluate(() => { + /* eslint-disable */ + const xhr = new XMLHttpRequest(); + + xhr.open('POST', 'http://localhost:7654/foo'); + xhr.send(); + + 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', + 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, + response: { + size: 24, + headers: {}, + }, + }, + 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 deleted file mode 100644 index 0db738a467e0..000000000000 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/contentLengthHeader/test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { expect } from '@playwright/test'; - -import { sentryTest } from '../../../../../utils/fixtures'; -import { envelopeRequestParser, waitForErrorRequest } from '../../../../../utils/helpers'; -import { - getCustomRecordingEvents, - shouldSkipReplayTest, - waitForReplayRequest, -} from '../../../../../utils/replayHelpers'; - -sentryTest( - 'parses response_body_size from Content-Length header if available', - 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({ - userNames: ['John', 'Jane'], - }), - headers: { - 'Content-Type': 'application/json', - 'Content-Length': '789', - }, - }); - }); - - 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(); - - xhr.open('GET', 'http://localhost:7654/foo'); - xhr.setRequestHeader('Accept', 'application/json'); - xhr.setRequestHeader('Content-Type', 'application/json'); - xhr.setRequestHeader('Cache', 'no-cache'); - xhr.send(); - - 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: 'GET', - response_body_size: 789, - 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: 'GET', - statusCode: 200, - response: { size: 789 }, - }, - 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 deleted file mode 100644 index 18284361f17a..000000000000 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/noContentLengthHeader/test.ts +++ /dev/null @@ -1,99 +0,0 @@ -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 response_body_size without Content-Length header', - 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({ - userNames: ['John', 'Jane'], - }), - 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); - - await page.evaluate(() => { - /* eslint-disable */ - const xhr = new XMLHttpRequest(); - - xhr.open('GET', 'http://localhost:7654/foo'); - xhr.setRequestHeader('Accept', 'application/json'); - xhr.setRequestHeader('Content-Type', 'application/json'); - xhr.setRequestHeader('Cache', 'no-cache'); - xhr.send(); - - 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: 'GET', - response_body_size: 29, - 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: 'GET', - statusCode: 200, - response: { size: 29 }, - }, - description: 'http://localhost:7654/foo', - endTimestamp: expect.any(Number), - op: 'resource.xhr', - startTimestamp: expect.any(Number), - }, - ]); - }, -); diff --git a/packages/browser-integration-tests/utils/replayEventTemplates.ts b/packages/browser-integration-tests/utils/replayEventTemplates.ts index 2c4b887032e0..be194e9a2bb6 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, - request: { size: 3 }, - response: { size: 11 }, + request: { size: 3, headers: {} }, + response: { size: 11, headers: { 'content-length': '11', 'content-type': 'application/json' } }, }, }; @@ -169,7 +169,7 @@ export const expectedXHRPerformanceSpan = { data: { method: 'GET', statusCode: 200, - response: { size: 11 }, + response: { size: 11, headers: { 'content-length': '11', 'content-type': 'application/json' } }, }, }; diff --git a/packages/replay/src/coreHandlers/handleNetworkBreadcrumbs.ts b/packages/replay/src/coreHandlers/handleNetworkBreadcrumbs.ts index 62351494f86d..0e3940271115 100644 --- a/packages/replay/src/coreHandlers/handleNetworkBreadcrumbs.ts +++ b/packages/replay/src/coreHandlers/handleNetworkBreadcrumbs.ts @@ -8,16 +8,15 @@ import type { } from '@sentry/types'; import { addInstrumentationHandler, logger } from '@sentry/utils'; -import type { FetchHint, ReplayContainer, XhrHint } from '../types'; +import type { FetchHint, ReplayContainer, ReplayNetworkOptions, XhrHint } from '../types'; import { handleFetchSpanListener } from './handleFetch'; import { handleXhrSpanListener } from './handleXhr'; import { captureFetchBreadcrumbToReplay, enrichFetchBreadcrumb } from './util/fetchUtils'; import { captureXhrBreadcrumbToReplay, enrichXhrBreadcrumb } from './util/xhrUtils'; -interface ExtendedNetworkBreadcrumbsOptions { +interface ExtendedNetworkBreadcrumbsOptions extends ReplayNetworkOptions { replay: ReplayContainer; textEncoder: TextEncoderInternal; - captureBodies: boolean; } /** @@ -35,7 +34,7 @@ export function handleNetworkBreadcrumbs(replay: ReplayContainer): void { const options: ExtendedNetworkBreadcrumbsOptions = { replay, textEncoder, - captureBodies: replay.getOptions()._experiments.captureNetworkBodies || false, + ...replay.getExperimentalOptions().network, }; if (client && client.on) { diff --git a/packages/replay/src/coreHandlers/util/fetchUtils.ts b/packages/replay/src/coreHandlers/util/fetchUtils.ts index 1cdcf92f34af..1946efd44f07 100644 --- a/packages/replay/src/coreHandlers/util/fetchUtils.ts +++ b/packages/replay/src/coreHandlers/util/fetchUtils.ts @@ -5,12 +5,14 @@ import type { FetchHint, NetworkBody, ReplayContainer, + ReplayNetworkOptions, ReplayNetworkRequestData, ReplayNetworkRequestOrResponse, } from '../../types'; import { addNetworkBreadcrumb } from './addNetworkBreadcrumb'; import { buildNetworkRequestOrResponse, + getAllowedHeaders, getBodySize, getBodyString, getNetworkBody, @@ -25,7 +27,10 @@ import { export async function captureFetchBreadcrumbToReplay( breadcrumb: Breadcrumb & { data: FetchBreadcrumbData }, hint: FetchHint, - options: { captureBodies: boolean; textEncoder: TextEncoderInternal; replay: ReplayContainer }, + options: ReplayNetworkOptions & { + textEncoder: TextEncoderInternal; + replay: ReplayContainer; + }, ): Promise { try { const data = await _prepareFetchData(breadcrumb, hint, options); @@ -52,6 +57,7 @@ export function enrichFetchBreadcrumb( const body = _getFetchRequestArgBody(input); const reqSize = getBodySize(body, options.textEncoder); + const resSize = response ? parseContentLengthHeader(response.headers.get('content-length')) : undefined; if (reqSize !== undefined) { @@ -65,7 +71,9 @@ export function enrichFetchBreadcrumb( async function _prepareFetchData( breadcrumb: Breadcrumb & { data: FetchBreadcrumbData }, hint: FetchHint, - options: { captureBodies: boolean; textEncoder: TextEncoderInternal }, + options: ReplayNetworkOptions & { + textEncoder: TextEncoderInternal; + }, ): Promise { const { startTimestamp, endTimestamp } = hint; @@ -92,27 +100,37 @@ async function _prepareFetchData( } function _getRequestInfo( - { captureBodies }: { captureBodies: boolean }, + { captureBodies, requestHeaders }: ReplayNetworkOptions, input: FetchHint['input'], requestBodySize?: number, ): ReplayNetworkRequestOrResponse | undefined { + const headers = getRequestHeaders(input, requestHeaders); + if (!captureBodies) { - return buildNetworkRequestOrResponse(requestBodySize, undefined); + return buildNetworkRequestOrResponse(headers, 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); + return buildNetworkRequestOrResponse(headers, requestBodySize, body); } async function _getResponseInfo( - { captureBodies, textEncoder }: { captureBodies: boolean; textEncoder: TextEncoderInternal }, + { + captureBodies, + textEncoder, + responseHeaders, + }: ReplayNetworkOptions & { + textEncoder: TextEncoderInternal; + }, response: Response, responseBodySize?: number, ): Promise { + const headers = getAllHeaders(response.headers, responseHeaders); + if (!captureBodies && responseBodySize !== undefined) { - return buildNetworkRequestOrResponse(responseBodySize, undefined); + return buildNetworkRequestOrResponse(headers, responseBodySize, undefined); } // Only clone the response if we need to @@ -127,13 +145,13 @@ async function _getResponseInfo( : responseBodySize; if (captureBodies) { - return buildNetworkRequestOrResponse(size, body); + return buildNetworkRequestOrResponse(headers, size, body); } - return buildNetworkRequestOrResponse(size, undefined); + return buildNetworkRequestOrResponse(headers, size, undefined); } catch { // fallback - return buildNetworkRequestOrResponse(responseBodySize, undefined); + return buildNetworkRequestOrResponse(headers, responseBodySize, undefined); } } @@ -166,3 +184,53 @@ function _getFetchRequestArgBody(fetchArgs: unknown[] = []): RequestInit['body'] return (fetchArgs[1] as RequestInit).body; } + +function getAllHeaders(headers: Headers, allowedHeaders: string[]): Record { + const allHeaders: Record = {}; + + allowedHeaders.forEach(header => { + if (headers.get(header)) { + allHeaders[header] = headers.get(header) as string; + } + }); + + return allHeaders; +} + +function getRequestHeaders(fetchArgs: unknown[], allowedHeaders: string[]): Record { + if (fetchArgs.length === 1 && typeof fetchArgs[0] !== 'string') { + return getHeadersFromOptions(fetchArgs[0] as Request | RequestInit, allowedHeaders); + } + + if (fetchArgs.length === 2) { + return getHeadersFromOptions(fetchArgs[1] as Request | RequestInit, allowedHeaders); + } + + return {}; +} + +function getHeadersFromOptions( + input: Request | RequestInit | undefined, + allowedHeaders: string[], +): Record { + if (!input) { + return {}; + } + + const headers = input.headers; + + if (!headers) { + return {}; + } + + if (headers instanceof Headers) { + return getAllHeaders(headers, allowedHeaders); + } + + // We do not support this, as it is not really documented (anymore?) + if (Array.isArray(headers)) { + return {}; + } + + return getAllowedHeaders(headers, allowedHeaders); +} diff --git a/packages/replay/src/coreHandlers/util/networkUtils.ts b/packages/replay/src/coreHandlers/util/networkUtils.ts index 4c5271ddf30d..498fb0960c30 100644 --- a/packages/replay/src/coreHandlers/util/networkUtils.ts +++ b/packages/replay/src/coreHandlers/util/networkUtils.ts @@ -120,20 +120,29 @@ export function getNetworkBody(bodyText: string | undefined): NetworkBody | unde /** Build the request or response part of a replay network breadcrumb. */ export function buildNetworkRequestOrResponse( + headers: Record, bodySize: number | undefined, body: NetworkBody | undefined, ): ReplayNetworkRequestOrResponse | undefined { - if (!bodySize) { + if (!bodySize && Object.keys(headers).length === 0) { return undefined; } + if (!bodySize) { + return { + headers, + }; + } + if (!body) { return { + headers, size: bodySize, }; } const info: ReplayNetworkRequestOrResponse = { + headers, size: bodySize, }; @@ -148,6 +157,18 @@ export function buildNetworkRequestOrResponse( return info; } +/** Filter a set of headers */ +export function getAllowedHeaders(headers: Record, allowedHeaders: string[]): Record { + return Object.keys(headers).reduce((filteredHeaders: Record, key: string) => { + const normalizedKey = key.toLowerCase(); + // Avoid putting empty strings into the headers + if (allowedHeaders.includes(normalizedKey) && headers[key]) { + filteredHeaders[normalizedKey] = headers[key]; + } + return filteredHeaders; + }, {}); +} + 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' diff --git a/packages/replay/src/coreHandlers/util/xhrUtils.ts b/packages/replay/src/coreHandlers/util/xhrUtils.ts index efd881d85ece..b39148592476 100644 --- a/packages/replay/src/coreHandlers/util/xhrUtils.ts +++ b/packages/replay/src/coreHandlers/util/xhrUtils.ts @@ -1,10 +1,11 @@ import type { Breadcrumb, TextEncoderInternal, XhrBreadcrumbData } from '@sentry/types'; -import { logger } from '@sentry/utils'; +import { logger, SENTRY_XHR_DATA_KEY } from '@sentry/utils'; -import type { ReplayContainer, ReplayNetworkRequestData, XhrHint } from '../../types'; +import type { ReplayContainer, ReplayNetworkOptions, ReplayNetworkRequestData, XhrHint } from '../../types'; import { addNetworkBreadcrumb } from './addNetworkBreadcrumb'; import { buildNetworkRequestOrResponse, + getAllowedHeaders, getBodySize, getBodyString, getNetworkBody, @@ -19,7 +20,7 @@ import { export async function captureXhrBreadcrumbToReplay( breadcrumb: Breadcrumb & { data: XhrBreadcrumbData }, hint: XhrHint, - options: { replay: ReplayContainer; captureBodies: boolean }, + options: ReplayNetworkOptions & { replay: ReplayContainer }, ): Promise { try { const data = _prepareXhrData(breadcrumb, hint, options); @@ -60,9 +61,9 @@ export function enrichXhrBreadcrumb( function _prepareXhrData( breadcrumb: Breadcrumb & { data: XhrBreadcrumbData }, hint: XhrHint, - options: { captureBodies: boolean }, + options: ReplayNetworkOptions, ): ReplayNetworkRequestData | null { - const { startTimestamp, endTimestamp, input } = hint; + const { startTimestamp, endTimestamp, input, xhr } = hint; const { url, @@ -72,15 +73,21 @@ function _prepareXhrData( response_body_size: responseBodySize, } = breadcrumb.data; + const xhrInfo = xhr[SENTRY_XHR_DATA_KEY]; + const requestHeaders = xhrInfo ? getAllowedHeaders(xhrInfo.request_headers, options.requestHeaders) : {}; + const responseHeaders = getAllowedHeaders(getResponseHeaders(xhr), options.responseHeaders); + if (!url) { return null; } const request = buildNetworkRequestOrResponse( + requestHeaders, requestBodySize, options.captureBodies ? getNetworkBody(getBodyString(input)) : undefined, ); const response = buildNetworkRequestOrResponse( + responseHeaders, responseBodySize, options.captureBodies ? getNetworkBody(hint.xhr.responseText) : undefined, ); @@ -95,3 +102,17 @@ function _prepareXhrData( response, }; } + +function getResponseHeaders(xhr: XMLHttpRequest): Record { + const headers = xhr.getAllResponseHeaders(); + + if (!headers) { + return {}; + } + + return headers.split('\r\n').reduce((acc: Record, line: string) => { + const [key, value] = line.split(': '); + acc[key.toLowerCase()] = value; + return acc; + }, {}); +} diff --git a/packages/replay/src/replay.ts b/packages/replay/src/replay.ts index 4c6ff1d07bd8..62bb32f3c995 100644 --- a/packages/replay/src/replay.ts +++ b/packages/replay/src/replay.ts @@ -18,6 +18,7 @@ import type { PopEventContext, RecordingOptions, ReplayContainer as ReplayContainerInterface, + ReplayExperimentalPluginOptions, ReplayPluginOptions, Session, Timeouts, @@ -63,6 +64,8 @@ export class ReplayContainer implements ReplayContainerInterface { maxSessionLife: MAX_SESSION_LIFE, } as const; + private readonly _experimentalOptions: ReplayExperimentalPluginOptions; + /** * Options to pass to `rrweb.record()` */ @@ -125,6 +128,8 @@ export class ReplayContainer implements ReplayContainerInterface { this._debouncedFlush = debounce(() => this._flush(), this._options.flushMinDelay, { maxWait: this._options.flushMaxDelay, }); + + this._experimentalOptions = _getExperimentalOptions(options); } /** Get the event context. */ @@ -147,6 +152,15 @@ export class ReplayContainer implements ReplayContainerInterface { return this._options; } + /** + * Get the experimental options. + * THIS IS INTERNAL AND SUBJECT TO CHANGE! + * @hidden + */ + public getExperimentalOptions(): ReplayExperimentalPluginOptions { + return this._experimentalOptions; + } + /** * Initializes the plugin. * @@ -854,3 +868,20 @@ export class ReplayContainer implements ReplayContainerInterface { return true; }; } + +function _getExperimentalOptions(options: ReplayPluginOptions): ReplayExperimentalPluginOptions { + const requestHeaders = options._experiments.captureRequestHeaders || []; + const responseHeaders = options._experiments.captureResponseHeaders || []; + const captureBodies = options._experiments.captureNetworkBodies || false; + + // Add defaults + const defaultHeaders = ['content-length', 'content-type', 'accept']; + + return { + network: { + captureBodies, + requestHeaders: [...defaultHeaders, ...requestHeaders.map(header => header.toLowerCase())], + responseHeaders: [...defaultHeaders, ...responseHeaders.map(header => header.toLowerCase())], + }, + }; +} diff --git a/packages/replay/src/types.ts b/packages/replay/src/types.ts index 6f2db385ed1c..98b7ebc4063a 100644 --- a/packages/replay/src/types.ts +++ b/packages/replay/src/types.ts @@ -243,9 +243,32 @@ export interface ReplayPluginOptions extends SessionOptions { mutationLimit: number; mutationBreadcrumbLimit: number; captureNetworkBodies: boolean; + captureRequestHeaders: string[]; + captureResponseHeaders: string[]; }>; } +export interface ReplayNetworkOptions { + /** + * If request & response bodies should be captured. + */ + captureBodies: boolean; + + /** + * Capture the following request headers, in addition to the default ones. + */ + requestHeaders: string[]; + + /** + * Capture the following response headers, in addition to the default ones. + */ + responseHeaders: string[]; +} + +export interface ReplayExperimentalPluginOptions { + network: ReplayNetworkOptions; +} + export interface ReplayIntegrationPrivacyOptions { /** * Mask text content for elements that match the CSS selectors in the list. @@ -445,6 +468,7 @@ export interface ReplayContainer { triggerUserActivity(): void; addUpdate(cb: AddUpdateCallback): void; getOptions(): ReplayPluginOptions; + getExperimentalOptions(): ReplayExperimentalPluginOptions; getSessionId(): string | undefined; checkAndHandleExpiredSession(): boolean | void; setInitialState(): void; @@ -499,6 +523,7 @@ interface NetworkMeta { export interface ReplayNetworkRequestOrResponse { size?: number; body?: NetworkBody; + headers: Record; _meta?: NetworkMeta; } diff --git a/packages/replay/test/unit/coreHandlers/handleNetworkBreadcrumbs.test.ts b/packages/replay/test/unit/coreHandlers/handleNetworkBreadcrumbs.test.ts index 6a5f344cbdc0..809d3717f023 100644 --- a/packages/replay/test/unit/coreHandlers/handleNetworkBreadcrumbs.test.ts +++ b/packages/replay/test/unit/coreHandlers/handleNetworkBreadcrumbs.test.ts @@ -2,16 +2,18 @@ import type { Breadcrumb, BreadcrumbHint, FetchBreadcrumbHint, + SentryWrappedXMLHttpRequest, TextEncoderInternal, XhrBreadcrumbHint, } from '@sentry/types'; +import { SENTRY_XHR_DATA_KEY } from '@sentry/utils'; import { TextEncoder } from 'util'; import { BASE_TIMESTAMP } from '../..'; 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 type { ReplayContainer, ReplayNetworkOptions } from '../../../src/types'; import { setupReplayContainer } from '../../utils/setupReplayContainer'; jest.useFakeTimers(); @@ -25,12 +27,33 @@ async function waitForReplayEventBuffer() { const LARGE_BODY = 'a'.repeat(NETWORK_BODY_MAX_SIZE + 1); +function getMockResponse(contentLength?: string, body?: string, headers?: Record): Response { + const internalHeaders: Record = { + ...(contentLength !== undefined ? { 'content-length': `${contentLength}` } : {}), + ...headers, + }; + + const response = { + headers: { + has: (prop: string) => { + return !!internalHeaders[prop?.toLowerCase() ?? '']; + }, + get: (prop: string) => { + return internalHeaders[prop?.toLowerCase() ?? '']; + }, + }, + clone: () => response, + text: () => Promise.resolve(body), + } as unknown as Response; + + return response; +} + describe('Unit | coreHandlers | handleNetworkBreadcrumbs', () => { describe('beforeAddNetworkBreadcrumb()', () => { - let options: { + let options: ReplayNetworkOptions & { replay: ReplayContainer; textEncoder: TextEncoderInternal; - captureBodies: boolean; }; beforeEach(() => { @@ -40,6 +63,8 @@ describe('Unit | coreHandlers | handleNetworkBreadcrumbs', () => { textEncoder: new TextEncoder(), replay: setupReplayContainer(), captureBodies: false, + requestHeaders: ['content-type', 'accept', 'x-custom-header'], + responseHeaders: ['content-type', 'accept', 'x-custom-header'], }; jest.runAllTimers(); @@ -84,10 +109,19 @@ describe('Unit | coreHandlers | handleNetworkBreadcrumbs', () => { status_code: 200, }, }; - const xhr = new XMLHttpRequest(); + const xhr = new XMLHttpRequest() as XMLHttpRequest & SentryWrappedXMLHttpRequest; Object.defineProperty(xhr, 'response', { value: 'test response', }); + xhr[SENTRY_XHR_DATA_KEY] = { + request_headers: { + 'content-type': 'text/plain', + 'other-header': 'test', + }, + }; + xhr.getAllResponseHeaders = () => `content-type: application/json\r +accept: application/json\r +other-header: test`; const hint: XhrBreadcrumbHint = { xhr, input: 'test input', @@ -121,9 +155,16 @@ describe('Unit | coreHandlers | handleNetworkBreadcrumbs', () => { statusCode: 200, request: { size: 10, + headers: { + 'content-type': 'text/plain', + }, }, response: { size: 13, + headers: { + accept: 'application/json', + 'content-type': 'application/json', + }, }, }, description: 'https://example.com', @@ -194,14 +235,17 @@ describe('Unit | coreHandlers | handleNetworkBreadcrumbs', () => { }, }; - const mockResponse = { - headers: { - get: () => '13', - }, - } as unknown as Response; + const mockResponse = getMockResponse('13', undefined, { + 'content-type': 'application/json', + accept: 'application/json', + 'other-header': 'test', + }); const hint: FetchBreadcrumbHint = { - input: ['GET', { body: 'test input' }], + input: [ + 'GET', + { body: 'test input', headers: { 'content-type': 'text/plain', other: 'header here', accept: 'text/plain' } }, + ], response: mockResponse, startTimestamp: BASE_TIMESTAMP + 1000, endTimestamp: BASE_TIMESTAMP + 2000, @@ -232,9 +276,17 @@ describe('Unit | coreHandlers | handleNetworkBreadcrumbs', () => { method: 'GET', request: { size: 10, + headers: { + 'content-type': 'text/plain', + accept: 'text/plain', + }, }, response: { size: 13, + headers: { + 'content-type': 'application/json', + accept: 'application/json', + }, }, statusCode: 200, }, @@ -257,11 +309,7 @@ describe('Unit | coreHandlers | handleNetworkBreadcrumbs', () => { }, }; - const mockResponse = { - headers: { - get: () => '', - }, - } as unknown as Response; + const mockResponse = getMockResponse(); const hint: FetchBreadcrumbHint = { input: [], @@ -310,13 +358,7 @@ describe('Unit | coreHandlers | handleNetworkBreadcrumbs', () => { }, }; - const mockResponse = { - headers: { - get: () => '', - }, - clone: () => mockResponse, - text: () => Promise.resolve('test response'), - } as unknown as Response; + const mockResponse = getMockResponse('', 'test response'); const hint: FetchBreadcrumbHint = { input: [], @@ -347,6 +389,7 @@ describe('Unit | coreHandlers | handleNetworkBreadcrumbs', () => { statusCode: 200, response: { size: 13, + headers: {}, }, }, description: 'https://example.com', @@ -371,13 +414,7 @@ describe('Unit | coreHandlers | handleNetworkBreadcrumbs', () => { }, }; - const mockResponse = { - headers: { - get: () => '13', - }, - clone: () => mockResponse, - text: () => Promise.resolve('test response'), - } as unknown as Response; + const mockResponse = getMockResponse('13', 'test response'); const hint: FetchBreadcrumbHint = { input: ['GET', { body: 'test input' }], @@ -412,10 +449,12 @@ describe('Unit | coreHandlers | handleNetworkBreadcrumbs', () => { statusCode: 200, request: { size: 10, + headers: {}, body: 'test input', }, response: { size: 13, + headers: {}, body: 'test response', }, }, @@ -441,13 +480,7 @@ describe('Unit | coreHandlers | handleNetworkBreadcrumbs', () => { }, }; - const mockResponse = { - headers: { - get: () => '', - }, - clone: () => mockResponse, - text: () => Promise.resolve('{"this":"is","json":true}'), - } as unknown as Response; + const mockResponse = getMockResponse('', '{"this":"is","json":true}'); const hint: FetchBreadcrumbHint = { input: ['GET', { body: '{"that":"is","json":true}' }], @@ -481,10 +514,12 @@ describe('Unit | coreHandlers | handleNetworkBreadcrumbs', () => { statusCode: 200, request: { size: 25, + headers: {}, body: { that: 'is', json: true }, }, response: { size: 25, + headers: {}, body: { this: 'is', json: true }, }, }, @@ -510,13 +545,7 @@ describe('Unit | coreHandlers | handleNetworkBreadcrumbs', () => { }, }; - const mockResponse = { - headers: { - get: () => '', - }, - clone: () => mockResponse, - text: () => Promise.resolve(''), - } as unknown as Response; + const mockResponse = getMockResponse('', ''); const hint: FetchBreadcrumbHint = { input: ['GET', { body: undefined }], @@ -570,13 +599,7 @@ describe('Unit | coreHandlers | handleNetworkBreadcrumbs', () => { }, }; - const mockResponse = { - headers: { - get: () => '', - }, - clone: () => mockResponse, - text: () => Promise.resolve(LARGE_BODY), - } as unknown as Response; + const mockResponse = getMockResponse('', LARGE_BODY); const hint: FetchBreadcrumbHint = { input: ['GET', { body: LARGE_BODY }], @@ -610,12 +633,14 @@ describe('Unit | coreHandlers | handleNetworkBreadcrumbs', () => { statusCode: 200, request: { size: LARGE_BODY.length, + headers: {}, _meta: { errors: ['MAX_BODY_SIZE_EXCEEDED'], }, }, response: { size: LARGE_BODY.length, + headers: {}, _meta: { errors: ['MAX_BODY_SIZE_EXCEEDED'], }, @@ -682,10 +707,12 @@ describe('Unit | coreHandlers | handleNetworkBreadcrumbs', () => { statusCode: 200, request: { size: 10, + headers: {}, body: 'test input', }, response: { size: 13, + headers: {}, body: 'test response', }, }, @@ -750,10 +777,12 @@ describe('Unit | coreHandlers | handleNetworkBreadcrumbs', () => { statusCode: 200, request: { size: 25, + headers: {}, body: { that: 'is', json: true }, }, response: { size: 25, + headers: {}, body: { this: 'is', json: true }, }, }, @@ -876,12 +905,14 @@ describe('Unit | coreHandlers | handleNetworkBreadcrumbs', () => { statusCode: 200, request: { size: LARGE_BODY.length, + headers: {}, _meta: { errors: ['MAX_BODY_SIZE_EXCEEDED'], }, }, response: { size: LARGE_BODY.length, + headers: {}, _meta: { errors: ['MAX_BODY_SIZE_EXCEEDED'], }, diff --git a/packages/utils/src/instrument.ts b/packages/utils/src/instrument.ts index 81de75b02f8d..be0f8f49cedc 100644 --- a/packages/utils/src/instrument.ts +++ b/packages/utils/src/instrument.ts @@ -306,7 +306,7 @@ function instrumentXHR(): void { const xhrInfo = this[SENTRY_XHR_DATA_KEY]; if (xhrInfo) { - xhrInfo.request_headers[header] = value; + xhrInfo.request_headers[header.toLowerCase()] = value; } return original.apply(this, setRequestHeaderArgs);