diff --git a/packages/browser-integration-tests/suites/replay/requests/subject.js b/packages/browser-integration-tests/suites/replay/requests/subject.js index 7e5b305eabc3..a58f304fc687 100644 --- a/packages/browser-integration-tests/suites/replay/requests/subject.js +++ b/packages/browser-integration-tests/suites/replay/requests/subject.js @@ -6,7 +6,7 @@ document.getElementById('go-background').addEventListener('click', () => { }); document.getElementById('fetch').addEventListener('click', () => { - fetch('https://example.com'); + fetch('https://example.com', { method: 'POST', body: 'foo' }); }); document.getElementById('xhr').addEventListener('click', () => { diff --git a/packages/browser-integration-tests/utils/replayEventTemplates.ts b/packages/browser-integration-tests/utils/replayEventTemplates.ts index c417624f9b9a..8891f5aaeb6d 100644 --- a/packages/browser-integration-tests/utils/replayEventTemplates.ts +++ b/packages/browser-integration-tests/utils/replayEventTemplates.ts @@ -132,23 +132,26 @@ export const expectedFPPerformanceSpan = { export const expectedFetchPerformanceSpan = { op: 'resource.fetch', - description: expect.any(String), + description: 'https://example.com', startTimestamp: expect.any(Number), endTimestamp: expect.any(Number), data: { - method: expect.any(String), - statusCode: expect.any(Number), + method: 'POST', + statusCode: 200, + responseBodySize: 11, + requestBodySize: 3, }, }; export const expectedXHRPerformanceSpan = { op: 'resource.xhr', - description: expect.any(String), + description: 'https://example.com', startTimestamp: expect.any(Number), endTimestamp: expect.any(Number), data: { - method: expect.any(String), - statusCode: expect.any(Number), + method: 'GET', + statusCode: 200, + responseBodySize: 11, }, }; diff --git a/packages/browser/src/integrations/breadcrumbs.ts b/packages/browser/src/integrations/breadcrumbs.ts index ce79b89ea78d..cfd833ef759a 100644 --- a/packages/browser/src/integrations/breadcrumbs.ts +++ b/packages/browser/src/integrations/breadcrumbs.ts @@ -2,6 +2,12 @@ /* eslint-disable max-lines */ import { getCurrentHub } from '@sentry/core'; import type { Event as SentryEvent, HandlerDataFetch, HandlerDataXhr, Integration } from '@sentry/types'; +import type { + FetchBreadcrumbData, + FetchBreadcrumbHint, + XhrBreadcrumbData, + XhrBreadcrumbHint, +} from '@sentry/types/build/types/breadcrumb'; import { addInstrumentationHandler, getEventDescription, @@ -217,27 +223,35 @@ function _consoleBreadcrumb(handlerData: HandlerData & { args: unknown[]; level: * Creates breadcrumbs from XHR API calls */ function _xhrBreadcrumb(handlerData: HandlerData & HandlerDataXhr): void { + const { startTimestamp, endTimestamp } = handlerData; + // We only capture complete, non-sentry requests - if (!handlerData.endTimestamp || !handlerData.xhr.__sentry_xhr__) { + if (!startTimestamp || !endTimestamp || !handlerData.xhr.__sentry_xhr__) { return; } const { method, url, status_code, body } = handlerData.xhr.__sentry_xhr__; + const data: XhrBreadcrumbData = { + method, + url, + status_code, + }; + + const hint: XhrBreadcrumbHint = { + xhr: handlerData.xhr, + input: body, + startTimestamp, + endTimestamp, + }; + getCurrentHub().addBreadcrumb( { category: 'xhr', - data: { - method, - url, - status_code, - }, + data, type: 'http', }, - { - xhr: handlerData.xhr, - input: body, - }, + hint, ); } @@ -245,8 +259,10 @@ function _xhrBreadcrumb(handlerData: HandlerData & HandlerDataXhr): void { * Creates breadcrumbs from fetch API calls */ function _fetchBreadcrumb(handlerData: HandlerData & HandlerDataFetch & { response?: Response }): void { + const { startTimestamp, endTimestamp } = handlerData; + // We only capture complete fetch requests - if (!handlerData.endTimestamp) { + if (!endTimestamp) { return; } @@ -256,32 +272,41 @@ function _fetchBreadcrumb(handlerData: HandlerData & HandlerDataFetch & { respon } if (handlerData.error) { + const data: FetchBreadcrumbData = handlerData.fetchData; + const hint: FetchBreadcrumbHint = { + data: handlerData.error, + input: handlerData.args, + startTimestamp, + endTimestamp, + }; + getCurrentHub().addBreadcrumb( { category: 'fetch', - data: handlerData.fetchData, + data, level: 'error', type: 'http', }, - { - data: handlerData.error, - input: handlerData.args, - }, + hint, ); } else { + const data: FetchBreadcrumbData = { + ...handlerData.fetchData, + status_code: handlerData.response && handlerData.response.status, + }; + const hint: FetchBreadcrumbHint = { + input: handlerData.args, + response: handlerData.response, + startTimestamp, + endTimestamp, + }; getCurrentHub().addBreadcrumb( { category: 'fetch', - data: { - ...handlerData.fetchData, - status_code: handlerData.response && handlerData.response.status, - }, + data, type: 'http', }, - { - input: handlerData.args, - response: handlerData.response, - }, + hint, ); } } diff --git a/packages/replay/src/coreHandlers/addNetworkBreadcrumb.ts b/packages/replay/src/coreHandlers/addNetworkBreadcrumb.ts new file mode 100644 index 000000000000..19f5a17a1c9f --- /dev/null +++ b/packages/replay/src/coreHandlers/addNetworkBreadcrumb.ts @@ -0,0 +1,26 @@ +import type { ReplayContainer, ReplayPerformanceEntry } from '../types'; +import { createPerformanceSpans } from '../util/createPerformanceSpans'; +import { shouldFilterRequest } from '../util/shouldFilterRequest'; + +/** Add a performance entry breadcrumb */ +export function addNetworkBreadcrumb(replay: ReplayContainer, result: ReplayPerformanceEntry | null): void { + if (!replay.isEnabled()) { + return; + } + + if (result === null) { + return; + } + + if (shouldFilterRequest(replay, result.name)) { + return; + } + + replay.addUpdate(() => { + createPerformanceSpans(replay, [result]); + // Returning true will cause `addUpdate` to not flush + // We do not want network requests to cause a flush. This will prevent + // recurring/polling requests from keeping the replay session alive. + return true; + }); +} diff --git a/packages/replay/src/coreHandlers/handleFetch.ts b/packages/replay/src/coreHandlers/handleFetch.ts index 4ba163d5c5c9..2569706d7c33 100644 --- a/packages/replay/src/coreHandlers/handleFetch.ts +++ b/packages/replay/src/coreHandlers/handleFetch.ts @@ -1,8 +1,7 @@ import type { HandlerDataFetch } from '@sentry/types'; import type { ReplayContainer, ReplayPerformanceEntry } from '../types'; -import { createPerformanceSpans } from '../util/createPerformanceSpans'; -import { shouldFilterRequest } from '../util/shouldFilterRequest'; +import { addNetworkBreadcrumb } from './addNetworkBreadcrumb'; /** only exported for tests */ export function handleFetch(handlerData: HandlerDataFetch): null | ReplayPerformanceEntry { @@ -39,20 +38,6 @@ export function handleFetchSpanListener(replay: ReplayContainer): (handlerData: const result = handleFetch(handlerData); - if (result === null) { - return; - } - - if (shouldFilterRequest(replay, result.name)) { - return; - } - - replay.addUpdate(() => { - createPerformanceSpans(replay, [result]); - // Returning true will cause `addUpdate` to not flush - // We do not want network requests to cause a flush. This will prevent - // recurring/polling requests from keeping the replay session alive. - return true; - }); + addNetworkBreadcrumb(replay, result); }; } diff --git a/packages/replay/src/coreHandlers/extendNetworkBreadcrumbs.ts b/packages/replay/src/coreHandlers/handleNetworkBreadcrumbs.ts similarity index 50% rename from packages/replay/src/coreHandlers/extendNetworkBreadcrumbs.ts rename to packages/replay/src/coreHandlers/handleNetworkBreadcrumbs.ts index bc1acb1c7c72..0ee72f9edc9c 100644 --- a/packages/replay/src/coreHandlers/extendNetworkBreadcrumbs.ts +++ b/packages/replay/src/coreHandlers/handleNetworkBreadcrumbs.ts @@ -2,15 +2,31 @@ import { getCurrentHub } from '@sentry/core'; import type { Breadcrumb, BreadcrumbHint, + FetchBreadcrumbData, + FetchBreadcrumbHint, HandlerDataFetch, SentryWrappedXMLHttpRequest, TextEncoderInternal, + XhrBreadcrumbData, + XhrBreadcrumbHint, } from '@sentry/types'; -import { logger } from '@sentry/utils'; +import { addInstrumentationHandler, logger } from '@sentry/utils'; + +import type { ReplayContainer, ReplayPerformanceEntry } from '../types'; +import { addNetworkBreadcrumb } from './addNetworkBreadcrumb'; +import { handleFetchSpanListener } from './handleFetch'; +import { handleXhrSpanListener } from './handleXhr'; type RequestBody = null | Blob | BufferSource | FormData | URLSearchParams | string; +type XhrHint = XhrBreadcrumbHint & { xhr: XMLHttpRequest & SentryWrappedXMLHttpRequest; input?: RequestBody }; +type FetchHint = FetchBreadcrumbHint & { + input: HandlerDataFetch['args']; + response: Response; +}; + interface ExtendedNetworkBreadcrumbsOptions { + replay: ReplayContainer; textEncoder: TextEncoderInternal; } @@ -23,25 +39,31 @@ interface ExtendedNetworkBreadcrumbsOptions { * * to the breadcrumb data. */ -export function extendNetworkBreadcrumbs(): void { +export function handleNetworkBreadcrumbs(replay: ReplayContainer): void { const client = getCurrentHub().getClient(); try { const textEncoder = new TextEncoder(); const options: ExtendedNetworkBreadcrumbsOptions = { + replay, textEncoder, }; if (client && client.on) { - client.on('beforeAddBreadcrumb', (breadcrumb, hint) => _beforeNetworkBreadcrumb(options, breadcrumb, hint)); + client.on('beforeAddBreadcrumb', (breadcrumb, hint) => handleNetworkBreadcrumb(options, breadcrumb, hint)); + } else { + // Fallback behavior + addInstrumentationHandler('fetch', handleFetchSpanListener(replay)); + addInstrumentationHandler('xhr', handleXhrSpanListener(replay)); } } catch { // Do nothing } } -function _beforeNetworkBreadcrumb( +/** just exported for tests */ +export function handleNetworkBreadcrumb( options: ExtendedNetworkBreadcrumbsOptions, breadcrumb: Breadcrumb, hint?: BreadcrumbHint, @@ -51,40 +73,80 @@ function _beforeNetworkBreadcrumb( } try { - if (breadcrumb.category === 'xhr' && hint && hint.xhr) { - _enrichXhrBreadcrumb( - breadcrumb as Breadcrumb & { data: object }, - { - xhr: hint.xhr as XMLHttpRequest & SentryWrappedXMLHttpRequest, - body: hint.input as RequestBody, - }, - options, - ); + if (_isXhrBreadcrumb(breadcrumb) && _isXhrHint(hint)) { + // Enriches the breadcrumb overall + _enrichXhrBreadcrumb(breadcrumb, hint, options); + + // Create a replay performance entry from this breadcrumb + const result = _makeNetworkReplayBreadcrumb('resource.xhr', breadcrumb, hint); + addNetworkBreadcrumb(options.replay, result); } - if (breadcrumb.category === 'fetch' && hint) { - _enrichFetchBreadcrumb( - breadcrumb as Breadcrumb & { data: object }, - { - input: hint.input as HandlerDataFetch['args'], - response: hint.response as Response, - }, - options, - ); + if (_isFetchBreadcrumb(breadcrumb) && _isFetchHint(hint)) { + // Enriches the breadcrumb overall + _enrichFetchBreadcrumb(breadcrumb, hint, options); + + // Create a replay performance entry from this breadcrumb + const result = _makeNetworkReplayBreadcrumb('resource.fetch', breadcrumb, hint); + addNetworkBreadcrumb(options.replay, result); } } catch (e) { __DEBUG_BUILD__ && logger.warn('Error when enriching network breadcrumb'); } } +function _makeNetworkReplayBreadcrumb( + type: string, + breadcrumb: Breadcrumb & { data: FetchBreadcrumbData | XhrBreadcrumbData }, + hint: FetchBreadcrumbHint | XhrBreadcrumbHint, +): ReplayPerformanceEntry | null { + const { startTimestamp, endTimestamp } = hint; + + if (!endTimestamp) { + return null; + } + + const { + url, + method, + status_code: statusCode, + request_body_size: requestBodySize, + response_body_size: responseBodySize, + } = breadcrumb.data; + + if (url === undefined) { + return null; + } + + const result: ReplayPerformanceEntry & { data: object } = { + type, + start: startTimestamp / 1000, + end: endTimestamp / 1000, + name: url, + data: { + method, + statusCode, + }, + }; + + if (requestBodySize) { + result.data.requestBodySize = requestBodySize; + } + if (responseBodySize) { + result.data.responseBodySize = responseBodySize; + } + + return result; +} + function _enrichXhrBreadcrumb( - breadcrumb: Breadcrumb & { data: object }, - hint: { xhr: XMLHttpRequest & SentryWrappedXMLHttpRequest; body?: RequestBody }, + breadcrumb: Breadcrumb & { data: XhrBreadcrumbData }, + hint: XhrHint, options: ExtendedNetworkBreadcrumbsOptions, ): void { - const { xhr, body } = hint; + const { xhr, input } = hint; - const reqSize = getBodySize(body, options.textEncoder); + const reqSize = getBodySize(input, options.textEncoder); const resSize = xhr.getResponseHeader('content-length') ? parseContentSizeHeader(xhr.getResponseHeader('content-length')) : getBodySize(xhr.response, options.textEncoder); @@ -98,11 +160,8 @@ function _enrichXhrBreadcrumb( } function _enrichFetchBreadcrumb( - breadcrumb: Breadcrumb & { data: object }, - hint: { - input: HandlerDataFetch['args']; - response: Response; - }, + breadcrumb: Breadcrumb & { data: FetchBreadcrumbData }, + hint: FetchHint, options: ExtendedNetworkBreadcrumbsOptions, ): void { const { input, response } = hint; @@ -179,3 +238,19 @@ function getFetchBody(fetchArgs: unknown[] = []): RequestInit['body'] | undefine return (fetchArgs[1] as RequestInit).body; } + +function _isXhrBreadcrumb(breadcrumb: Breadcrumb): breadcrumb is Breadcrumb & { data: XhrBreadcrumbData } { + return breadcrumb.category === 'xhr'; +} + +function _isFetchBreadcrumb(breadcrumb: Breadcrumb): breadcrumb is Breadcrumb & { data: FetchBreadcrumbData } { + return breadcrumb.category === 'fetch'; +} + +function _isXhrHint(hint?: BreadcrumbHint): hint is XhrHint { + return hint && hint.xhr; +} + +function _isFetchHint(hint?: BreadcrumbHint): hint is FetchHint { + return hint && hint.response; +} diff --git a/packages/replay/src/coreHandlers/handleXhr.ts b/packages/replay/src/coreHandlers/handleXhr.ts index 1e39ec409ea8..f78c41fc831e 100644 --- a/packages/replay/src/coreHandlers/handleXhr.ts +++ b/packages/replay/src/coreHandlers/handleXhr.ts @@ -1,8 +1,7 @@ import type { HandlerDataXhr } from '@sentry/types'; import type { ReplayContainer, ReplayPerformanceEntry } from '../types'; -import { createPerformanceSpans } from '../util/createPerformanceSpans'; -import { shouldFilterRequest } from '../util/shouldFilterRequest'; +import { addNetworkBreadcrumb } from './addNetworkBreadcrumb'; /** only exported for tests */ export function handleXhr(handlerData: HandlerDataXhr): ReplayPerformanceEntry | null { @@ -49,20 +48,6 @@ export function handleXhrSpanListener(replay: ReplayContainer): (handlerData: Ha const result = handleXhr(handlerData); - if (result === null) { - return; - } - - if (shouldFilterRequest(replay, result.name)) { - return; - } - - replay.addUpdate(() => { - createPerformanceSpans(replay, [result]); - // Returning true will cause `addUpdate` to not flush - // We do not want network requests to cause a flush. This will prevent - // recurring/polling requests from keeping the replay session alive. - return true; - }); + addNetworkBreadcrumb(replay, result); }; } diff --git a/packages/replay/src/util/addGlobalListeners.ts b/packages/replay/src/util/addGlobalListeners.ts index ea685c49241a..fc68f322d090 100644 --- a/packages/replay/src/util/addGlobalListeners.ts +++ b/packages/replay/src/util/addGlobalListeners.ts @@ -2,14 +2,12 @@ import type { BaseClient } from '@sentry/core'; import { addGlobalEventProcessor, getCurrentHub } from '@sentry/core'; import { addInstrumentationHandler } from '@sentry/utils'; -import { extendNetworkBreadcrumbs } from '../coreHandlers/extendNetworkBreadcrumbs'; import { handleAfterSendEvent } from '../coreHandlers/handleAfterSendEvent'; import { handleDomListener } from '../coreHandlers/handleDom'; -import { handleFetchSpanListener } from '../coreHandlers/handleFetch'; import { handleGlobalEventListener } from '../coreHandlers/handleGlobalEvent'; import { handleHistorySpanListener } from '../coreHandlers/handleHistory'; +import { handleNetworkBreadcrumbs } from '../coreHandlers/handleNetworkBreadcrumbs'; import { handleScopeListener } from '../coreHandlers/handleScope'; -import { handleXhrSpanListener } from '../coreHandlers/handleXhr'; import type { ReplayContainer } from '../types'; /** @@ -24,9 +22,8 @@ export function addGlobalListeners(replay: ReplayContainer): void { scope.addScopeListener(handleScopeListener(replay)); } addInstrumentationHandler('dom', handleDomListener(replay)); - addInstrumentationHandler('fetch', handleFetchSpanListener(replay)); - addInstrumentationHandler('xhr', handleXhrSpanListener(replay)); addInstrumentationHandler('history', handleHistorySpanListener(replay)); + handleNetworkBreadcrumbs(replay); // If a custom client has no hooks yet, we continue to use the "old" implementation const hasHooks = !!(client && client.on); @@ -39,6 +36,4 @@ export function addGlobalListeners(replay: ReplayContainer): void { // eslint-disable-next-line @typescript-eslint/no-explicit-any (client as BaseClient).on('afterSendEvent', handleAfterSendEvent(replay)); } - - extendNetworkBreadcrumbs(); } diff --git a/packages/replay/test/unit/coreHandlers/extendNetworkBreadcrumbs.test.ts b/packages/replay/test/unit/coreHandlers/extendNetworkBreadcrumbs.test.ts deleted file mode 100644 index a8d6885d43c2..000000000000 --- a/packages/replay/test/unit/coreHandlers/extendNetworkBreadcrumbs.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { TextEncoder } from 'util'; - -import { getBodySize, parseContentSizeHeader } from '../../../src/coreHandlers/extendNetworkBreadcrumbs'; - -describe('Unit | coreHandlers | extendNetworkBreadcrumbs', () => { - describe('parseContentSizeHeader()', () => { - it.each([ - [undefined, undefined], - [null, undefined], - ['', undefined], - ['12', 12], - ['abc', undefined], - ])('works with %s header value', (headerValue, size) => { - expect(parseContentSizeHeader(headerValue)).toBe(size); - }); - }); - - describe('getBodySize()', () => { - const textEncoder = new TextEncoder(); - - it('works with empty body', () => { - expect(getBodySize(undefined, textEncoder)).toBe(undefined); - expect(getBodySize(null, textEncoder)).toBe(undefined); - expect(getBodySize('', textEncoder)).toBe(undefined); - }); - - it('works with string body', () => { - expect(getBodySize('abcd', textEncoder)).toBe(4); - // Emojis are correctly counted as mutliple characters - expect(getBodySize('With emoji: 😈', textEncoder)).toBe(16); - }); - - it('works with URLSearchParams', () => { - const params = new URLSearchParams(); - params.append('name', 'Jane'); - params.append('age', '42'); - params.append('emoji', '😈'); - - expect(getBodySize(params, textEncoder)).toBe(35); - }); - - it('works with FormData', () => { - const formData = new FormData(); - formData.append('name', 'Jane'); - formData.append('age', '42'); - formData.append('emoji', '😈'); - - expect(getBodySize(formData, textEncoder)).toBe(35); - }); - - it('works with Blob', () => { - const blob = new Blob(['Hello world: 😈'], { type: 'text/html' }); - - expect(getBodySize(blob, textEncoder)).toBe(30); - }); - - it('works with ArrayBuffer', () => { - const arrayBuffer = new ArrayBuffer(8); - - expect(getBodySize(arrayBuffer, textEncoder)).toBe(8); - }); - }); -}); diff --git a/packages/replay/test/unit/coreHandlers/handleNetworkBreadcrumbs.test.ts b/packages/replay/test/unit/coreHandlers/handleNetworkBreadcrumbs.test.ts new file mode 100644 index 000000000000..d0a7b5cedca1 --- /dev/null +++ b/packages/replay/test/unit/coreHandlers/handleNetworkBreadcrumbs.test.ts @@ -0,0 +1,340 @@ +import type { + Breadcrumb, + BreadcrumbHint, + FetchBreadcrumbHint, + TextEncoderInternal, + XhrBreadcrumbHint, +} from '@sentry/types'; +import { TextEncoder } from 'util'; + +import { BASE_TIMESTAMP } from '../..'; +import { + getBodySize, + handleNetworkBreadcrumb, + parseContentSizeHeader, +} from '../../../src/coreHandlers/handleNetworkBreadcrumbs'; +import type { EventBufferArray } from '../../../src/eventBuffer/EventBufferArray'; +import type { ReplayContainer } from '../../../src/types'; +import { setupReplayContainer } from '../../utils/setupReplayContainer'; + +jest.useFakeTimers(); + +describe('Unit | coreHandlers | handleNetworkBreadcrumbs', () => { + describe('parseContentSizeHeader()', () => { + it.each([ + [undefined, undefined], + [null, undefined], + ['', undefined], + ['12', 12], + ['abc', undefined], + ])('works with %s header value', (headerValue, size) => { + expect(parseContentSizeHeader(headerValue)).toBe(size); + }); + }); + + describe('getBodySize()', () => { + const textEncoder = new TextEncoder(); + + it('works with empty body', () => { + expect(getBodySize(undefined, textEncoder)).toBe(undefined); + expect(getBodySize(null, textEncoder)).toBe(undefined); + expect(getBodySize('', textEncoder)).toBe(undefined); + }); + + it('works with string body', () => { + expect(getBodySize('abcd', textEncoder)).toBe(4); + // Emojis are correctly counted as mutliple characters + expect(getBodySize('With emoji: 😈', textEncoder)).toBe(16); + }); + + it('works with URLSearchParams', () => { + const params = new URLSearchParams(); + params.append('name', 'Jane'); + params.append('age', '42'); + params.append('emoji', '😈'); + + expect(getBodySize(params, textEncoder)).toBe(35); + }); + + it('works with FormData', () => { + const formData = new FormData(); + formData.append('name', 'Jane'); + formData.append('age', '42'); + formData.append('emoji', '😈'); + + expect(getBodySize(formData, textEncoder)).toBe(35); + }); + + it('works with Blob', () => { + const blob = new Blob(['Hello world: 😈'], { type: 'text/html' }); + + expect(getBodySize(blob, textEncoder)).toBe(30); + }); + + it('works with ArrayBuffer', () => { + const arrayBuffer = new ArrayBuffer(8); + + expect(getBodySize(arrayBuffer, textEncoder)).toBe(8); + }); + }); + + describe('handleNetworkBreadcrumb()', () => { + let options: { + replay: ReplayContainer; + textEncoder: TextEncoderInternal; + }; + + beforeEach(() => { + jest.setSystemTime(BASE_TIMESTAMP); + + options = { + textEncoder: new TextEncoder(), + replay: setupReplayContainer(), + }; + + jest.runAllTimers(); + }); + + it('ignores breadcrumb without data', () => { + const breadcrumb: Breadcrumb = {}; + const hint: BreadcrumbHint = {}; + handleNetworkBreadcrumb(options, breadcrumb, hint); + + expect(breadcrumb).toEqual({}); + expect((options.replay.eventBuffer as EventBufferArray).events).toEqual([]); + }); + + it('ignores non-network breadcrumbs', () => { + const breadcrumb: Breadcrumb = { + category: 'foo', + data: {}, + }; + const hint: BreadcrumbHint = {}; + handleNetworkBreadcrumb(options, breadcrumb, hint); + + expect(breadcrumb).toEqual({ + category: 'foo', + data: {}, + }); + expect((options.replay.eventBuffer as EventBufferArray).events).toEqual([]); + }); + + it('handles full xhr breadcrumb', async () => { + const breadcrumb: Breadcrumb = { + category: 'xhr', + data: { + method: 'GET', + url: 'https://example.com', + status_code: 200, + }, + }; + const xhr = new XMLHttpRequest(); + Object.defineProperty(xhr, 'response', { + value: 'test response', + }); + const hint: XhrBreadcrumbHint = { + xhr, + input: 'test input', + startTimestamp: BASE_TIMESTAMP + 1000, + endTimestamp: BASE_TIMESTAMP + 2000, + }; + handleNetworkBreadcrumb(options, breadcrumb, hint); + + expect(breadcrumb).toEqual({ + category: 'xhr', + data: { + method: 'GET', + request_body_size: 10, + response_body_size: 13, + status_code: 200, + url: 'https://example.com', + }, + }); + + jest.runAllTimers(); + + expect((options.replay.eventBuffer as EventBufferArray).events).toEqual([ + { + type: 5, + timestamp: (BASE_TIMESTAMP + 1000) / 1000, + data: { + tag: 'performanceSpan', + payload: { + data: { + method: 'GET', + requestBodySize: 10, + responseBodySize: 13, + statusCode: 200, + }, + description: 'https://example.com', + endTimestamp: (BASE_TIMESTAMP + 2000) / 1000, + op: 'resource.xhr', + startTimestamp: (BASE_TIMESTAMP + 1000) / 1000, + }, + }, + }, + ]); + }); + + it('handles minimal xhr breadcrumb', async () => { + const breadcrumb: Breadcrumb = { + category: 'xhr', + data: { + url: 'https://example.com', + status_code: 200, + }, + }; + const xhr = new XMLHttpRequest(); + + const hint: XhrBreadcrumbHint = { + xhr, + input: undefined, + startTimestamp: BASE_TIMESTAMP + 1000, + endTimestamp: BASE_TIMESTAMP + 2000, + }; + handleNetworkBreadcrumb(options, breadcrumb, hint); + + expect(breadcrumb).toEqual({ + category: 'xhr', + data: { + status_code: 200, + url: 'https://example.com', + }, + }); + + jest.runAllTimers(); + + expect((options.replay.eventBuffer as EventBufferArray).events).toEqual([ + { + type: 5, + timestamp: (BASE_TIMESTAMP + 1000) / 1000, + data: { + tag: 'performanceSpan', + payload: { + data: { + statusCode: 200, + }, + description: 'https://example.com', + endTimestamp: (BASE_TIMESTAMP + 2000) / 1000, + op: 'resource.xhr', + startTimestamp: (BASE_TIMESTAMP + 1000) / 1000, + }, + }, + }, + ]); + }); + + it('handles full fetch breadcrumb', async () => { + const breadcrumb: Breadcrumb = { + category: 'fetch', + data: { + method: 'GET', + url: 'https://example.com', + status_code: 200, + }, + }; + + const mockResponse = { + headers: { + get: () => '13', + }, + } as unknown as Response; + + const hint: FetchBreadcrumbHint = { + input: ['GET', { body: 'test input' }], + response: mockResponse, + startTimestamp: BASE_TIMESTAMP + 1000, + endTimestamp: BASE_TIMESTAMP + 2000, + }; + handleNetworkBreadcrumb(options, breadcrumb, hint); + + expect(breadcrumb).toEqual({ + category: 'fetch', + data: { + method: 'GET', + request_body_size: 10, + response_body_size: 13, + status_code: 200, + url: 'https://example.com', + }, + }); + + jest.runAllTimers(); + + expect((options.replay.eventBuffer as EventBufferArray).events).toEqual([ + { + type: 5, + timestamp: (BASE_TIMESTAMP + 1000) / 1000, + data: { + tag: 'performanceSpan', + payload: { + data: { + method: 'GET', + requestBodySize: 10, + responseBodySize: 13, + statusCode: 200, + }, + description: 'https://example.com', + endTimestamp: (BASE_TIMESTAMP + 2000) / 1000, + op: 'resource.fetch', + startTimestamp: (BASE_TIMESTAMP + 1000) / 1000, + }, + }, + }, + ]); + }); + + it('handles minimal fetch breadcrumb', async () => { + const breadcrumb: Breadcrumb = { + category: 'fetch', + data: { + url: 'https://example.com', + status_code: 200, + }, + }; + + const mockResponse = { + headers: { + get: () => '', + }, + } as unknown as Response; + + const hint: FetchBreadcrumbHint = { + input: [], + response: mockResponse, + startTimestamp: BASE_TIMESTAMP + 1000, + endTimestamp: BASE_TIMESTAMP + 2000, + }; + handleNetworkBreadcrumb(options, breadcrumb, hint); + + expect(breadcrumb).toEqual({ + category: 'fetch', + data: { + status_code: 200, + url: 'https://example.com', + }, + }); + + jest.runAllTimers(); + + expect((options.replay.eventBuffer as EventBufferArray).events).toEqual([ + { + type: 5, + timestamp: (BASE_TIMESTAMP + 1000) / 1000, + data: { + tag: 'performanceSpan', + payload: { + data: { + statusCode: 200, + }, + description: 'https://example.com', + endTimestamp: (BASE_TIMESTAMP + 2000) / 1000, + op: 'resource.fetch', + startTimestamp: (BASE_TIMESTAMP + 1000) / 1000, + }, + }, + }, + ]); + }); + }); +}); diff --git a/packages/types/src/breadcrumb.ts b/packages/types/src/breadcrumb.ts index b8e2552a2f34..9f9b36bd6dcb 100644 --- a/packages/types/src/breadcrumb.ts +++ b/packages/types/src/breadcrumb.ts @@ -16,3 +16,34 @@ export interface Breadcrumb { export interface BreadcrumbHint { [key: string]: any; } + +export interface FetchBreadcrumbData { + method: string; + url: string; + status_code?: number; + request_body_size?: number; + response_body_size?: number; +} + +export interface XhrBreadcrumbData { + method?: string; + url?: string; + status_code?: number; + request_body_size?: number; + response_body_size?: number; +} + +export interface FetchBreadcrumbHint { + input: any[]; + data?: unknown; + response?: unknown; + startTimestamp: number; + endTimestamp: number; +} + +export interface XhrBreadcrumbHint { + xhr: unknown; + input: unknown; + startTimestamp: number; + endTimestamp: number; +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 6cb59b9931f8..e62fe6390ac8 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -1,5 +1,12 @@ export type { Attachment } from './attachment'; -export type { Breadcrumb, BreadcrumbHint } from './breadcrumb'; +export type { + Breadcrumb, + BreadcrumbHint, + FetchBreadcrumbData, + XhrBreadcrumbData, + FetchBreadcrumbHint, + XhrBreadcrumbHint, +} from './breadcrumb'; export type { Client } from './client'; export type { ClientReport, Outcome, EventDropReason } from './clientreport'; export type { Context, Contexts, DeviceContext, OsContext, AppContext, CultureContext, TraceContext } from './context';