diff --git a/CHANGELOG.md b/CHANGELOG.md index 8533b562cd..808d4feb65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Improve touch event component info if annotated with [`@sentry/babel-plugin-component-annotate`](https://www.npmjs.com/package/@sentry/babel-plugin-component-annotate) ([#3899](https://github.com/getsentry/sentry-react-native/pull/3899)) - Add replay breadcrumbs for touch & navigation events ([#3846](https://github.com/getsentry/sentry-react-native/pull/3846)) +- Add network data to Session Replays ([#3912](https://github.com/getsentry/sentry-react-native/pull/3912)) ### Dependencies diff --git a/android/src/main/java/io/sentry/react/RNSentryReplayBreadcrumbConverter.java b/android/src/main/java/io/sentry/react/RNSentryReplayBreadcrumbConverter.java index f58f0d5b17..be6d62783c 100644 --- a/android/src/main/java/io/sentry/react/RNSentryReplayBreadcrumbConverter.java +++ b/android/src/main/java/io/sentry/react/RNSentryReplayBreadcrumbConverter.java @@ -4,10 +4,15 @@ import io.sentry.android.replay.DefaultReplayBreadcrumbConverter; import io.sentry.rrweb.RRWebEvent; import io.sentry.rrweb.RRWebBreadcrumbEvent; +import io.sentry.rrweb.RRWebSpanEvent; + import java.util.ArrayList; import java.util.HashMap; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.TestOnly; + +import java.util.HashMap; public final class RNSentryReplayBreadcrumbConverter extends DefaultReplayBreadcrumbConverter { public RNSentryReplayBreadcrumbConverter() { @@ -15,60 +20,115 @@ public RNSentryReplayBreadcrumbConverter() { @Override public @Nullable RRWebEvent convert(final @NotNull Breadcrumb breadcrumb) { - RRWebBreadcrumbEvent rrwebBreadcrumb = new RRWebBreadcrumbEvent(); - assert rrwebBreadcrumb.getCategory() == null; + if (breadcrumb.getCategory() == null) { + return null; + } if (breadcrumb.getCategory().equals("touch")) { - rrwebBreadcrumb.setCategory("ui.tap"); - ArrayList path = (ArrayList) breadcrumb.getData("path"); - if (path != null) { - StringBuilder message = new StringBuilder(); - for (int i = Math.min(3, path.size()); i >= 0; i--) { - HashMap item = (HashMap) path.get(i); - message.append(item.get("name")); - if (item.containsKey("element") || item.containsKey("file")) { - message.append('('); - if (item.containsKey("element")) { - message.append(item.get("element")); - if (item.containsKey("file")) { - message.append(", "); - message.append(item.get("file")); - } - } else if (item.containsKey("file")) { - message.append(item.get("file")); - } - message.append(')'); - } - if (i > 0) { - message.append(" > "); - } - } - rrwebBreadcrumb.setMessage(message.toString()); - } - rrwebBreadcrumb.setData(breadcrumb.getData()); - } else if (breadcrumb.getCategory().equals("navigation")) { - rrwebBreadcrumb.setCategory(breadcrumb.getCategory()); - rrwebBreadcrumb.setData(breadcrumb.getData()); + return convertTouchBreadcrumb(breadcrumb); } - - if (rrwebBreadcrumb.getCategory() != null && !rrwebBreadcrumb.getCategory().isEmpty()) { - rrwebBreadcrumb.setLevel(breadcrumb.getLevel()); - rrwebBreadcrumb.setTimestamp(breadcrumb.getTimestamp().getTime()); - rrwebBreadcrumb.setBreadcrumbTimestamp(breadcrumb.getTimestamp().getTime() / 1000.0); - rrwebBreadcrumb.setBreadcrumbType("default"); - return rrwebBreadcrumb; + if (breadcrumb.getCategory().equals("navigation")) { + final RRWebBreadcrumbEvent rrWebBreadcrumb = new RRWebBreadcrumbEvent(); + rrWebBreadcrumb.setCategory(breadcrumb.getCategory()); + rrWebBreadcrumb.setData(breadcrumb.getData()); + return rrWebBreadcrumb; + } + if (breadcrumb.getCategory().equals("xhr")) { + return convertNetworkBreadcrumb(breadcrumb); + } + if (breadcrumb.getCategory().equals("http")) { + // Drop native http breadcrumbs to avoid duplicates + return null; } RRWebEvent nativeBreadcrumb = super.convert(breadcrumb); // ignore native navigation breadcrumbs if (nativeBreadcrumb instanceof RRWebBreadcrumbEvent) { - rrwebBreadcrumb = (RRWebBreadcrumbEvent) nativeBreadcrumb; - if (rrwebBreadcrumb.getCategory() != null && rrwebBreadcrumb.getCategory().equals("navigation")) { + final RRWebBreadcrumbEvent rrWebBreadcrumb = (RRWebBreadcrumbEvent) nativeBreadcrumb; + if (rrWebBreadcrumb.getCategory() != null && rrWebBreadcrumb.getCategory().equals("navigation")) { return null; } } return nativeBreadcrumb; } + + @TestOnly + public @NotNull RRWebEvent convertTouchBreadcrumb(final @NotNull Breadcrumb breadcrumb) { + final RRWebBreadcrumbEvent rrWebBreadcrumb = new RRWebBreadcrumbEvent(); + + rrWebBreadcrumb.setCategory("ui.tap"); + ArrayList path = (ArrayList) breadcrumb.getData("path"); + if (path != null) { + StringBuilder message = new StringBuilder(); + for (int i = Math.min(3, path.size()); i >= 0; i--) { + HashMap item = (HashMap) path.get(i); + message.append(item.get("name")); + if (item.containsKey("element") || item.containsKey("file")) { + message.append('('); + if (item.containsKey("element")) { + message.append(item.get("element")); + if (item.containsKey("file")) { + message.append(", "); + message.append(item.get("file")); + } + } else if (item.containsKey("file")) { + message.append(item.get("file")); + } + message.append(')'); + } + if (i > 0) { + message.append(" > "); + } + } + rrWebBreadcrumb.setMessage(message.toString()); + } + + rrWebBreadcrumb.setLevel(breadcrumb.getLevel()); + rrWebBreadcrumb.setData(breadcrumb.getData()); + rrWebBreadcrumb.setTimestamp(breadcrumb.getTimestamp().getTime()); + rrWebBreadcrumb.setBreadcrumbTimestamp(breadcrumb.getTimestamp().getTime() / 1000.0); + rrWebBreadcrumb.setBreadcrumbType("default"); + return rrWebBreadcrumb; + } + + @TestOnly + public @Nullable RRWebEvent convertNetworkBreadcrumb(final @NotNull Breadcrumb breadcrumb) { + final Double startTimestamp = breadcrumb.getData("start_timestamp") instanceof Number + ? (Double) breadcrumb.getData("start_timestamp") : null; + final Double endTimestamp = breadcrumb.getData("end_timestamp") instanceof Number + ? (Double) breadcrumb.getData("end_timestamp") : null; + final String url = breadcrumb.getData("url") instanceof String + ? (String) breadcrumb.getData("url") : null; + + if (startTimestamp == null || endTimestamp == null || url == null) { + return null; + } + + final HashMap data = new HashMap<>(); + if (breadcrumb.getData("method") instanceof String) { + data.put("method", breadcrumb.getData("method")); + } + if (breadcrumb.getData("status_code") instanceof Double) { + final Double statusCode = (Double) breadcrumb.getData("status_code"); + if (statusCode > 0) { + data.put("statusCode", statusCode.intValue()); + } + } + if (breadcrumb.getData("request_body_size") instanceof Double) { + data.put("requestBodySize", breadcrumb.getData("request_body_size")); + } + if (breadcrumb.getData("response_body_size") instanceof Double) { + data.put("responseBodySize", breadcrumb.getData("response_body_size")); + } + + final RRWebSpanEvent rrWebSpanEvent = new RRWebSpanEvent(); + rrWebSpanEvent.setOp("resource.http"); + rrWebSpanEvent.setStartTimestamp(startTimestamp / 1000.0); + rrWebSpanEvent.setEndTimestamp(endTimestamp / 1000.0); + rrWebSpanEvent.setDescription(url); + rrWebSpanEvent.setData(data); + return rrWebSpanEvent; + } } diff --git a/ios/RNSentryReplayBreadcrumbConverter.m b/ios/RNSentryReplayBreadcrumbConverter.m index 92d736e9b3..7963824241 100644 --- a/ios/RNSentryReplayBreadcrumbConverter.m +++ b/ios/RNSentryReplayBreadcrumbConverter.m @@ -20,6 +20,15 @@ - (instancetype _Nonnull)init { (SentryBreadcrumb *_Nonnull)breadcrumb { assert(breadcrumb.timestamp != nil); + if ([breadcrumb.category isEqualToString:@"http"]) { + // Drop native network breadcrumbs to avoid duplicates + return nil; + } + if ([breadcrumb.type isEqualToString:@"navigation"] && ![breadcrumb.category isEqualToString:@"navigation"]) { + // Drop native navigation breadcrumbs to avoid duplicates + return nil; + } + if ([breadcrumb.category isEqualToString:@"touch"]) { NSMutableString *message; if (breadcrumb.data) { @@ -54,28 +63,70 @@ - (instancetype _Nonnull)init { message:message level:breadcrumb.level data:breadcrumb.data]; - } else if ([breadcrumb.category isEqualToString:@"navigation"]) { + } + + if ([breadcrumb.category isEqualToString:@"navigation"]) { return [SentrySessionReplayIntegration createBreadcrumbwithTimestamp:breadcrumb.timestamp category:breadcrumb.category message:nil level:breadcrumb.level data:breadcrumb.data]; - } else { - SentryRRWebEvent *nativeBreadcrumb = - [self->defaultConverter convertFrom:breadcrumb]; - - // ignore native navigation breadcrumbs - if (nativeBreadcrumb && nativeBreadcrumb.data && - nativeBreadcrumb.data[@"payload"] && - nativeBreadcrumb.data[@"payload"][@"category"] && - [nativeBreadcrumb.data[@"payload"][@"category"] - isEqualToString:@"navigation"]) { - return nil; - } - return nativeBreadcrumb; } + + if ([breadcrumb.category isEqualToString:@"xhr"]) { + return [self convertNavigation:breadcrumb]; + } + + SentryRRWebEvent *nativeBreadcrumb = + [self->defaultConverter convertFrom:breadcrumb]; + + // ignore native navigation breadcrumbs + if (nativeBreadcrumb && nativeBreadcrumb.data && + nativeBreadcrumb.data[@"payload"] && + nativeBreadcrumb.data[@"payload"][@"category"] && + [nativeBreadcrumb.data[@"payload"][@"category"] + isEqualToString:@"navigation"]) { + return nil; + } + + return nativeBreadcrumb; +} + +- (id _Nullable)convertNavigation: (SentryBreadcrumb *_Nonnull)breadcrumb { + NSNumber* startTimestamp = [breadcrumb.data[@"start_timestamp"] isKindOfClass:[NSNumber class]] + ? breadcrumb.data[@"start_timestamp"] : nil; + NSNumber* endTimestamp = [breadcrumb.data[@"end_timestamp"] isKindOfClass:[NSNumber class]] + ? breadcrumb.data[@"end_timestamp"] : nil; + NSString* url = [breadcrumb.data[@"url"] isKindOfClass:[NSString class]] + ? breadcrumb.data[@"url"] : nil; + + if (startTimestamp == nil || endTimestamp == nil || url == nil) { + return nil; + } + + NSMutableDictionary* data = [[NSMutableDictionary alloc] init]; + if ([breadcrumb.data[@"method"] isKindOfClass:[NSString class]]) { + data[@"method"] = breadcrumb.data[@"method"]; + } + if ([breadcrumb.data[@"status_code"] isKindOfClass:[NSNumber class]]) { + data[@"statusCode"] = breadcrumb.data[@"status_code"]; + } + if ([breadcrumb.data[@"request_body_size"] isKindOfClass:[NSNumber class]]) { + data[@"requestBodySize"] = breadcrumb.data[@"request_body_size"]; + } + if ([breadcrumb.data[@"response_body_size"] isKindOfClass:[NSNumber class]]) { + data[@"responseBodySize"] = breadcrumb.data[@"response_body_size"]; + } + + return [SentrySessionReplayIntegration + createNetworkBreadcrumbWithTimestamp:[NSDate dateWithTimeIntervalSince1970:(startTimestamp.doubleValue / 1000)] + endTimestamp:[NSDate dateWithTimeIntervalSince1970:(endTimestamp.doubleValue / 1000)] + operation:@"resource.http" + description:url + data:data]; } @end + #endif diff --git a/src/js/client.ts b/src/js/client.ts index 81e84fc8fa..cc45b15e8f 100644 --- a/src/js/client.ts +++ b/src/js/client.ts @@ -16,10 +16,10 @@ import { dateTimestampInSeconds, logger, SentryError } from '@sentry/utils'; import { Alert } from 'react-native'; import { createIntegration } from './integrations/factory'; -import type { mobileReplayIntegration } from './integrations/mobilereplay'; -import { MOBILE_REPLAY_INTEGRATION_NAME } from './integrations/mobilereplay'; import { defaultSdkInfo } from './integrations/sdkinfo'; import type { ReactNativeClientOptions } from './options'; +import type { mobileReplayIntegration } from './replay/mobilereplay'; +import { MOBILE_REPLAY_INTEGRATION_NAME } from './replay/mobilereplay'; import { ReactNativeTracing } from './tracing'; import { createUserFeedbackEnvelope, items } from './utils/envelope'; import { ignoreRequireCycleLogs } from './utils/ignorerequirecyclelogs'; diff --git a/src/js/integrations/exports.ts b/src/js/integrations/exports.ts index 996f5c5ddb..1bb337d5c3 100644 --- a/src/js/integrations/exports.ts +++ b/src/js/integrations/exports.ts @@ -12,7 +12,7 @@ export { screenshotIntegration } from './screenshot'; export { viewHierarchyIntegration } from './viewhierarchy'; export { expoContextIntegration } from './expocontext'; export { spotlightIntegration } from './spotlight'; -export { mobileReplayIntegration } from './mobilereplay'; +export { mobileReplayIntegration } from '../replay/mobilereplay'; export { breadcrumbsIntegration, diff --git a/src/js/integrations/index.ts b/src/js/integrations/index.ts index f1331379a9..1dac165811 100644 --- a/src/js/integrations/index.ts +++ b/src/js/integrations/index.ts @@ -14,4 +14,4 @@ export { Screenshot } from './screenshot'; export { ViewHierarchy } from './viewhierarchy'; export { ExpoContext } from './expocontext'; export { Spotlight } from './spotlight'; -export { mobileReplayIntegration } from './mobilereplay'; +export { mobileReplayIntegration } from '../replay/mobilereplay'; diff --git a/src/js/integrations/mobilereplay.ts b/src/js/replay/mobilereplay.ts similarity index 96% rename from src/js/integrations/mobilereplay.ts rename to src/js/replay/mobilereplay.ts index 9353ab8eec..6d376ad4fb 100644 --- a/src/js/integrations/mobilereplay.ts +++ b/src/js/replay/mobilereplay.ts @@ -5,6 +5,7 @@ import { isHardCrash } from '../misc'; import { hasHooks } from '../utils/clientutils'; import { isExpoGo, notMobileOs } from '../utils/environment'; import { NATIVE } from '../wrapper'; +import { enrichXhrBreadcrumbsForMobileReplay } from './xhrUtils'; export const MOBILE_REPLAY_INTEGRATION_NAME = 'MobileReplay'; @@ -103,6 +104,8 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau dsc.replay_id = currentReplayId; } }); + + client.on('beforeAddBreadcrumb', enrichXhrBreadcrumbsForMobileReplay); } // TODO: When adding manual API, ensure overlap with the web replay so users can use the same API interchangeably diff --git a/src/js/replay/networkUtils.ts b/src/js/replay/networkUtils.ts new file mode 100644 index 0000000000..6834294b33 --- /dev/null +++ b/src/js/replay/networkUtils.ts @@ -0,0 +1,64 @@ +import { RN_GLOBAL_OBJ } from '../utils/worldwide'; +import { utf8ToBytes } from '../vendor'; + +/** Convert a Content-Length header to number/undefined. */ +export function parseContentLengthHeader(header: string | null | undefined): number | undefined { + if (!header) { + return undefined; + } + + const size = parseInt(header, 10); + return isNaN(size) ? undefined : size; +} + +export type RequestBody = null | Blob | FormData | URLSearchParams | string | ArrayBuffer | undefined; + +/** Get the size of a body. */ +export function getBodySize(body: RequestBody): number | undefined { + if (!body) { + return undefined; + } + + try { + if (typeof body === 'string') { + return _encode(body).length; + } + + if (body instanceof URLSearchParams) { + return _encode(body.toString()).length; + } + + if (body instanceof FormData) { + const formDataStr = _serializeFormData(body); + return _encode(formDataStr).length; + } + + if (body instanceof Blob) { + return body.size; + } + + if (body instanceof ArrayBuffer) { + return body.byteLength; + } + + // Currently unhandled types: ArrayBufferView, ReadableStream + } catch { + // just return undefined + } + + return undefined; +} + +function _encode(input: string): number[] | Uint8Array { + if (RN_GLOBAL_OBJ.TextEncoder) { + return new RN_GLOBAL_OBJ.TextEncoder().encode(input); + } + return utf8ToBytes(input); +} + +function _serializeFormData(formData: FormData): string { + // This is a bit simplified, but gives us a decent estimate + // This converts e.g. { name: 'Anne Smith', age: 13 } to 'name=Anne+Smith&age=13' + // @ts-expect-error passing FormData to URLSearchParams won't correctly serialize `File` entries, which is fine for this use-case. See https://github.com/microsoft/TypeScript/issues/30584 + return new URLSearchParams(formData).toString(); +} diff --git a/src/js/replay/xhrUtils.ts b/src/js/replay/xhrUtils.ts new file mode 100644 index 0000000000..a0ac892b99 --- /dev/null +++ b/src/js/replay/xhrUtils.ts @@ -0,0 +1,52 @@ +import type { Breadcrumb, BreadcrumbHint, SentryWrappedXMLHttpRequest, XhrBreadcrumbHint } from '@sentry/types'; +import { dropUndefinedKeys } from '@sentry/utils'; + +import type { RequestBody } from './networkUtils'; +import { getBodySize, parseContentLengthHeader } from './networkUtils'; + +/** + * Enrich an XHR breadcrumb with additional data for Mobile Replay network tab. + */ +export function enrichXhrBreadcrumbsForMobileReplay(breadcrumb: Breadcrumb, hint: BreadcrumbHint | undefined): void { + if (breadcrumb.category !== 'xhr' || !hint) { + return; + } + + const xhrHint = hint as Partial; + if (!xhrHint.xhr) { + return; + } + + const now = Date.now(); + const { startTimestamp = now, endTimestamp = now, input, xhr } = xhrHint; + + const reqSize = getBodySize(input); + const resSize = xhr.getResponseHeader('content-length') + ? parseContentLengthHeader(xhr.getResponseHeader('content-length')) + : _getBodySize(xhr.response, xhr.responseType); + + breadcrumb.data = dropUndefinedKeys({ + start_timestamp: startTimestamp, + end_timestamp: endTimestamp, + request_body_size: reqSize, + response_body_size: resSize, + ...breadcrumb.data, + }); +} + +type XhrHint = XhrBreadcrumbHint & { + xhr: XMLHttpRequest & SentryWrappedXMLHttpRequest; + input?: RequestBody; +}; + +function _getBodySize( + body: XMLHttpRequest['response'], + responseType: XMLHttpRequest['responseType'], +): number | undefined { + try { + const bodyStr = responseType === 'json' && body && typeof body === 'object' ? JSON.stringify(body) : body; + return getBodySize(bodyStr); + } catch { + return undefined; + } +} diff --git a/src/js/utils/worldwide.ts b/src/js/utils/worldwide.ts index 4f1cfc4c7b..7215613672 100644 --- a/src/js/utils/worldwide.ts +++ b/src/js/utils/worldwide.ts @@ -24,7 +24,13 @@ export interface ReactNativeInternalGlobal extends InternalGlobal { }; __BUNDLE_START_TIME__?: number; nativePerformanceNow?: () => number; + TextEncoder?: TextEncoder; } +type TextEncoder = { + new (): TextEncoder; + encode(input?: string): Uint8Array; +}; + /** Get's the global object for the current JavaScript runtime */ export const RN_GLOBAL_OBJ = GLOBAL_OBJ as ReactNativeInternalGlobal; diff --git a/src/js/wrapper.ts b/src/js/wrapper.ts index f3950d5faf..bef1a6692c 100644 --- a/src/js/wrapper.ts +++ b/src/js/wrapper.ts @@ -12,7 +12,6 @@ import type { import { logger, normalize, SentryError } from '@sentry/utils'; import { NativeModules, Platform } from 'react-native'; -import type { MobileReplayOptions } from './integrations/mobilereplay'; import { isHardCrash } from './misc'; import type { NativeAppStartResponse, @@ -26,6 +25,7 @@ import type { import type { ReactNativeClientOptions } from './options'; import type * as Hermes from './profiling/hermes'; import type { NativeAndroidProfileEvent, NativeProfileEvent } from './profiling/nativeTypes'; +import type { MobileReplayOptions } from './replay/mobilereplay'; import type { RequiredKeysUser } from './user'; import { isTurboModuleEnabled } from './utils/environment'; import { ReactNativeLibraries } from './utils/rnlibraries'; diff --git a/test/replay/networkUtils.test.ts b/test/replay/networkUtils.test.ts new file mode 100644 index 0000000000..9bacfc9ce1 --- /dev/null +++ b/test/replay/networkUtils.test.ts @@ -0,0 +1,59 @@ +import { getBodySize, parseContentLengthHeader } from '../../src/js/replay/networkUtils'; + +describe('networkUtils', () => { + describe('parseContentLengthHeader()', () => { + it.each([ + [undefined, undefined], + [null, undefined], + ['', undefined], + ['12', 12], + ['abc', undefined], + ])('works with %s header value', (headerValue, size) => { + expect(parseContentLengthHeader(headerValue)).toBe(size); + }); + }); + + describe('getBodySize()', () => { + it('works with empty body', () => { + expect(getBodySize(undefined)).toBe(undefined); + expect(getBodySize(null)).toBe(undefined); + expect(getBodySize('')).toBe(undefined); + }); + + it('works with string body', () => { + expect(getBodySize('abcd')).toBe(4); + // Emojis are correctly counted as mutliple characters + expect(getBodySize('With emoji: 😈')).toBe(16); + }); + + it('works with URLSearchParams', () => { + const params = new URLSearchParams(); + params.append('name', 'Jane'); + params.append('age', '42'); + params.append('emoji', '😈'); + + expect(getBodySize(params)).toBe(35); + }); + + it('works with FormData', () => { + const formData = new FormData(); + formData.append('name', 'Jane'); + formData.append('age', '42'); + formData.append('emoji', '😈'); + + expect(getBodySize(formData)).toBe(35); + }); + + it('works with Blob', () => { + const blob = new Blob(['Hello world: 😈'], { type: 'text/html', lastModified: 0 }); + + expect(getBodySize(blob)).toBe(30); + }); + + it('works with ArrayBuffer', () => { + const arrayBuffer = new ArrayBuffer(8); + + expect(getBodySize(arrayBuffer)).toBe(8); + }); + }); +}); diff --git a/test/replay/xhrUtils.test.ts b/test/replay/xhrUtils.test.ts new file mode 100644 index 0000000000..614dae4be6 --- /dev/null +++ b/test/replay/xhrUtils.test.ts @@ -0,0 +1,89 @@ +import type { Breadcrumb } from '@sentry/types'; + +import { enrichXhrBreadcrumbsForMobileReplay } from '../../src/js/replay/xhrUtils'; + +describe('xhrUtils', () => { + describe('enrichXhrBreadcrumbsForMobileReplay', () => { + it('only changes xhr category breadcrumbs', () => { + const breadcrumb: Breadcrumb = { category: 'http' }; + enrichXhrBreadcrumbsForMobileReplay(breadcrumb, getValidXhrHint()); + expect(breadcrumb).toEqual({ category: 'http' }); + }); + + it('does nothing without hint', () => { + const breadcrumb: Breadcrumb = { category: 'xhr' }; + enrichXhrBreadcrumbsForMobileReplay(breadcrumb, undefined); + expect(breadcrumb).toEqual({ category: 'xhr' }); + }); + + it('does nothing without xhr hint', () => { + const breadcrumb: Breadcrumb = { category: 'xhr' }; + enrichXhrBreadcrumbsForMobileReplay(breadcrumb, {}); + expect(breadcrumb).toEqual({ category: 'xhr' }); + }); + + it('set start and end timestamp', () => { + const breadcrumb: Breadcrumb = { category: 'xhr' }; + enrichXhrBreadcrumbsForMobileReplay(breadcrumb, getValidXhrHint()); + expect(breadcrumb.data).toEqual( + expect.objectContaining({ + start_timestamp: 1, + end_timestamp: 2, + }), + ); + }); + + it('uses now as default timestamp', () => { + const breadcrumb: Breadcrumb = { category: 'xhr' }; + enrichXhrBreadcrumbsForMobileReplay(breadcrumb, { + ...getValidXhrHint(), + startTimestamp: undefined, + endTimestamp: undefined, + }); + expect(breadcrumb.data).toEqual( + expect.objectContaining({ + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + }), + ); + }); + + it('sets request body size', () => { + const breadcrumb: Breadcrumb = { category: 'xhr' }; + enrichXhrBreadcrumbsForMobileReplay(breadcrumb, getValidXhrHint()); + expect(breadcrumb.data).toEqual( + expect.objectContaining({ + request_body_size: 10, + }), + ); + }); + + it('sets response body size', () => { + const breadcrumb: Breadcrumb = { category: 'xhr' }; + enrichXhrBreadcrumbsForMobileReplay(breadcrumb, getValidXhrHint()); + expect(breadcrumb.data).toEqual( + expect.objectContaining({ + response_body_size: 13, + }), + ); + }); + }); +}); + +function getValidXhrHint() { + return { + startTimestamp: 1, + endTimestamp: 2, + input: 'test-input', // 10 bytes + xhr: { + getResponseHeader: (key: string) => { + if (key === 'content-length') { + return '13'; + } + throw new Error('Invalid key'); + }, + response: 'test-response', // 13 bytes + responseType: 'json', + }, + }; +}