From b4fb3d79ee307a966e29298f4bb6d828fb6e5b68 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Mon, 24 Apr 2023 15:42:27 +0200 Subject: [PATCH] feat(replay): Allow to configure URLs to capture network bodies/headers --- .../fetch/captureRequestBody/init.js | 6 +- .../fetch/captureRequestBody/test.ts | 517 ++++++++++-------- .../fetch/captureRequestHeaders/init.js | 6 +- .../fetch/captureRequestHeaders/test.ts | 88 +++ .../fetch/captureRequestSize/test.ts | 28 +- .../fetch/captureResponseBody/init.js | 6 +- .../fetch/captureResponseBody/test.ts | 497 ++++++++++------- .../fetch/captureResponseHeaders/init.js | 6 +- .../fetch/captureResponseHeaders/test.ts | 82 +++ .../fetch/captureResponseSize/test.ts | 42 +- .../xhr/captureRequestBody/init.js | 7 +- .../xhr/captureRequestBody/test.ts | 502 ++++++++++------- .../xhr/captureRequestHeaders/init.js | 6 +- .../xhr/captureRequestHeaders/test.ts | 94 ++++ .../xhr/captureRequestSize/test.ts | 18 + .../xhr/captureResponseBody/init.js | 6 +- .../xhr/captureResponseBody/test.ts | 397 ++++++++------ .../xhr/captureResponseHeaders/init.js | 6 +- .../xhr/captureResponseHeaders/test.ts | 97 ++++ .../xhr/captureResponseSize/test.ts | 29 +- .../utils/replayEventTemplates.ts | 30 +- .../coreHandlers/handleNetworkBreadcrumbs.ts | 8 +- .../src/coreHandlers/util/fetchUtils.ts | 39 +- .../src/coreHandlers/util/networkUtils.ts | 18 +- .../replay/src/coreHandlers/util/xhrUtils.ts | 38 +- packages/replay/src/integration.ts | 16 + packages/replay/src/replay.ts | 31 -- packages/replay/src/types.ts | 62 ++- .../handleNetworkBreadcrumbs.test.ts | 320 ++++++++++- .../replay/test/utils/setupReplayContainer.ts | 4 + rollup/plugins/bundlePlugins.js | 2 + 31 files changed, 2065 insertions(+), 943 deletions(-) diff --git a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureRequestBody/init.js b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureRequestBody/init.js index ff7729968b4e..15be2bb2764d 100644 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureRequestBody/init.js +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureRequestBody/init.js @@ -4,9 +4,9 @@ window.Sentry = Sentry; window.Replay = new Sentry.Replay({ flushMinDelay: 200, flushMaxDelay: 200, - _experiments: { - captureNetworkBodies: true, - }, + + networkDetailAllowUrls: ['http://localhost:7654/foo'], + networkCaptureBodies: true, }); Sentry.init({ 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 index 0f9bd5dfae59..b5c81b4b1b6c 100644 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureRequestBody/test.ts +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureRequestBody/test.ts @@ -8,252 +8,327 @@ import { 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' }), - }); - }); +sentryTest('captures text request body', async ({ getLocalTestPath, page, browserName }) => { + if (shouldSkipReplayTest()) { + sentryTest.skip(); + } - const requestPromise = waitForErrorRequest(page); - const replayRequestPromise1 = waitForReplayRequest(page, 0); + const additionalHeaders = browserName === 'webkit' ? { 'content-type': 'text/plain' } : undefined; - 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 */ + await page.route('**/foo', route => { + return route.fulfill({ + status: 200, }); + }); - 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', + 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', - 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, + statusCode: 200, + request: { + size: 10, + headers: {}, + body: 'input body', }, - description: 'http://localhost:7654/foo', - endTimestamp: expect.any(Number), - op: 'resource.fetch', - startTimestamp: expect.any(Number), + response: additionalHeaders ? { headers: additionalHeaders } : undefined, }, - ]); - }, -); - -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, - }); + description: 'http://localhost:7654/foo', + endTimestamp: expect.any(Number), + op: 'resource.fetch', + startTimestamp: expect.any(Number), + }, + ]); +}); + +sentryTest('captures JSON request body', 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' }), - }); + 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 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'); }); - - 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', + /* 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', - 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, + statusCode: 200, + request: { + size: 13, + headers: {}, + body: { foo: 'bar' }, }, - description: 'http://localhost:7654/foo', - endTimestamp: expect.any(Number), - op: 'resource.fetch', - startTimestamp: expect.any(Number), + response: additionalHeaders ? { headers: additionalHeaders } : undefined, }, - ]); - }, -); - -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, - }); + description: 'http://localhost:7654/foo', + endTimestamp: expect.any(Number), + op: 'resource.fetch', + startTimestamp: expect.any(Number), + }, + ]); +}); + +sentryTest('captures 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', 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' }), - }); + 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 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'); }); - - 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', + /* 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', - request_body_size: 16, - status_code: 200, - url: 'http://localhost:7654/foo', + 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), + }, + ]); +}); + +sentryTest('does not capture request body when URL does not match', async ({ getLocalTestPath, page }) => { + if (shouldSkipReplayTest()) { + sentryTest.skip(); + } + + await page.route('**/bar', route => { + return route.fulfill({ + status: 200, }); + }); - 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', + 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/bar', { + 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/bar', + }, + }); + + 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: {}, + _meta: { + warnings: ['URL_SKIPPED'], + }, + }, + response: { + headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], }, - response: additionalHeaders ? { headers: additionalHeaders } : undefined, }, - description: 'http://localhost:7654/foo', - endTimestamp: expect.any(Number), - op: 'resource.fetch', - startTimestamp: expect.any(Number), }, - ]); - }, -); + description: 'http://localhost:7654/bar', + endTimestamp: expect.any(Number), + op: 'resource.fetch', + startTimestamp: expect.any(Number), + }, + ]); +}); diff --git a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureRequestHeaders/init.js b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureRequestHeaders/init.js index ce0f253a910d..a60fcdcfc530 100644 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureRequestHeaders/init.js +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureRequestHeaders/init.js @@ -4,9 +4,9 @@ window.Sentry = Sentry; window.Replay = new Sentry.Replay({ flushMinDelay: 200, flushMaxDelay: 200, - _experiments: { - captureRequestHeaders: ['X-Test-Header'], - }, + + networkDetailAllowUrls: ['http://localhost:7654/foo'], + networkRequestHeaders: ['X-Test-Header'], }); Sentry.init({ 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 index 64ae5133b559..4b1b1d882eb2 100644 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureRequestHeaders/test.ts +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureRequestHeaders/test.ts @@ -329,3 +329,91 @@ sentryTest('captures request headers as Headers instance', async ({ getLocalTest }, ]); }); + +sentryTest('does not captures request headers if URL does not match', async ({ getLocalTestPath, page }) => { + if (shouldSkipReplayTest()) { + sentryTest.skip(); + } + + await page.route('**/bar', 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/bar', { + 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/bar', + }, + }); + + 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: {}, + _meta: { + warnings: ['URL_SKIPPED'], + }, + }, + response: { + headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], + }, + }, + }, + description: 'http://localhost:7654/bar', + 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 index 9aeb5615f628..712210558176 100644 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureRequestSize/test.ts +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureRequestSize/test.ts @@ -8,13 +8,11 @@ import { waitForReplayRequest, } from '../../../../../utils/replayHelpers'; -sentryTest('captures request body size when body is sent', async ({ getLocalTestPath, page, browserName }) => { +sentryTest('captures request body size when body is sent', async ({ getLocalTestPath, page }) => { if (shouldSkipReplayTest()) { sentryTest.skip(); } - const additionalHeaders = browserName === 'webkit' ? { 'content-type': 'text/plain' } : undefined; - await page.route('**/foo', route => { return route.fulfill({ status: 200, @@ -75,8 +73,16 @@ sentryTest('captures request body size when body is sent', async ({ getLocalTest request: { size: 13, headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], + }, + }, + response: { + headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], + }, }, - response: additionalHeaders ? { headers: additionalHeaders } : undefined, }, description: 'http://localhost:7654/foo', endTimestamp: expect.any(Number), @@ -86,13 +92,11 @@ sentryTest('captures request body size when body is sent', async ({ getLocalTest ]); }); -sentryTest('captures request size from non-text request body', async ({ getLocalTestPath, page, browserName }) => { +sentryTest('captures request size from non-text request body', async ({ getLocalTestPath, page }) => { if (shouldSkipReplayTest()) { sentryTest.skip(); } - const additionalHeaders = browserName === 'webkit' ? { 'content-type': 'text/plain' } : undefined; - await page.route('**/foo', async route => { return route.fulfill({ status: 200, @@ -155,8 +159,16 @@ sentryTest('captures request size from non-text request body', async ({ getLocal request: { size: 26, headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], + }, + }, + response: { + headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], + }, }, - response: additionalHeaders ? { headers: additionalHeaders } : undefined, }, description: 'http://localhost:7654/foo', endTimestamp: expect.any(Number), diff --git a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureResponseBody/init.js b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureResponseBody/init.js index ff7729968b4e..15be2bb2764d 100644 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureResponseBody/init.js +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureResponseBody/init.js @@ -4,9 +4,9 @@ window.Sentry = Sentry; window.Replay = new Sentry.Replay({ flushMinDelay: 200, flushMaxDelay: 200, - _experiments: { - captureNetworkBodies: true, - }, + + networkDetailAllowUrls: ['http://localhost:7654/foo'], + networkCaptureBodies: true, }); Sentry.init({ 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 index 6f0fc2c4d4f2..1741b6a19803 100644 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureResponseBody/test.ts +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureResponseBody/test.ts @@ -8,254 +8,329 @@ import { 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' }), - }); - }); +sentryTest('captures text response body', async ({ getLocalTestPath, page, browserName }) => { + if (shouldSkipReplayTest()) { + sentryTest.skip(); + } - const requestPromise = waitForErrorRequest(page); - const replayRequestPromise1 = waitForReplayRequest(page, 0); + const additionalHeaders = browserName === 'webkit' ? { 'content-type': 'text/plain' } : undefined; - const url = await getLocalTestPath({ testDir: __dirname }); - await page.goto(url); + await page.route('**/foo', route => { + return route.fulfill({ + status: 200, + body: 'response body', + }); + }); - 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 */ + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), }); + }); - const request = await requestPromise; - const eventData = envelopeRequestParser(request); + const requestPromise = waitForErrorRequest(page); + const replayRequestPromise1 = waitForReplayRequest(page, 0); - expect(eventData.exception?.values).toHaveLength(1); + const url = await getLocalTestPath({ testDir: __dirname }); + await page.goto(url); - expect(eventData?.breadcrumbs?.length).toBe(1); - expect(eventData!.breadcrumbs![0]).toEqual({ - timestamp: expect.any(Number), - category: 'fetch', - type: 'http', + 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', - 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', + 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' }), - }); + description: 'http://localhost:7654/foo', + endTimestamp: expect.any(Number), + op: 'resource.fetch', + startTimestamp: expect.any(Number), + }, + ]); +}); + +sentryTest('captures JSON response body', 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' }), - }); + 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 requestPromise = waitForErrorRequest(page); + const replayRequestPromise1 = waitForReplayRequest(page, 0); - const url = await getLocalTestPath({ testDir: __dirname }); - await page.goto(url); + 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 */ + await page.evaluate(() => { + /* eslint-disable */ + fetch('http://localhost:7654/foo', { + method: 'POST', + }).then(() => { + // @ts-ignore Sentry is a global + Sentry.captureException('test error'); }); - - 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', + /* 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', - 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' }, + 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'), - }); + description: 'http://localhost:7654/foo', + endTimestamp: expect.any(Number), + op: 'resource.fetch', + startTimestamp: expect.any(Number), + }, + ]); +}); + +sentryTest('captures 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', 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' }), - }); + 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 requestPromise = waitForErrorRequest(page); + const replayRequestPromise1 = waitForReplayRequest(page, 0); - const url = await getLocalTestPath({ testDir: __dirname }); - await page.goto(url); + const url = await getLocalTestPath({ testDir: __dirname }); + await page.goto(url); - await page.evaluate(() => { - /* eslint-disable */ - fetch('http://localhost:7654/foo', { + 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', - }).then(() => { - // @ts-ignore Sentry is a global - Sentry.captureException('test error'); - }); - /* eslint-enable */ + 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), + }, + ]); +}); + +sentryTest('does not capture response body when URL does not match', async ({ getLocalTestPath, page }) => { + if (shouldSkipReplayTest()) { + sentryTest.skip(); + } + + await page.route('**/bar', 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 request = await requestPromise; - const eventData = envelopeRequestParser(request); + const requestPromise = waitForErrorRequest(page); + const replayRequestPromise1 = waitForReplayRequest(page, 0); - expect(eventData.exception?.values).toHaveLength(1); + const url = await getLocalTestPath({ testDir: __dirname }); + await page.goto(url); - expect(eventData?.breadcrumbs?.length).toBe(1); - expect(eventData!.breadcrumbs![0]).toEqual({ - timestamp: expect.any(Number), - category: 'fetch', - type: 'http', + await page.evaluate(() => { + /* eslint-disable */ + fetch('http://localhost:7654/bar', { + 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/bar', + }, + }); + + const replayReq1 = await replayRequestPromise1; + const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); + expect(performanceSpans1.filter(span => span.op === 'resource.fetch')).toEqual([ + { 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', + statusCode: 200, + request: { + headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], + }, + }, + response: { + size: 13, + headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], }, }, - description: 'http://localhost:7654/foo', - endTimestamp: expect.any(Number), - op: 'resource.fetch', - startTimestamp: expect.any(Number), }, - ]); - }, -); + description: 'http://localhost:7654/bar', + 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 index e675af70ba91..241dcc7adc29 100644 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureResponseHeaders/init.js +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureResponseHeaders/init.js @@ -4,9 +4,9 @@ window.Sentry = Sentry; window.Replay = new Sentry.Replay({ flushMinDelay: 200, flushMaxDelay: 200, - _experiments: { - captureResponseHeaders: ['X-Test-Header'], - }, + + networkDetailAllowUrls: ['http://localhost:7654/foo'], + networkResponseHeaders: ['X-Test-Header'], }); Sentry.init({ 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 index 177dfa8efd57..b377e1667ee6 100644 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureResponseHeaders/test.ts +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureResponseHeaders/test.ts @@ -158,3 +158,85 @@ sentryTest('captures response headers', async ({ getLocalTestPath, page }) => { }, ]); }); + +sentryTest('does not capture response headers if URL does not match', async ({ getLocalTestPath, page }) => { + if (shouldSkipReplayTest()) { + sentryTest.skip(); + } + + await page.route('**/bar', 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/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: 'GET', + status_code: 200, + url: 'http://localhost:7654/bar', + }, + }); + + const replayReq1 = await replayRequestPromise1; + const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); + expect(performanceSpans1.filter(span => span.op === 'resource.fetch')).toEqual([ + { + data: { + method: 'GET', + statusCode: 200, + request: { + headers: {}, + _meta: { warnings: ['URL_SKIPPED'] }, + }, + response: { + headers: {}, + _meta: { warnings: ['URL_SKIPPED'] }, + }, + }, + description: 'http://localhost:7654/bar', + 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 index eeb51d16ee9a..3604f270ec23 100644 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureResponseSize/test.ts +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/fetch/captureResponseSize/test.ts @@ -74,12 +74,18 @@ sentryTest('captures response size from Content-Length header if available', asy data: { method: 'GET', statusCode: 200, - response: { - headers: { - 'content-length': '789', - 'content-type': 'application/json', + request: { + headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], }, + }, + response: { size: 789, + headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], + }, }, }, description: 'http://localhost:7654/foo', @@ -156,11 +162,18 @@ sentryTest('captures response size without Content-Length header', async ({ getL data: { method: 'GET', statusCode: 200, - response: { - headers: { - 'content-type': 'application/json', + request: { + headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], }, + }, + response: { size: 29, + headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], + }, }, }, description: 'http://localhost:7654/foo', @@ -171,13 +184,11 @@ sentryTest('captures response size without Content-Length header', async ({ getL ]); }); -sentryTest('captures response size from non-text response body', async ({ getLocalTestPath, page, browserName }) => { +sentryTest('captures response size from non-text response body', async ({ getLocalTestPath, page }) => { if (shouldSkipReplayTest()) { sentryTest.skip(); } - const additionalHeaders = browserName === 'webkit' ? { 'content-type': 'application/octet-stream' } : {}; - await page.route('**/foo', async route => { return route.fulfill({ status: 200, @@ -237,10 +248,17 @@ sentryTest('captures response size from non-text response body', async ({ getLoc data: { method: 'POST', statusCode: 200, + request: { + headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], + }, + }, response: { size: 24, - headers: { - ...additionalHeaders, + headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], }, }, }, diff --git a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureRequestBody/init.js b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureRequestBody/init.js index de48fc6febee..15be2bb2764d 100644 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureRequestBody/init.js +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureRequestBody/init.js @@ -4,10 +4,9 @@ window.Sentry = Sentry; window.Replay = new Sentry.Replay({ flushMinDelay: 200, flushMaxDelay: 200, - _experiments: { - captureNetworkBodies: true, - captureResponseHeaders: ['X-Test-Header'], - }, + + networkDetailAllowUrls: ['http://localhost:7654/foo'], + networkCaptureBodies: true, }); Sentry.init({ 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 index 567990fe55fb..0d8b8579939e 100644 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureRequestBody/test.ts +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureRequestBody/test.ts @@ -8,258 +8,338 @@ import { 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, - }); +sentryTest('captures 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', 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' }), - }); + 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 requestPromise = waitForErrorRequest(page); + const replayRequestPromise1 = waitForReplayRequest(page, 0); - const url = await getLocalTestPath({ testDir: __dirname }); - await page.goto(url); + const url = await getLocalTestPath({ testDir: __dirname }); + await page.goto(url); - void page.evaluate(() => { - /* eslint-disable */ - const xhr = new XMLHttpRequest(); + void page.evaluate(() => { + /* eslint-disable */ + const xhr = new XMLHttpRequest(); - xhr.open('POST', 'http://localhost:7654/foo'); - xhr.send('input body'); + 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 */ + xhr.addEventListener('readystatechange', function () { + if (xhr.readyState === 4) { + // @ts-ignore Sentry is a global + setTimeout(() => Sentry.captureException('test error', 0)); + } }); - - 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', + /* 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', - 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', - }, + 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, - }); + description: 'http://localhost:7654/foo', + endTimestamp: expect.any(Number), + op: 'resource.xhr', + startTimestamp: expect.any(Number), + }, + ]); +}); + +sentryTest('captures JSON 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', 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' }), - }); + 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 requestPromise = waitForErrorRequest(page); + const replayRequestPromise1 = waitForReplayRequest(page, 0); - const url = await getLocalTestPath({ testDir: __dirname }); - await page.goto(url); + const url = await getLocalTestPath({ testDir: __dirname }); + await page.goto(url); - void page.evaluate(() => { - /* eslint-disable */ - const xhr = new XMLHttpRequest(); + void page.evaluate(() => { + /* eslint-disable */ + const xhr = new XMLHttpRequest(); - xhr.open('POST', 'http://localhost:7654/foo'); - xhr.send('{"foo":"bar"}'); + 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 */ + xhr.addEventListener('readystatechange', function () { + if (xhr.readyState === 4) { + // @ts-ignore Sentry is a global + setTimeout(() => Sentry.captureException('test error', 0)); + } }); - - 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', + /* 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', - 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' }, - }, + 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, - }); + description: 'http://localhost:7654/foo', + endTimestamp: expect.any(Number), + op: 'resource.xhr', + startTimestamp: expect.any(Number), + }, + ]); +}); + +sentryTest('captures 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' }), - }); + 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 requestPromise = waitForErrorRequest(page); + const replayRequestPromise1 = waitForReplayRequest(page, 0); - const url = await getLocalTestPath({ testDir: __dirname }); - await page.goto(url); + const url = await getLocalTestPath({ testDir: __dirname }); + await page.goto(url); - await page.evaluate(() => { - /* eslint-disable */ - const xhr = new XMLHttpRequest(); + await page.evaluate(() => { + /* eslint-disable */ + const xhr = new XMLHttpRequest(); - const body = new URLSearchParams(); - body.append('name', 'Anne'); - body.append('age', '32'); + const body = new URLSearchParams(); + body.append('name', 'Anne'); + body.append('age', '32'); - xhr.open('POST', 'http://localhost:7654/foo'); - xhr.send(body); + 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 */ + xhr.addEventListener('readystatechange', function () { + if (xhr.readyState === 4) { + // @ts-ignore Sentry is a global + setTimeout(() => Sentry.captureException('test error', 0)); + } }); - - 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', + /* 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', - request_body_size: 16, - status_code: 200, - url: 'http://localhost:7654/foo', + 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), + }, + ]); +}); + +sentryTest('does not capture request body when URL does not match', async ({ getLocalTestPath, page, browserName }) => { + // These are a bit flaky on non-chromium browsers + if (shouldSkipReplayTest() || browserName !== 'chromium') { + sentryTest.skip(); + } + + await page.route('**/bar', 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); - 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', + void page.evaluate(() => { + /* eslint-disable */ + const xhr = new XMLHttpRequest(); + + xhr.open('POST', 'http://localhost:7654/bar'); + 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/bar', + }, + }); + + 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: {}, + _meta: { + warnings: ['URL_SKIPPED'], + }, + }, + response: { + headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], }, }, - description: 'http://localhost:7654/foo', - endTimestamp: expect.any(Number), - op: 'resource.xhr', - startTimestamp: expect.any(Number), }, - ]); - }, -); + description: 'http://localhost:7654/bar', + endTimestamp: expect.any(Number), + op: 'resource.xhr', + startTimestamp: expect.any(Number), + }, + ]); +}); diff --git a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureRequestHeaders/init.js b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureRequestHeaders/init.js index ce0f253a910d..a60fcdcfc530 100644 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureRequestHeaders/init.js +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureRequestHeaders/init.js @@ -4,9 +4,9 @@ window.Sentry = Sentry; window.Replay = new Sentry.Replay({ flushMinDelay: 200, flushMaxDelay: 200, - _experiments: { - captureRequestHeaders: ['X-Test-Header'], - }, + + networkDetailAllowUrls: ['http://localhost:7654/foo'], + networkRequestHeaders: ['X-Test-Header'], }); Sentry.init({ diff --git a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureRequestHeaders/test.ts b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureRequestHeaders/test.ts index 9be377b9ea84..3a341f9df8a6 100644 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureRequestHeaders/test.ts +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureRequestHeaders/test.ts @@ -93,3 +93,97 @@ sentryTest('captures request headers', async ({ getLocalTestPath, page, browserN }, ]); }); + +sentryTest( + 'does not capture request headers if URL does not match', + async ({ getLocalTestPath, page, browserName }) => { + // These are a bit flaky on non-chromium browsers + if (shouldSkipReplayTest() || browserName !== 'chromium') { + sentryTest.skip(); + } + + await page.route('**/bar', 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/bar'); + xhr.setRequestHeader('Accept', 'application/json'); + xhr.setRequestHeader('Content-Type', 'application/json'); + xhr.setRequestHeader('Cache', 'no-cache'); + xhr.setRequestHeader('X-Test-Header', 'test-value'); + 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', + status_code: 200, + url: 'http://localhost:7654/bar', + }, + }); + + const replayReq1 = await replayRequestPromise1; + const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); + expect(performanceSpans1.filter(span => span.op === 'resource.xhr')).toEqual([ + { + data: { + method: 'POST', + statusCode: 200, + request: { + headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], + }, + }, + response: { + headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], + }, + }, + }, + description: 'http://localhost:7654/bar', + endTimestamp: expect.any(Number), + op: 'resource.xhr', + startTimestamp: 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 index 9d30f3aab3a1..83461fd61486 100644 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureRequestSize/test.ts +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureRequestSize/test.ts @@ -78,6 +78,15 @@ sentryTest('captures request body size when body is sent', async ({ getLocalTest request: { size: 13, headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], + }, + }, + response: { + headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], + }, }, }, description: 'http://localhost:7654/foo', @@ -160,6 +169,15 @@ sentryTest('captures request size from non-text request body', async ({ getLocal request: { size: 26, headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], + }, + }, + response: { + headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], + }, }, }, description: 'http://localhost:7654/foo', diff --git a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureResponseBody/init.js b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureResponseBody/init.js index ff7729968b4e..15be2bb2764d 100644 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureResponseBody/init.js +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureResponseBody/init.js @@ -4,9 +4,9 @@ window.Sentry = Sentry; window.Replay = new Sentry.Replay({ flushMinDelay: 200, flushMaxDelay: 200, - _experiments: { - captureNetworkBodies: true, - }, + + networkDetailAllowUrls: ['http://localhost:7654/foo'], + networkCaptureBodies: true, }); Sentry.init({ 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 index 0bfc4374faf1..4e0eb915f98a 100644 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureResponseBody/test.ts +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureResponseBody/test.ts @@ -8,194 +8,273 @@ import { 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': '', - }, - }); +sentryTest('captures text response body', 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' }), - }); + 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 requestPromise = waitForErrorRequest(page); + const replayRequestPromise1 = waitForReplayRequest(page, 0); - const url = await getLocalTestPath({ testDir: __dirname }); - await page.goto(url); + const url = await getLocalTestPath({ testDir: __dirname }); + await page.goto(url); - void page.evaluate(() => { - /* eslint-disable */ - const xhr = new XMLHttpRequest(); + void page.evaluate(() => { + /* eslint-disable */ + const xhr = new XMLHttpRequest(); - xhr.open('POST', 'http://localhost:7654/foo'); - xhr.send(); + 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 */ + xhr.addEventListener('readystatechange', function () { + if (xhr.readyState === 4) { + // @ts-ignore Sentry is a global + setTimeout(() => Sentry.captureException('test error', 0)); + } }); - - 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', + /* 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', - response_body_size: 13, - status_code: 200, - url: 'http://localhost:7654/foo', + 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 response body', 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': '', }, }); + }); - 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), - }, - ]); - }, -); + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); + }); -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(); - } + const requestPromise = waitForErrorRequest(page); + const replayRequestPromise1 = waitForReplayRequest(page, 0); - await page.route('**/foo', route => { - return route.fulfill({ - status: 200, - body: JSON.stringify({ res: 'this' }), - headers: { - 'Content-Length': '', + 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 response 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, + 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' }), - }); + 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 requestPromise = waitForErrorRequest(page); + const replayRequestPromise1 = waitForReplayRequest(page, 0); - const url = await getLocalTestPath({ testDir: __dirname }); - await page.goto(url); + const url = await getLocalTestPath({ testDir: __dirname }); + await page.goto(url); - void page.evaluate(() => { - /* eslint-disable */ - const xhr = new XMLHttpRequest(); + await page.evaluate(() => { + /* eslint-disable */ + const xhr = new XMLHttpRequest(); - xhr.open('POST', 'http://localhost:7654/foo'); - xhr.send(); + 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 */ + xhr.addEventListener('readystatechange', function () { + if (xhr.readyState === 4) { + // @ts-ignore Sentry is a global + setTimeout(() => Sentry.captureException('test error', 0)); + } }); - - 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', + /* 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', - 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' }, - }, + 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), }, - ]); - }, -); + 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', + 'does not capture response body when URL does not match', 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('**/bar', route => { return route.fulfill({ status: 200, - body: Buffer.from('Hello world'), + body: 'response body', headers: { 'Content-Length': '', }, @@ -216,11 +295,11 @@ sentryTest( const url = await getLocalTestPath({ testDir: __dirname }); await page.goto(url); - await page.evaluate(() => { + void page.evaluate(() => { /* eslint-disable */ const xhr = new XMLHttpRequest(); - xhr.open('POST', 'http://localhost:7654/foo'); + xhr.open('POST', 'http://localhost:7654/bar'); xhr.send(); xhr.addEventListener('readystatechange', function () { @@ -244,9 +323,9 @@ sentryTest( type: 'http', data: { method: 'POST', - response_body_size: 24, + response_body_size: 13, status_code: 200, - url: 'http://localhost:7654/foo', + url: 'http://localhost:7654/bar', }, }); @@ -257,13 +336,21 @@ sentryTest( data: { method: 'POST', statusCode: 200, + request: { + headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], + }, + }, response: { - size: 24, + size: 13, headers: {}, - body: 'Hello world', + _meta: { + warnings: ['URL_SKIPPED'], + }, }, }, - description: 'http://localhost:7654/foo', + description: 'http://localhost:7654/bar', 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 index e675af70ba91..241dcc7adc29 100644 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureResponseHeaders/init.js +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureResponseHeaders/init.js @@ -4,9 +4,9 @@ window.Sentry = Sentry; window.Replay = new Sentry.Replay({ flushMinDelay: 200, flushMaxDelay: 200, - _experiments: { - captureResponseHeaders: ['X-Test-Header'], - }, + + networkDetailAllowUrls: ['http://localhost:7654/foo'], + networkResponseHeaders: ['X-Test-Header'], }); Sentry.init({ diff --git a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureResponseHeaders/test.ts b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureResponseHeaders/test.ts index 5b34368c488f..ac80334663d8 100644 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureResponseHeaders/test.ts +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureResponseHeaders/test.ts @@ -96,3 +96,100 @@ sentryTest('captures response headers', async ({ getLocalTestPath, page, browser }, ]); }); + +sentryTest( + 'does not capture response headers if URL does not match', + async ({ getLocalTestPath, page, browserName }) => { + // These are a bit flaky on non-chromium browsers + if (shouldSkipReplayTest() || browserName !== 'chromium') { + sentryTest.skip(); + } + + await page.route('**/bar', 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 */ + const xhr = new XMLHttpRequest(); + + xhr.open('GET', 'http://localhost:7654/bar'); + 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', + status_code: 200, + url: 'http://localhost:7654/bar', + }, + }); + + const replayReq1 = await replayRequestPromise1; + const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1); + expect(performanceSpans1.filter(span => span.op === 'resource.xhr')).toEqual([ + { + data: { + method: 'GET', + statusCode: 200, + request: { + headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], + }, + }, + response: { + headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], + }, + }, + }, + description: 'http://localhost:7654/bar', + endTimestamp: expect.any(Number), + op: 'resource.xhr', + startTimestamp: 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 index 3467d1b5f04c..cf3de69d8fd4 100644 --- a/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureResponseSize/test.ts +++ b/packages/browser-integration-tests/suites/replay/extendNetworkBreadcrumbs/xhr/captureResponseSize/test.ts @@ -80,10 +80,17 @@ sentryTest( data: { method: 'GET', statusCode: 200, + request: { + headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], + }, + }, response: { size: 789, - headers: { - 'content-length': '789', + headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], }, }, }, @@ -169,9 +176,18 @@ sentryTest('captures response size without Content-Length header', async ({ getL data: { method: 'GET', statusCode: 200, + request: { + headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], + }, + }, response: { size: 29, headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], + }, }, }, description: 'http://localhost:7654/foo', @@ -253,9 +269,18 @@ sentryTest('captures response size for non-string bodies', async ({ getLocalTest data: { method: 'POST', statusCode: 200, + request: { + headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], + }, + }, response: { size: 24, headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], + }, }, }, description: 'http://localhost:7654/foo', diff --git a/packages/browser-integration-tests/utils/replayEventTemplates.ts b/packages/browser-integration-tests/utils/replayEventTemplates.ts index be194e9a2bb6..e6ee4bda18d2 100644 --- a/packages/browser-integration-tests/utils/replayEventTemplates.ts +++ b/packages/browser-integration-tests/utils/replayEventTemplates.ts @@ -156,8 +156,20 @@ export const expectedFetchPerformanceSpan = { data: { method: 'POST', statusCode: 200, - request: { size: 3, headers: {} }, - response: { size: 11, headers: { 'content-length': '11', 'content-type': 'application/json' } }, + request: { + size: 3, + headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], + }, + }, + response: { + size: 11, + headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], + }, + }, }, }; @@ -169,7 +181,19 @@ export const expectedXHRPerformanceSpan = { data: { method: 'GET', statusCode: 200, - response: { size: 11, headers: { 'content-length': '11', 'content-type': 'application/json' } }, + request: { + headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], + }, + }, + response: { + size: 11, + headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], + }, + }, }, }; diff --git a/packages/replay/src/coreHandlers/handleNetworkBreadcrumbs.ts b/packages/replay/src/coreHandlers/handleNetworkBreadcrumbs.ts index 0e3940271115..59324031e336 100644 --- a/packages/replay/src/coreHandlers/handleNetworkBreadcrumbs.ts +++ b/packages/replay/src/coreHandlers/handleNetworkBreadcrumbs.ts @@ -31,10 +31,16 @@ export function handleNetworkBreadcrumbs(replay: ReplayContainer): void { try { const textEncoder = new TextEncoder(); + const { networkDetailAllowUrls, networkCaptureBodies, networkRequestHeaders, networkResponseHeaders } = + replay.getOptions(); + const options: ExtendedNetworkBreadcrumbsOptions = { replay, textEncoder, - ...replay.getExperimentalOptions().network, + networkDetailAllowUrls, + networkCaptureBodies, + networkRequestHeaders, + networkResponseHeaders, }; if (client && client.on) { diff --git a/packages/replay/src/coreHandlers/util/fetchUtils.ts b/packages/replay/src/coreHandlers/util/fetchUtils.ts index b2f7a1ac72d7..9411332d0197 100644 --- a/packages/replay/src/coreHandlers/util/fetchUtils.ts +++ b/packages/replay/src/coreHandlers/util/fetchUtils.ts @@ -11,11 +11,13 @@ import type { import { addNetworkBreadcrumb } from './addNetworkBreadcrumb'; import { buildNetworkRequestOrResponse, + buildSkippedNetworkRequestOrResponse, getAllowedHeaders, getBodySize, getBodyString, makeNetworkReplayBreadcrumb, parseContentLengthHeader, + urlMatches, } from './networkUtils'; /** @@ -78,33 +80,37 @@ async function _prepareFetchData( const { url, method, - status_code: statusCode, + status_code: statusCode = 0, request_body_size: requestBodySize, response_body_size: responseBodySize, } = breadcrumb.data; - const request = _getRequestInfo(options, hint.input, requestBodySize); - const response = await _getResponseInfo(options, hint.response, responseBodySize); + const captureDetails = urlMatches(url, options.networkDetailAllowUrls); + + const request = captureDetails + ? _getRequestInfo(options, hint.input, requestBodySize) + : buildSkippedNetworkRequestOrResponse(requestBodySize); + const response = await _getResponseInfo(captureDetails, options, hint.response, responseBodySize); return { startTimestamp, endTimestamp, url, method, - statusCode: statusCode || 0, + statusCode, request, response, }; } function _getRequestInfo( - { captureBodies, requestHeaders }: ReplayNetworkOptions, + { networkCaptureBodies, networkRequestHeaders }: ReplayNetworkOptions, input: FetchHint['input'], requestBodySize?: number, ): ReplayNetworkRequestOrResponse | undefined { - const headers = getRequestHeaders(input, requestHeaders); + const headers = getRequestHeaders(input, networkRequestHeaders); - if (!captureBodies) { + if (!networkCaptureBodies) { return buildNetworkRequestOrResponse(headers, requestBodySize, undefined); } @@ -115,19 +121,24 @@ function _getRequestInfo( } async function _getResponseInfo( + captureDetails: boolean, { - captureBodies, + networkCaptureBodies, textEncoder, - responseHeaders, + networkResponseHeaders, }: ReplayNetworkOptions & { textEncoder: TextEncoderInternal; }, response: Response, responseBodySize?: number, ): Promise { - const headers = getAllHeaders(response.headers, responseHeaders); + if (!captureDetails && responseBodySize !== undefined) { + return buildSkippedNetworkRequestOrResponse(responseBodySize); + } + + const headers = getAllHeaders(response.headers, networkResponseHeaders); - if (!captureBodies && responseBodySize !== undefined) { + if (!networkCaptureBodies && responseBodySize !== undefined) { return buildNetworkRequestOrResponse(headers, responseBodySize, undefined); } @@ -142,7 +153,11 @@ async function _getResponseInfo( ? getBodySize(bodyText, textEncoder) : responseBodySize; - if (captureBodies) { + if (!captureDetails) { + return buildSkippedNetworkRequestOrResponse(size); + } + + if (networkCaptureBodies) { return buildNetworkRequestOrResponse(headers, size, bodyText); } diff --git a/packages/replay/src/coreHandlers/util/networkUtils.ts b/packages/replay/src/coreHandlers/util/networkUtils.ts index 79515bf36ff4..8ee1a87b7b6d 100644 --- a/packages/replay/src/coreHandlers/util/networkUtils.ts +++ b/packages/replay/src/coreHandlers/util/networkUtils.ts @@ -1,5 +1,5 @@ import type { TextEncoderInternal } from '@sentry/types'; -import { dropUndefinedKeys } from '@sentry/utils'; +import { dropUndefinedKeys, stringMatchesSomePattern } from '@sentry/utils'; import { NETWORK_BODY_MAX_SIZE } from '../../constants'; import type { @@ -120,6 +120,17 @@ export function getNetworkBody(bodyText: string | undefined): NetworkBody | unde return bodyText; } +/** Build the request or response part of a replay network breadcrumb that was skipped. */ +export function buildSkippedNetworkRequestOrResponse(bodySize: number | undefined): ReplayNetworkRequestOrResponse { + return { + headers: {}, + size: bodySize, + _meta: { + warnings: ['URL_SKIPPED'], + }, + }; +} + /** Build the request or response part of a replay network breadcrumb. */ export function buildNetworkRequestOrResponse( headers: Record, @@ -220,3 +231,8 @@ function _strIsProbablyJson(str: string): boolean { // Simple check: If this does not start & end with {} or [], it's not JSON return (first === '[' && last === ']') || (first === '{' && last === '}'); } + +/** Match an URL against a list of strings/Regex. */ +export function urlMatches(url: string, urls: (string | RegExp)[]): boolean { + return stringMatchesSomePattern(url, urls); +} diff --git a/packages/replay/src/coreHandlers/util/xhrUtils.ts b/packages/replay/src/coreHandlers/util/xhrUtils.ts index b241bd945771..dfb1ccaf3ffc 100644 --- a/packages/replay/src/coreHandlers/util/xhrUtils.ts +++ b/packages/replay/src/coreHandlers/util/xhrUtils.ts @@ -5,11 +5,13 @@ import type { ReplayContainer, ReplayNetworkOptions, ReplayNetworkRequestData, X import { addNetworkBreadcrumb } from './addNetworkBreadcrumb'; import { buildNetworkRequestOrResponse, + buildSkippedNetworkRequestOrResponse, getAllowedHeaders, getBodySize, getBodyString, makeNetworkReplayBreadcrumb, parseContentLengthHeader, + urlMatches, } from './networkUtils'; /** @@ -67,28 +69,44 @@ function _prepareXhrData( const { url, method, - status_code: statusCode, + status_code: statusCode = 0, request_body_size: requestBodySize, 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; } + if (!urlMatches(url, options.networkDetailAllowUrls)) { + const request = buildSkippedNetworkRequestOrResponse(requestBodySize); + const response = buildSkippedNetworkRequestOrResponse(responseBodySize); + return { + startTimestamp, + endTimestamp, + url, + method, + statusCode, + request, + response, + }; + } + + const xhrInfo = xhr[SENTRY_XHR_DATA_KEY]; + const networkRequestHeaders = xhrInfo + ? getAllowedHeaders(xhrInfo.request_headers, options.networkRequestHeaders) + : {}; + const networkResponseHeaders = getAllowedHeaders(getResponseHeaders(xhr), options.networkResponseHeaders); + const request = buildNetworkRequestOrResponse( - requestHeaders, + networkRequestHeaders, requestBodySize, - options.captureBodies ? getBodyString(input) : undefined, + options.networkCaptureBodies ? getBodyString(input) : undefined, ); const response = buildNetworkRequestOrResponse( - responseHeaders, + networkResponseHeaders, responseBodySize, - options.captureBodies ? hint.xhr.responseText : undefined, + options.networkCaptureBodies ? hint.xhr.responseText : undefined, ); return { @@ -96,7 +114,7 @@ function _prepareXhrData( endTimestamp, url, method, - statusCode: statusCode || 0, + statusCode, request, response, }; diff --git a/packages/replay/src/integration.ts b/packages/replay/src/integration.ts index cd3f5b28e57c..dfc1eeef7a06 100644 --- a/packages/replay/src/integration.ts +++ b/packages/replay/src/integration.ts @@ -11,6 +11,8 @@ import { isBrowser } from './util/isBrowser'; const MEDIA_SELECTORS = 'img,image,svg,video,object,picture,embed,map,audio,link[rel="icon"],link[rel="apple-touch-icon"]'; +const DEFAULT_NETWORK_HEADERS = ['content-length', 'content-type', 'accept']; + let _initialized = false; type InitialReplayPluginOptions = Omit & @@ -58,6 +60,11 @@ export class Replay implements Integration { maskAllInputs = true, blockAllMedia = true, + networkDetailAllowUrls = [], + networkCaptureBodies = true, + networkRequestHeaders = [], + networkResponseHeaders = [], + mask = [], unmask = [], block = [], @@ -116,6 +123,11 @@ export class Replay implements Integration { errorSampleRate, useCompression, blockAllMedia, + networkDetailAllowUrls, + networkCaptureBodies, + networkRequestHeaders: _getMergedNetworkHeaders(networkRequestHeaders), + networkResponseHeaders: _getMergedNetworkHeaders(networkResponseHeaders), + _experiments, }; @@ -288,3 +300,7 @@ function loadReplayOptionsFromClient(initialOptions: InitialReplayPluginOptions) return finalOptions; } + +function _getMergedNetworkHeaders(headers: string[]): string[] { + return [...DEFAULT_NETWORK_HEADERS, ...headers.map(header => header.toLowerCase())]; +} diff --git a/packages/replay/src/replay.ts b/packages/replay/src/replay.ts index 990ca825266e..f8b890462d84 100644 --- a/packages/replay/src/replay.ts +++ b/packages/replay/src/replay.ts @@ -18,7 +18,6 @@ import type { PopEventContext, RecordingOptions, ReplayContainer as ReplayContainerInterface, - ReplayExperimentalPluginOptions, ReplayPluginOptions, SendBufferedReplayOptions, Session, @@ -65,8 +64,6 @@ export class ReplayContainer implements ReplayContainerInterface { maxSessionLife: MAX_SESSION_LIFE, } as const; - private readonly _experimentalOptions: ReplayExperimentalPluginOptions; - /** * Options to pass to `rrweb.record()` */ @@ -129,8 +126,6 @@ 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. */ @@ -153,15 +148,6 @@ 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. * @@ -902,20 +888,3 @@ 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 684e5442ed9c..e2f4b6d1b71f 100644 --- a/packages/replay/src/types.ts +++ b/packages/replay/src/types.ts @@ -196,6 +196,39 @@ export interface SampleRates { errorSampleRate: number; } +export interface ReplayNetworkOptions { + /** + * Capture request/response details for XHR/Fetch requests that match the given URLs. + * The URLs can be strings or regular expressions. + * When provided a string, we will match any URL that contains the given string. + * You can use a Regex to handle exact matches or more complex matching. + * + * Only URLs matching these patterns will have bodies & additional headers captured. + */ + networkDetailAllowUrls: (string | RegExp)[]; + + /** + * If request & response bodies should be captured. + * Only applies to URLs matched by `networkDetailAllowUrls`. + * Defaults to true. + */ + networkCaptureBodies: boolean; + + /** + * Capture the following request headers, in addition to the default ones. + * Only applies to URLs matched by `networkDetailAllowUrls`. + * Any headers defined here will be captured in addition to the default headers. + */ + networkRequestHeaders: string[]; + + /** + * Capture the following response headers, in addition to the default ones. + * Only applies to URLs matched by `networkDetailAllowUrls`. + * Any headers defined here will be captured in addition to the default headers. + */ + networkResponseHeaders: string[]; +} + /** * Session options that are configurable by the integration configuration */ @@ -207,7 +240,7 @@ export interface SessionOptions extends SampleRates { stickySession: boolean; } -export interface ReplayPluginOptions extends SessionOptions { +export interface ReplayPluginOptions extends SessionOptions, ReplayNetworkOptions { /** * The amount of time to wait before sending a replay */ @@ -242,33 +275,9 @@ export interface ReplayPluginOptions extends SessionOptions { traceInternals: boolean; 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. @@ -473,7 +482,6 @@ export interface ReplayContainer { triggerUserActivity(): void; addUpdate(cb: AddUpdateCallback): void; getOptions(): ReplayPluginOptions; - getExperimentalOptions(): ReplayExperimentalPluginOptions; getSessionId(): string | undefined; checkAndHandleExpiredSession(): boolean | void; setInitialState(): void; @@ -522,7 +530,7 @@ type JsonArray = unknown[]; export type NetworkBody = JsonObject | JsonArray | string; -export type NetworkMetaWarning = 'JSON_TRUNCATED' | 'TEXT_TRUNCATED' | 'INVALID_JSON'; +export type NetworkMetaWarning = 'JSON_TRUNCATED' | 'TEXT_TRUNCATED' | 'INVALID_JSON' | 'URL_SKIPPED'; interface NetworkMeta { warnings?: NetworkMetaWarning[]; diff --git a/packages/replay/test/unit/coreHandlers/handleNetworkBreadcrumbs.test.ts b/packages/replay/test/unit/coreHandlers/handleNetworkBreadcrumbs.test.ts index b47a849f868e..825c6e4eb452 100644 --- a/packages/replay/test/unit/coreHandlers/handleNetworkBreadcrumbs.test.ts +++ b/packages/replay/test/unit/coreHandlers/handleNetworkBreadcrumbs.test.ts @@ -62,9 +62,10 @@ describe('Unit | coreHandlers | handleNetworkBreadcrumbs', () => { options = { textEncoder: new TextEncoder(), replay: setupReplayContainer(), - captureBodies: false, - requestHeaders: ['content-type', 'accept', 'x-custom-header'], - responseHeaders: ['content-type', 'accept', 'x-custom-header'], + networkDetailAllowUrls: ['https://example.com'], + networkCaptureBodies: false, + networkRequestHeaders: ['content-type', 'accept', 'x-custom-header'], + networkResponseHeaders: ['content-type', 'accept', 'x-custom-header'], }; jest.runAllTimers(); @@ -402,8 +403,78 @@ other-header: test`; ]); }); + it('does not add fetch request/response body if URL does not match', async () => { + options.networkCaptureBodies = true; + + const breadcrumb: Breadcrumb = { + category: 'fetch', + data: { + method: 'GET', + url: 'https://example2.com', + status_code: 200, + }, + }; + + const mockResponse = getMockResponse('13', 'test response'); + + const hint: FetchBreadcrumbHint = { + input: ['GET', { body: 'test input' }], + response: mockResponse, + startTimestamp: BASE_TIMESTAMP + 1000, + endTimestamp: BASE_TIMESTAMP + 2000, + }; + beforeAddNetworkBreadcrumb(options, breadcrumb, hint); + + expect(breadcrumb).toEqual({ + category: 'fetch', + data: { + method: 'GET', + request_body_size: 10, + response_body_size: 13, + status_code: 200, + url: 'https://example2.com', + }, + }); + + await waitForReplayEventBuffer(); + + expect((options.replay.eventBuffer as EventBufferArray).events).toEqual([ + { + type: 5, + timestamp: (BASE_TIMESTAMP + 1000) / 1000, + data: { + tag: 'performanceSpan', + payload: { + data: { + method: 'GET', + statusCode: 200, + request: { + size: 10, + headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], + }, + }, + response: { + size: 13, + headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], + }, + }, + }, + description: 'https://example2.com', + endTimestamp: (BASE_TIMESTAMP + 2000) / 1000, + op: 'resource.fetch', + startTimestamp: (BASE_TIMESTAMP + 1000) / 1000, + }, + }, + }, + ]); + }); + it('adds fetch request/response body if configured', async () => { - options.captureBodies = true; + options.networkCaptureBodies = true; const breadcrumb: Breadcrumb = { category: 'fetch', @@ -469,7 +540,7 @@ other-header: test`; }); it('adds fetch request/response body as JSON if configured', async () => { - options.captureBodies = true; + options.networkCaptureBodies = true; const breadcrumb: Breadcrumb = { category: 'fetch', @@ -534,7 +605,7 @@ other-header: test`; }); it('skips fetch request/response body if configured & no body found', async () => { - options.captureBodies = true; + options.networkCaptureBodies = true; const breadcrumb: Breadcrumb = { category: 'fetch', @@ -588,7 +659,7 @@ other-header: test`; }); it('truncates fetch text request/response body if configured & too large', async () => { - options.captureBodies = true; + options.networkCaptureBodies = true; const breadcrumb: Breadcrumb = { category: 'fetch', @@ -659,7 +730,7 @@ other-header: test`; }); it('truncates fetch JSON request/response body if configured & too large', async () => { - options.captureBodies = true; + options.networkCaptureBodies = true; const largeBody = JSON.stringify({ a: LARGE_BODY }); @@ -737,8 +808,82 @@ other-header: test`; ]); }); + it('does not add xhr request/response body if URL does not match', async () => { + options.networkCaptureBodies = true; + + const breadcrumb: Breadcrumb = { + category: 'xhr', + data: { + method: 'GET', + url: 'https://example2.com', + status_code: 200, + }, + }; + const xhr = new XMLHttpRequest(); + Object.defineProperty(xhr, 'response', { + value: 'test response', + }); + Object.defineProperty(xhr, 'responseText', { + value: 'test response', + }); + const hint: XhrBreadcrumbHint = { + xhr, + input: 'test input', + startTimestamp: BASE_TIMESTAMP + 1000, + endTimestamp: BASE_TIMESTAMP + 2000, + }; + beforeAddNetworkBreadcrumb(options, breadcrumb, hint); + + expect(breadcrumb).toEqual({ + category: 'xhr', + data: { + method: 'GET', + request_body_size: 10, + response_body_size: 13, + status_code: 200, + url: 'https://example2.com', + }, + }); + + await waitForReplayEventBuffer(); + + expect((options.replay.eventBuffer as EventBufferArray).events).toEqual([ + { + type: 5, + timestamp: (BASE_TIMESTAMP + 1000) / 1000, + data: { + tag: 'performanceSpan', + payload: { + data: { + method: 'GET', + statusCode: 200, + request: { + size: 10, + headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], + }, + }, + response: { + size: 13, + headers: {}, + _meta: { + warnings: ['URL_SKIPPED'], + }, + }, + }, + description: 'https://example2.com', + endTimestamp: (BASE_TIMESTAMP + 2000) / 1000, + op: 'resource.xhr', + startTimestamp: (BASE_TIMESTAMP + 1000) / 1000, + }, + }, + }, + ]); + }); + it('adds xhr request/response body if configured', async () => { - options.captureBodies = true; + options.networkCaptureBodies = true; const breadcrumb: Breadcrumb = { category: 'xhr', @@ -808,7 +953,7 @@ other-header: test`; }); it('adds xhr JSON request/response body if configured', async () => { - options.captureBodies = true; + options.networkCaptureBodies = true; const breadcrumb: Breadcrumb = { category: 'xhr', @@ -878,7 +1023,7 @@ other-header: test`; }); it('skips xhr request/response body if configured & no body found', async () => { - options.captureBodies = true; + options.networkCaptureBodies = true; const breadcrumb: Breadcrumb = { category: 'xhr', @@ -936,7 +1081,7 @@ other-header: test`; }); it('truncates text xhr request/response body if configured & body too large', async () => { - options.captureBodies = true; + options.networkCaptureBodies = true; const breadcrumb: Breadcrumb = { category: 'xhr', @@ -1012,7 +1157,7 @@ other-header: test`; }); it('truncates JSON xhr request/response body if configured & body too large', async () => { - options.captureBodies = true; + options.networkCaptureBodies = true; const largeBody = JSON.stringify({ a: LARGE_BODY }); @@ -1088,5 +1233,154 @@ other-header: test`; }, ]); }); + + describe.each([ + ['exact string match', 'https://example.com/foo'], + ['partial string match', 'https://example.com/bar/what'], + ['exact regex match', 'http://example.com/exact'], + ['partial regex match', 'http://example.com/partial/string'], + ])('matching URL %s', (_label, url) => { + it('correctly matches URL for fetch request', async () => { + options.networkDetailAllowUrls = [ + 'https://example.com/foo', + 'com/bar', + /^http:\/\/example.com\/exact$/, + /^http:\/\/example.com\/partial/, + ]; + + const breadcrumb: Breadcrumb = { + category: 'fetch', + data: { + method: 'GET', + url, + status_code: 200, + }, + }; + + const mockResponse = getMockResponse('13', 'test response'); + + const hint: FetchBreadcrumbHint = { + input: ['GET', { body: 'test input' }], + response: mockResponse, + startTimestamp: BASE_TIMESTAMP + 1000, + endTimestamp: BASE_TIMESTAMP + 2000, + }; + beforeAddNetworkBreadcrumb(options, breadcrumb, hint); + + expect(breadcrumb).toEqual({ + category: 'fetch', + data: { + method: 'GET', + request_body_size: 10, + response_body_size: 13, + status_code: 200, + url, + }, + }); + + await waitForReplayEventBuffer(); + + expect((options.replay.eventBuffer as EventBufferArray).events).toEqual([ + { + type: 5, + timestamp: (BASE_TIMESTAMP + 1000) / 1000, + data: { + tag: 'performanceSpan', + payload: { + data: { + method: 'GET', + statusCode: 200, + request: { + size: 10, + headers: {}, + }, + response: { + size: 13, + headers: {}, + }, + }, + description: url, + endTimestamp: (BASE_TIMESTAMP + 2000) / 1000, + op: 'resource.fetch', + startTimestamp: (BASE_TIMESTAMP + 1000) / 1000, + }, + }, + }, + ]); + }); + + it('correctly matches URL for xhe request', async () => { + options.networkDetailAllowUrls = [ + 'https://example.com/foo', + 'com/bar', + /^http:\/\/example.com\/exact$/, + /^http:\/\/example.com\/partial/, + ]; + + const breadcrumb: Breadcrumb = { + category: 'xhr', + data: { + method: 'GET', + url, + status_code: 200, + }, + }; + const xhr = new XMLHttpRequest(); + Object.defineProperty(xhr, 'response', { + value: 'test response', + }); + Object.defineProperty(xhr, 'responseText', { + value: 'test response', + }); + const hint: XhrBreadcrumbHint = { + xhr, + input: 'test input', + startTimestamp: BASE_TIMESTAMP + 1000, + endTimestamp: BASE_TIMESTAMP + 2000, + }; + beforeAddNetworkBreadcrumb(options, breadcrumb, hint); + + expect(breadcrumb).toEqual({ + category: 'xhr', + data: { + method: 'GET', + request_body_size: 10, + response_body_size: 13, + status_code: 200, + url, + }, + }); + + await waitForReplayEventBuffer(); + + expect((options.replay.eventBuffer as EventBufferArray).events).toEqual([ + { + type: 5, + timestamp: (BASE_TIMESTAMP + 1000) / 1000, + data: { + tag: 'performanceSpan', + payload: { + data: { + method: 'GET', + statusCode: 200, + request: { + size: 10, + headers: {}, + }, + response: { + size: 13, + headers: {}, + }, + }, + description: url, + endTimestamp: (BASE_TIMESTAMP + 2000) / 1000, + op: 'resource.xhr', + startTimestamp: (BASE_TIMESTAMP + 1000) / 1000, + }, + }, + }, + ]); + }); + }); }); }); diff --git a/packages/replay/test/utils/setupReplayContainer.ts b/packages/replay/test/utils/setupReplayContainer.ts index e6a427e19638..b8c9d71292bd 100644 --- a/packages/replay/test/utils/setupReplayContainer.ts +++ b/packages/replay/test/utils/setupReplayContainer.ts @@ -16,6 +16,10 @@ export function setupReplayContainer({ errorSampleRate: 1, useCompression: false, blockAllMedia: true, + networkDetailAllowUrls: [], + networkCaptureBodies: true, + networkRequestHeaders: [], + networkResponseHeaders: [], _experiments: {}, ...options, }, diff --git a/rollup/plugins/bundlePlugins.js b/rollup/plugins/bundlePlugins.js index f1e3b184cd11..c2b939e3db41 100644 --- a/rollup/plugins/bundlePlugins.js +++ b/rollup/plugins/bundlePlugins.js @@ -127,6 +127,8 @@ export function makeTerserPlugin() { '_cssText', // We want to keep the _integrations variable unmangled to send all installed integrations from replay '_integrations', + // _meta is used to store metadata of replay network events + '_meta', ], }, },