From 9f45729efb103731be557563f00fecbc156bc722 Mon Sep 17 00:00:00 2001 From: "carlos.nogueira" Date: Wed, 5 Aug 2026 18:38:00 +0100 Subject: [PATCH 1/2] Add optional global Fetch resource tracking Add trackFetchResources configuration to instrument Expo and other global Fetch implementations while keeping XHR tracking enabled. Coordinate Fetch and XHR proxies to prevent duplicate events for XHR-backed Fetch requests. --- packages/core/README.md | 5 + .../core/datadog-configuration.schema.json | 4 + packages/core/src/DdSdkReactNative.tsx | 6 +- .../src/__tests__/DdSdkReactNative.test.tsx | 7 +- .../DdSdkReactNativeConfiguration.test.ts | 3 + .../core/src/config/FileBasedConfiguration.ts | 2 + .../src/config/FileBasedConfiguration.type.ts | 1 + .../__tests__/FileBasedConfiguration.test.ts | 3 + .../configuration-all-fields.json | 1 + .../async/AutoInstrumentationConfiguration.ts | 5 + .../src/config/features/RumConfiguration.ts | 4 + .../config/features/RumConfiguration.type.ts | 8 + .../DdRumResourceTracking.tsx | 51 +++- .../__tests__/DdRumResourceTracking.test.ts | 101 +++++++ .../requestProxy/FetchProxy/FetchProxy.ts | 282 ++++++++++++++++++ .../FetchProxy/__tests__/FetchProxy.test.ts | 249 ++++++++++++++++ .../__tests__/ResourceReporter.test.ts | 2 +- .../internalDevResourceBlocklist.test.ts | 2 +- .../requestProxy/XHRProxy/XHRProxy.ts | 164 +++------- .../XHRProxy/__tests__/XHRProxy.test.ts | 2 +- .../requestProxy/common/FetchProxyState.ts | 25 ++ .../requestProxy/common/RequestContext.ts | 54 ++++ .../ResourceReporter.ts | 10 +- .../internalDevResourceBlocklist.ts | 18 +- .../requestProxy/common/requestHeaders.ts | 106 +++++++ .../resourceTiming.ts | 18 -- .../__tests__/initialization.test.tsx | 1 + 27 files changed, 960 insertions(+), 174 deletions(-) create mode 100644 packages/core/src/rum/instrumentation/resourceTracking/requestProxy/FetchProxy/FetchProxy.ts create mode 100644 packages/core/src/rum/instrumentation/resourceTracking/requestProxy/FetchProxy/__tests__/FetchProxy.test.ts create mode 100644 packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/FetchProxyState.ts create mode 100644 packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/RequestContext.ts rename packages/core/src/rum/instrumentation/resourceTracking/requestProxy/{XHRProxy/DatadogRumResource => common}/ResourceReporter.ts (91%) rename packages/core/src/rum/instrumentation/resourceTracking/requestProxy/{XHRProxy/DatadogRumResource => common}/internalDevResourceBlocklist.ts (58%) create mode 100644 packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/requestHeaders.ts rename packages/core/src/rum/instrumentation/resourceTracking/requestProxy/{XHRProxy/DatadogRumResource => common}/resourceTiming.ts (64%) diff --git a/packages/core/README.md b/packages/core/README.md index 38582ceb2..60978e9ae 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -47,6 +47,7 @@ const datadogConfiguration = new DatadogProviderConfiguration( applicationId: '', trackInteractions: true, // track User interactions (e.g.: Tap on buttons. You can use 'accessibilityLabel' element property to give tap action the name, otherwise element type will be reported) trackResources: true, // track XHR Resources + trackFetchResources: true, // Optional: also track requests made with the global Expo Fetch implementation trackFrustrations: true, // track Frustrations trackErrors: true, // track errors nativeCrashReportEnabled: true, // Optional: enable or disable native crash reports @@ -80,6 +81,10 @@ export default function App() { } ``` +`trackFetchResources` tracks calls made through the Expo-installed global +`fetch`. Direct imports retained from `expo/fetch` are not intercepted. XHR +tracking remains enabled for clients such as axios. + ### Track view navigation Because React Native offers a wide range of libraries to create screen navigation, by default only manual View tracking is supported. You can manually start and stop a View using the following `startView()` and `stopView` methods. diff --git a/packages/core/datadog-configuration.schema.json b/packages/core/datadog-configuration.schema.json index be0a2d84e..992e72f3e 100644 --- a/packages/core/datadog-configuration.schema.json +++ b/packages/core/datadog-configuration.schema.json @@ -176,6 +176,10 @@ "description": "Track React Native resources.", "type": "boolean" }, + "trackFetchResources": { + "description": "Track requests made with supported native Fetch implementations, such as the global Fetch installed by Expo. Only applies when resource tracking is enabled.", + "type": "boolean" + }, "trackErrors": { "description": "Track React Native errors.", "type": "boolean" diff --git a/packages/core/src/DdSdkReactNative.tsx b/packages/core/src/DdSdkReactNative.tsx index 4c8042815..2f1941193 100644 --- a/packages/core/src/DdSdkReactNative.tsx +++ b/packages/core/src/DdSdkReactNative.tsx @@ -507,6 +507,9 @@ export class DdSdkReactNative { const trackResources = configuration.rumConfiguration?.trackResources || RUM_DEFAULTS.trackResources; + const trackFetchResources = + configuration.rumConfiguration?.trackFetchResources ?? + RUM_DEFAULTS.trackFetchResources; const trackErrors = configuration.rumConfiguration?.trackErrors || RUM_DEFAULTS.trackErrors; @@ -553,7 +556,8 @@ export class DdSdkReactNative { if (trackResources) { DdRumResourceTracking.startTracking({ resourceTraceSampleRate, - firstPartyHosts + firstPartyHosts, + trackFetchResources }); } diff --git a/packages/core/src/__tests__/DdSdkReactNative.test.tsx b/packages/core/src/__tests__/DdSdkReactNative.test.tsx index 4a3902d32..433035fb0 100644 --- a/packages/core/src/__tests__/DdSdkReactNative.test.tsx +++ b/packages/core/src/__tests__/DdSdkReactNative.test.tsx @@ -614,7 +614,9 @@ describe('DdSdkReactNative', () => { configuration.rumConfiguration = new RumConfiguration( fakeAppId, false, - true + true, + false, + { trackFetchResources: true } ); configuration.rumConfiguration.resourceTraceSampleRate = 42; configuration.rumConfiguration.firstPartyHosts = [ @@ -676,7 +678,8 @@ describe('DdSdkReactNative', () => { match: 'something.fr', propagatorTypes: ['datadog'] } - ] + ], + trackFetchResources: true }); }); diff --git a/packages/core/src/__tests__/DdSdkReactNativeConfiguration.test.ts b/packages/core/src/__tests__/DdSdkReactNativeConfiguration.test.ts index 207d9abdd..7f8a6e231 100644 --- a/packages/core/src/__tests__/DdSdkReactNativeConfiguration.test.ts +++ b/packages/core/src/__tests__/DdSdkReactNativeConfiguration.test.ts @@ -73,6 +73,7 @@ describe('DdSdkReactNativeConfiguration', () => { "telemetrySampleRate": 20, "trackBackgroundEvents": false, "trackErrors": false, + "trackFetchResources": false, "trackFrustrations": true, "trackInteractions": false, "trackMemoryWarnings": true, @@ -217,6 +218,7 @@ describe('DdSdkReactNativeConfiguration', () => { "telemetrySampleRate": 20, "trackBackgroundEvents": true, "trackErrors": true, + "trackFetchResources": false, "trackFrustrations": true, "trackInteractions": true, "trackMemoryWarnings": true, @@ -320,6 +322,7 @@ describe('DdSdkReactNativeConfiguration', () => { "telemetrySampleRate": 0, "trackBackgroundEvents": false, "trackErrors": false, + "trackFetchResources": false, "trackFrustrations": false, "trackInteractions": false, "trackMemoryWarnings": false, diff --git a/packages/core/src/config/FileBasedConfiguration.ts b/packages/core/src/config/FileBasedConfiguration.ts index c226050c0..cd89c7c07 100644 --- a/packages/core/src/config/FileBasedConfiguration.ts +++ b/packages/core/src/config/FileBasedConfiguration.ts @@ -170,6 +170,8 @@ export const getJSONConfiguration = ( trackInteractions: configuration.rumConfiguration.trackInteractions, trackResources: configuration.rumConfiguration.trackResources, + trackFetchResources: + configuration.rumConfiguration.trackFetchResources, trackErrors: configuration.rumConfiguration.trackErrors, nativeLongTaskThresholdMs: configuration.rumConfiguration.nativeLongTaskThresholdMs, diff --git a/packages/core/src/config/FileBasedConfiguration.type.ts b/packages/core/src/config/FileBasedConfiguration.type.ts index 693fa5738..b248fe2f4 100644 --- a/packages/core/src/config/FileBasedConfiguration.type.ts +++ b/packages/core/src/config/FileBasedConfiguration.type.ts @@ -34,6 +34,7 @@ export interface JsonConfiguration extends CoreConfigurationOptions { useAccessibilityLabel?: boolean; trackInteractions?: boolean; trackResources?: boolean; + trackFetchResources?: boolean; trackErrors?: boolean; longTaskThresholdMs?: number; actionNameAttribute?: string; diff --git a/packages/core/src/config/__tests__/FileBasedConfiguration.test.ts b/packages/core/src/config/__tests__/FileBasedConfiguration.test.ts index 5b062447f..1b801d8c5 100644 --- a/packages/core/src/config/__tests__/FileBasedConfiguration.test.ts +++ b/packages/core/src/config/__tests__/FileBasedConfiguration.test.ts @@ -67,6 +67,7 @@ describe('FileBasedConfiguration', () => { "telemetrySampleRate": 20, "trackBackgroundEvents": true, "trackErrors": true, + "trackFetchResources": true, "trackFrustrations": true, "trackInteractions": true, "trackMemoryWarnings": false, @@ -179,6 +180,7 @@ describe('FileBasedConfiguration', () => { "telemetrySampleRate": 20, "trackBackgroundEvents": false, "trackErrors": true, + "trackFetchResources": false, "trackFrustrations": true, "trackInteractions": true, "trackMemoryWarnings": true, @@ -243,6 +245,7 @@ describe('FileBasedConfiguration', () => { "telemetrySampleRate": 20, "trackBackgroundEvents": false, "trackErrors": false, + "trackFetchResources": false, "trackFrustrations": true, "trackInteractions": false, "trackMemoryWarnings": true, diff --git a/packages/core/src/config/__tests__/__fixtures__/configuration-all-fields.json b/packages/core/src/config/__tests__/__fixtures__/configuration-all-fields.json index 3b5d15d59..6a4f7623a 100644 --- a/packages/core/src/config/__tests__/__fixtures__/configuration-all-fields.json +++ b/packages/core/src/config/__tests__/__fixtures__/configuration-all-fields.json @@ -10,6 +10,7 @@ "useAccessibilityLabel": false, "trackInteractions": true, "trackResources": true, + "trackFetchResources": true, "trackErrors": true, "longTaskThresholdMs": 44, "trackNonFatalAnrs": true, diff --git a/packages/core/src/config/async/AutoInstrumentationConfiguration.ts b/packages/core/src/config/async/AutoInstrumentationConfiguration.ts index 199aaa4eb..b049e10bd 100644 --- a/packages/core/src/config/async/AutoInstrumentationConfiguration.ts +++ b/packages/core/src/config/async/AutoInstrumentationConfiguration.ts @@ -20,6 +20,7 @@ export type AutoInstrumentationConfiguration = { readonly rumConfiguration: { readonly trackInteractions: boolean; readonly trackResources: boolean; + readonly trackFetchResources?: boolean; readonly trackErrors: boolean; readonly useAccessibilityLabel?: boolean; readonly actionNameAttribute?: string; @@ -45,6 +46,7 @@ export type AutoInstrumentationParameters = { readonly useAccessibilityLabel: boolean; readonly trackInteractions: boolean; readonly trackResources: boolean; + readonly trackFetchResources: boolean; readonly trackErrors: boolean; readonly actionNameAttribute?: string; readonly resourceTraceSampleRate?: number; @@ -81,6 +83,9 @@ export const addDefaultValuesToAutoInstrumentationConfiguration = ( trackResources: features.rumConfiguration.trackResources ?? RUM_DEFAULTS.trackResources, + trackFetchResources: + features.rumConfiguration.trackFetchResources ?? + RUM_DEFAULTS.trackFetchResources, trackErrors: features.rumConfiguration.trackErrors ?? RUM_DEFAULTS.trackErrors, diff --git a/packages/core/src/config/features/RumConfiguration.ts b/packages/core/src/config/features/RumConfiguration.ts index 3ab70409c..9d6e6b965 100644 --- a/packages/core/src/config/features/RumConfiguration.ts +++ b/packages/core/src/config/features/RumConfiguration.ts @@ -33,6 +33,7 @@ const DEFAULTS = { telemetrySampleRate: 20.0, trackBackgroundEvents: false, trackErrors: false, + trackFetchResources: false, trackFrustrations: true, trackInteractions: false, trackMemoryWarnings: true, @@ -103,6 +104,9 @@ export class RumConfiguration implements RumConfigurationType { // Track Background Events Enabled public trackBackgroundEvents: boolean = DEFAULTS.trackBackgroundEvents; + // Track native Fetch resources + public trackFetchResources: boolean = DEFAULTS.trackFetchResources; + // Track Frustrations Enabled public trackFrustrations: boolean = DEFAULTS.trackFrustrations; diff --git a/packages/core/src/config/features/RumConfiguration.type.ts b/packages/core/src/config/features/RumConfiguration.type.ts index 0a2094ece..40509aaa6 100644 --- a/packages/core/src/config/features/RumConfiguration.type.ts +++ b/packages/core/src/config/features/RumConfiguration.type.ts @@ -66,6 +66,14 @@ export interface RumConfigurationOptions { */ errorEventMapper?: ErrorEventMapper | null; + /** + * Enables tracking of requests made with native Fetch implementations, + * such as the global Fetch installed by Expo. + * + * This option only takes effect when resource tracking is enabled. + */ + trackFetchResources?: boolean; + /** * List of backend hosts used to enable tracing. */ diff --git a/packages/core/src/rum/instrumentation/resourceTracking/DdRumResourceTracking.tsx b/packages/core/src/rum/instrumentation/resourceTracking/DdRumResourceTracking.tsx index 5db4153a6..7204bc5c4 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/DdRumResourceTracking.tsx +++ b/packages/core/src/rum/instrumentation/resourceTracking/DdRumResourceTracking.tsx @@ -13,6 +13,7 @@ import type { FirstPartyHost } from '../../types'; import { DistributedTracingSampling } from './distributedTracing/distributedTracingSampling'; import { firstPartyHostsRegexMapBuilder } from './distributedTracing/firstPartyHosts'; +import { FetchProxy } from './requestProxy/FetchProxy/FetchProxy'; import { XHRProxy } from './requestProxy/XHRProxy/XHRProxy'; import type { RequestProxy } from './requestProxy/interfaces/RequestProxy'; @@ -24,7 +25,7 @@ const RUM_RESOURCE_TRACKING_MODULE = */ class RumResourceTracking { private _isTracking = false; - private _requestProxy: RequestProxy | null = null; + private _requestProxies: RequestProxy[] = []; private _maxSampledTraceId: BigInt.BigInteger | null = null; get isTracking(): boolean { @@ -40,33 +41,55 @@ class RumResourceTracking { */ startTracking({ resourceTraceSampleRate, - firstPartyHosts + firstPartyHosts, + trackFetchResources = false }: { resourceTraceSampleRate: number; firstPartyHosts: FirstPartyHost[]; + trackFetchResources?: boolean; }): void { // extra safety to avoid proxying the XHR class twice if (this._isTracking) { InternalLog.log( - 'Datadog SDK is already tracking XHR resources', + 'Datadog SDK is already tracking resources', SdkVerbosity.WARN ); return; } - this._requestProxy = XHRProxy.createWithResourceReporter(); - this._requestProxy.onTrackingStart({ + const requestProxyOptions = { tracingSamplingRate: resourceTraceSampleRate, firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder( firstPartyHosts ) - }); + }; + + const xhrProxy = XHRProxy.createWithResourceReporter(); + xhrProxy.onTrackingStart(requestProxyOptions); + this._requestProxies.push(xhrProxy); InternalLog.log( 'Datadog SDK is tracking XHR resources', SdkVerbosity.INFO ); + if (trackFetchResources) { + if (typeof globalThis.fetch !== 'function') { + InternalLog.log( + 'Datadog SDK did not install Fetch resource tracking because global Fetch is not available', + SdkVerbosity.INFO + ); + } else { + const fetchProxy = FetchProxy.createWithResourceReporter(); + fetchProxy.onTrackingStart(requestProxyOptions); + this._requestProxies.push(fetchProxy); + InternalLog.log( + 'Datadog SDK is tracking Fetch resources', + SdkVerbosity.INFO + ); + } + } + this._isTracking = true; DistributedTracingSampling.setResourceTraceSampleRate( resourceTraceSampleRate @@ -85,11 +108,13 @@ class RumResourceTracking { }: { resourceTraceSampleRate: number; }): void { - if (!this._isTracking || !this._requestProxy) { + if (!this._isTracking) { return; } - this._requestProxy.onTrackingUpdate({ - tracingSamplingRate: resourceTraceSampleRate + this._requestProxies.forEach(requestProxy => { + requestProxy.onTrackingUpdate({ + tracingSamplingRate: resourceTraceSampleRate + }); }); // Keep the distributed-tracing sampler's max-trace-id in sync; the // shouldSampleTrace path consults this for rates strictly between 0 @@ -102,10 +127,10 @@ class RumResourceTracking { stopTracking(): void { if (this._isTracking) { this._isTracking = false; - if (this._requestProxy) { - this._requestProxy.onTrackingStop(); - } - this._requestProxy = null; + this._requestProxies.forEach(requestProxy => + requestProxy.onTrackingStop() + ); + this._requestProxies = []; this._maxSampledTraceId = null; } } diff --git a/packages/core/src/rum/instrumentation/resourceTracking/__tests__/DdRumResourceTracking.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/__tests__/DdRumResourceTracking.test.ts index f48f6fd51..d578fbb57 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/__tests__/DdRumResourceTracking.test.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/__tests__/DdRumResourceTracking.test.ts @@ -14,6 +14,7 @@ import { SAMPLING_PRIORITY_HEADER_KEY } from '../distributedTracing/headers'; import { XMLHttpRequestMock } from './__utils__/XMLHttpRequestMock'; const DdRum = NativeModules.DdRum; +const originalFetch = global.fetch; const flushPromises = () => new Promise(jest.requireActual('timers').setImmediate); @@ -26,7 +27,9 @@ beforeEach(() => { }); afterEach(() => { + DdRumResourceTracking.stopTracking(); global.XMLHttpRequest = undefined; + global.fetch = originalFetch; }); const executeRequest = (url: string = 'https://api.example.com/v2/user') => { @@ -92,6 +95,104 @@ describe('DdRumResourceTracking', () => { expect(DdRum.stopResource).not.toHaveBeenCalled(); }); + it('tracks Expo Fetch and XHR resources when Fetch tracking is enabled', async () => { + const fetchResponse = ({ + status: 200, + headers: { + get: (header: string) => + header.toLowerCase() === 'content-length' ? '12' : null + } + } as unknown) as Response; + const expoFetch = jest + .fn() + .mockResolvedValue(fetchResponse) as jest.MockedFunction< + typeof fetch + >; + global.fetch = expoFetch; + DdRumResourceTracking.startTracking({ + resourceTraceSampleRate: 100, + firstPartyHosts: [], + trackFetchResources: true + }); + + executeRequest(); + await global.fetch('https://api.example.com/v2/user'); + await flushPromises(); + + expect(DdRum.startResource).toHaveBeenCalledTimes(2); + expect(DdRum.stopResource).toHaveBeenCalledTimes(2); + expect(DdRum.stopResource.mock.calls.map(call => call[2])).toEqual( + expect.arrayContaining(['xhr', 'fetch']) + ); + expect(expoFetch).toHaveBeenCalledTimes(1); + }); + + it('tracks a native Fetch implementation without relying on an Expo marker', () => { + const fetchImplementation = jest.fn() as jest.MockedFunction< + typeof fetch + >; + global.fetch = fetchImplementation; + + DdRumResourceTracking.startTracking({ + resourceTraceSampleRate: 100, + firstPartyHosts: [], + trackFetchResources: true + }); + + expect(global.fetch).not.toBe(fetchImplementation); + }); + + it('does not wrap Expo Fetch when Fetch tracking is disabled', () => { + const expoFetch = jest.fn() as jest.MockedFunction; + global.fetch = expoFetch; + + DdRumResourceTracking.startTracking({ + resourceTraceSampleRate: 100, + firstPartyHosts: [] + }); + + expect(global.fetch).toBe(expoFetch); + }); + + it('does not double report an XHR-backed Fetch and still tracks direct XHR', async () => { + const fetchResponse = ({ + status: 200, + headers: { get: () => null } + } as unknown) as Response; + const xhrBackedFetch = jest.fn( + (input: RequestInfo | URL, init?: RequestInit) => { + const xhr = new XMLHttpRequestMock(); + xhr.open(init?.method ?? 'GET', String(input)); + new Headers(init?.headers).forEach((value, header) => { + xhr.setRequestHeader(header, value); + }); + xhr.send(); + xhr.notifyResponseArrived(); + xhr.complete(200, 'ok'); + return Promise.resolve(fetchResponse); + } + ) as jest.MockedFunction; + global.fetch = xhrBackedFetch; + + DdRumResourceTracking.startTracking({ + resourceTraceSampleRate: 100, + firstPartyHosts: [], + trackFetchResources: true + }); + + await global.fetch('https://api.example.com/fetch'); + executeRequest('https://api.example.com/xhr'); + await flushPromises(); + + expect(global.fetch).not.toBe(xhrBackedFetch); + expect(xhrBackedFetch).toHaveBeenCalledTimes(1); + expect(DdRum.startResource).toHaveBeenCalledTimes(2); + expect(DdRum.stopResource).toHaveBeenCalledTimes(2); + expect(DdRum.stopResource.mock.calls.map(call => call[2])).toEqual( + expect.arrayContaining(['fetch', 'xhr']) + ); + }); + describe('updateTrackingContext', () => { beforeEach(() => { DdRumResourceTracking.stopTracking(); diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/FetchProxy/FetchProxy.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/FetchProxy/FetchProxy.ts new file mode 100644 index 000000000..f60b40486 --- /dev/null +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/FetchProxy/FetchProxy.ts @@ -0,0 +1,282 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import { callOriginalFetch } from '../common/FetchProxyState'; +import type { RequestContext } from '../common/RequestContext'; +import { createRequestContext } from '../common/RequestContext'; +import { ResourceReporter } from '../common/ResourceReporter'; +import { filterDevResource } from '../common/internalDevResourceBlocklist'; +import { + getInstrumentationHeaders, + processRequestHeader +} from '../common/requestHeaders'; +import type { RequestProxyOptions } from '../interfaces/RequestProxy'; +import { RequestProxy } from '../interfaces/RequestProxy'; + +const RESPONSE_START_LABEL = 'response_start'; +const MISSING_RESOURCE_SIZE = -1; + +type FetchGlobal = { + fetch: typeof fetch; +}; + +interface FetchProxyProviders { + fetchGlobal: FetchGlobal; + headersType: typeof Headers; + resourceReporter: ResourceReporter; +} + +/** + * Proxies global Fetch implementations. + */ +export class FetchProxy extends RequestProxy { + private context: RequestProxyOptions | null = null; + private originalFetch: typeof fetch | null = null; + private installedFetch: typeof fetch | null = null; + + constructor(private providers: FetchProxyProviders) { + super(); + } + + static createWithResourceReporter() { + return new FetchProxy({ + fetchGlobal: globalThis, + headersType: Headers, + resourceReporter: new ResourceReporter([filterDevResource]) + }); + } + + onTrackingStart = (context: RequestProxyOptions) => { + this.context = context; + this.originalFetch = this.providers.fetchGlobal.fetch; + + const installedFetch: typeof fetch = (input, init) => { + return trackFetch({ + input, + init, + originalFetch: this.originalFetch as typeof fetch, + fetchThis: this.providers.fetchGlobal, + headersType: this.providers.headersType, + resourceReporter: this.providers.resourceReporter, + options: context + }); + }; + + this.installedFetch = installedFetch; + this.providers.fetchGlobal.fetch = installedFetch; + }; + + onTrackingStop = () => { + if ( + this.originalFetch !== null && + this.providers.fetchGlobal.fetch === this.installedFetch + ) { + this.providers.fetchGlobal.fetch = this.originalFetch; + } + + this.context = null; + this.originalFetch = null; + this.installedFetch = null; + }; + + onTrackingUpdate = (options: { tracingSamplingRate: number }) => { + if (this.context === null) { + return; + } + this.context.tracingSamplingRate = options.tracingSamplingRate; + }; +} + +const trackFetch = async ({ + input, + init, + originalFetch, + fetchThis, + headersType, + resourceReporter, + options +}: { + input: Parameters[0]; + init: Parameters[1]; + originalFetch: typeof fetch; + fetchThis: FetchGlobal; + headersType: typeof Headers; + resourceReporter: ResourceReporter; + options: RequestProxyOptions; +}): Promise => { + const url = getRequestUrl(input); + const method = getRequestMethod(input, init); + const context = createRequestContext({ method, url, options }); + const headers = buildHeaders({ input, init, context, headersType }); + + context.timer.start(); + + try { + const response = await callOriginalFetch(() => + originalFetch.call(fetchThis, input, { + ...(init ?? {}), + headers + }) + ); + + context.timer.recordTick(RESPONSE_START_LABEL); + context.timer.stop(); + reportFetch({ context, response, resourceReporter }); + return response; + } catch (error) { + context.timer.stop(); + reportFetchFailure({ context, resourceReporter }); + throw error; + } +}; + +const getRequestUrl = (input: Parameters[0]): string => { + if (typeof input === 'string') { + return input; + } + + if ( + typeof input === 'object' && + input !== null && + 'url' in input && + typeof input.url === 'string' + ) { + return input.url; + } + + return String(input); +}; + +const getRequestMethod = ( + input: Parameters[0], + init?: Parameters[1] +): string => { + const requestMethod = + typeof input === 'object' && + input !== null && + 'method' in input && + typeof input.method === 'string' + ? input.method + : undefined; + + return (init?.method ?? requestMethod ?? 'GET').toUpperCase(); +}; + +const buildHeaders = ({ + input, + init, + context, + headersType +}: { + input: Parameters[0]; + init?: Parameters[1]; + context: RequestContext; + headersType: typeof Headers; +}): Headers => { + const requestHeaders = + typeof input === 'object' && input !== null && 'headers' in input + ? input.headers + : undefined; + const headers: Headers = new headersType(init?.headers ?? requestHeaders); + const originalHeaders: { header: string; value: string }[] = []; + + headers.forEach((value: string, header: string) => { + originalHeaders.push({ header, value }); + }); + + originalHeaders.forEach(({ header }) => headers.delete(header)); + originalHeaders.forEach(({ header, value }) => { + applyHeader({ headers, context, header, value }); + }); + getInstrumentationHeaders(context).forEach(({ header, value }) => { + applyHeader({ headers, context, header, value }); + }); + + return headers; +}; + +const applyHeader = ({ + headers, + context, + header, + value +}: { + headers: Headers; + context: RequestContext; + header: string; + value: string; +}) => { + const processedHeader = processRequestHeader({ context, header, value }); + if (processedHeader.type === 'send') { + headers.set(processedHeader.header, processedHeader.value); + } +}; + +const reportFetch = ({ + context, + response, + resourceReporter +}: { + context: RequestContext; + response: Response; + resourceReporter: ResourceReporter; +}) => { + resourceReporter.reportResource({ + key: `${context.timer.startTime}/${context.method}`, + request: { + method: context.method, + url: context.url, + kind: 'fetch' + }, + graphqlAttributes: context.graphql, + tracingAttributes: context.tracingAttributes, + response: { + statusCode: response.status, + size: getResponseSize(response) + }, + timings: { + startTime: context.timer.startTime, + stopTime: context.timer.stopTime, + responseStartTime: context.timer.timeAt(RESPONSE_START_LABEL) + } + }); +}; + +const reportFetchFailure = ({ + context, + resourceReporter +}: { + context: RequestContext; + resourceReporter: ResourceReporter; +}) => { + resourceReporter.reportResource({ + key: `${context.timer.startTime}/${context.method}`, + request: { + method: context.method, + url: context.url, + kind: 'fetch' + }, + graphqlAttributes: context.graphql, + tracingAttributes: context.tracingAttributes, + response: { + statusCode: 0, + size: MISSING_RESOURCE_SIZE + }, + timings: { + startTime: context.timer.startTime, + stopTime: context.timer.stopTime + } + }); +}; + +const getResponseSize = (response: Response): number => { + const contentLength = response.headers.get('content-length'); + if (contentLength === null || !/^\d+$/.test(contentLength)) { + return MISSING_RESOURCE_SIZE; + } + + return Number(contentLength); +}; diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/FetchProxy/__tests__/FetchProxy.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/FetchProxy/__tests__/FetchProxy.test.ts new file mode 100644 index 000000000..7bcc7be96 --- /dev/null +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/FetchProxy/__tests__/FetchProxy.test.ts @@ -0,0 +1,249 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import { PropagatorType } from '../../../../../types'; +import { firstPartyHostsRegexMapBuilder } from '../../../distributedTracing/firstPartyHosts'; +import { + PARENT_ID_HEADER_KEY, + SAMPLING_PRIORITY_HEADER_KEY, + TRACKED_BY_HEADER_KEY, + TRACKED_BY_HEADER_VALUE +} from '../../../distributedTracing/headers'; +import { DATADOG_GRAPH_QL_OPERATION_TYPE_HEADER } from '../../../graphql/graphqlHeaders'; +import type { ResourceReporter } from '../../common/ResourceReporter'; +import type { RUMResource } from '../../interfaces/RumResource'; +import { FetchProxy } from '../FetchProxy'; + +class HeadersMock { + private values = new Map(); + + constructor(init?: HeadersInit) { + if (init instanceof HeadersMock) { + init.forEach((value, header) => this.set(header, value)); + } else if (Array.isArray(init)) { + init.forEach(([header, value]) => this.set(header, value)); + } else if (init && typeof init === 'object' && 'forEach' in init) { + (init as Headers).forEach((value, header) => + this.set(header, value) + ); + } else if (init) { + Object.entries(init).forEach(([header, value]) => + this.set(header, value) + ); + } + } + + delete(header: string) { + this.values.delete(header.toLowerCase()); + } + + forEach(callback: (value: string, header: string) => void) { + this.values.forEach((value, header) => callback(value, header)); + } + + get(header: string): string | null { + return this.values.get(header.toLowerCase()) ?? null; + } + + set(header: string, value: string) { + this.values.set(header.toLowerCase(), String(value)); + } +} + +const getOptions = (traceSampleRate = 100) => ({ + tracingSamplingRate: traceSampleRate, + firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([ + { + match: 'api.example.com', + propagatorTypes: [PropagatorType.DATADOG] + } + ]) +}); + +const createResponse = ({ + status = 200, + headers = {} +}: { + status?: number; + headers?: Record; +} = {}) => { + return ({ + status, + headers: new HeadersMock(headers) + } as unknown) as Response; +}; + +const createProxy = ({ + originalFetch, + reportResource +}: { + originalFetch: typeof fetch; + reportResource: jest.Mock; +}) => { + const fetchGlobal = { fetch: originalFetch }; + const proxy = new FetchProxy({ + fetchGlobal, + headersType: (HeadersMock as unknown) as typeof Headers, + resourceReporter: ({ + reportResource + } as unknown) as ResourceReporter + }); + + return { fetchGlobal, proxy }; +}; + +describe('FetchProxy', () => { + it('reports a successful Fetch resource without replacing the response', async () => { + const response = createResponse({ + status: 201, + headers: { 'content-length': '42' } + }); + const originalFetch = jest + .fn() + .mockResolvedValue(response) as jest.MockedFunction; + const reportResource = jest.fn(); + const { fetchGlobal, proxy } = createProxy({ + originalFetch, + reportResource + }); + proxy.onTrackingStart(getOptions()); + + const result = await fetchGlobal.fetch( + 'https://api.example.com/users', + { + method: 'post' + } + ); + + expect(result).toBe(response); + expect(reportResource).toHaveBeenCalledTimes(1); + expect(reportResource).toHaveBeenCalledWith( + expect.objectContaining({ + request: { + method: 'POST', + url: 'https://api.example.com/users', + kind: 'fetch' + }, + response: { + statusCode: 201, + size: 42 + } + }) + ); + + const outgoingHeaders = (originalFetch.mock.calls[0][1] + ?.headers as unknown) as HeadersMock; + expect(outgoingHeaders.get(TRACKED_BY_HEADER_KEY)).toBe( + TRACKED_BY_HEADER_VALUE + ); + expect(outgoingHeaders.get(SAMPLING_PRIORITY_HEADER_KEY)).toBe('1'); + expect(outgoingHeaders.get(PARENT_ID_HEADER_KEY)).not.toBeNull(); + }); + + it('extracts GraphQL metadata and removes its internal header', async () => { + const originalFetch = jest + .fn() + .mockResolvedValue(createResponse()) as jest.MockedFunction< + typeof fetch + >; + const reportResource = jest.fn(); + const { fetchGlobal, proxy } = createProxy({ + originalFetch, + reportResource + }); + proxy.onTrackingStart(getOptions()); + + await fetchGlobal.fetch('https://api.example.com/graphql', { + headers: { + [DATADOG_GRAPH_QL_OPERATION_TYPE_HEADER]: 'query', + accept: 'application/json' + } + }); + + const resource = reportResource.mock.calls[0][0] as RUMResource; + expect(resource.graphqlAttributes?.operationType).toBe('query'); + const outgoingHeaders = (originalFetch.mock.calls[0][1] + ?.headers as unknown) as HeadersMock; + expect( + outgoingHeaders.get(DATADOG_GRAPH_QL_OPERATION_TYPE_HEADER) + ).toBeNull(); + expect(outgoingHeaders.get('accept')).toBe('application/json'); + }); + + it('reports a failed resource and rethrows the original rejection', async () => { + const rejection = new Error('network failed'); + const originalFetch = jest + .fn() + .mockRejectedValue(rejection) as jest.MockedFunction; + const reportResource = jest.fn(); + const { fetchGlobal, proxy } = createProxy({ + originalFetch, + reportResource + }); + proxy.onTrackingStart(getOptions()); + + await expect( + fetchGlobal.fetch('https://api.example.com/users') + ).rejects.toBe(rejection); + expect(reportResource).toHaveBeenCalledWith( + expect.objectContaining({ + response: { + statusCode: 0, + size: -1 + } + }) + ); + }); + + it('applies sampling updates to subsequent Fetch requests', async () => { + const originalFetch = jest + .fn() + .mockResolvedValue(createResponse()) as jest.MockedFunction< + typeof fetch + >; + const { fetchGlobal, proxy } = createProxy({ + originalFetch, + reportResource: jest.fn() + }); + proxy.onTrackingStart(getOptions(0)); + proxy.onTrackingUpdate({ tracingSamplingRate: 100 }); + + await fetchGlobal.fetch('https://api.example.com/users'); + + const outgoingHeaders = (originalFetch.mock.calls[0][1] + ?.headers as unknown) as HeadersMock; + expect(outgoingHeaders.get(SAMPLING_PRIORITY_HEADER_KEY)).toBe('1'); + }); + + it('restores the original Fetch when tracking stops', () => { + const originalFetch = jest.fn() as jest.MockedFunction; + const { fetchGlobal, proxy } = createProxy({ + originalFetch, + reportResource: jest.fn() + }); + proxy.onTrackingStart(getOptions()); + + expect(fetchGlobal.fetch).not.toBe(originalFetch); + proxy.onTrackingStop(); + + expect(fetchGlobal.fetch).toBe(originalFetch); + }); + + it('does not overwrite a Fetch wrapper installed after Datadog', () => { + const originalFetch = jest.fn() as jest.MockedFunction; + const { fetchGlobal, proxy } = createProxy({ + originalFetch, + reportResource: jest.fn() + }); + proxy.onTrackingStart(getOptions()); + const laterFetch = jest.fn() as jest.MockedFunction; + fetchGlobal.fetch = laterFetch; + + proxy.onTrackingStop(); + + expect(fetchGlobal.fetch).toBe(laterFetch); + }); +}); diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/DatadogRumResource/__tests__/ResourceReporter.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/DatadogRumResource/__tests__/ResourceReporter.test.ts index a2c147603..194944986 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/DatadogRumResource/__tests__/ResourceReporter.test.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/DatadogRumResource/__tests__/ResourceReporter.test.ts @@ -7,8 +7,8 @@ import { NativeModules } from 'react-native'; import { BufferSingleton } from '../../../../../../../sdk/DatadogProvider/Buffer/BufferSingleton'; +import { ResourceReporter } from '../../../common/ResourceReporter'; import type { RUMResource } from '../../../interfaces/RumResource'; -import { ResourceReporter } from '../ResourceReporter'; import { ResourceMockFactory } from './__utils__/ResourceMockFactory'; diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/DatadogRumResource/__tests__/internalDevResourceBlocklist.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/DatadogRumResource/__tests__/internalDevResourceBlocklist.test.ts index a8dc73ea2..0483b2a74 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/DatadogRumResource/__tests__/internalDevResourceBlocklist.test.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/DatadogRumResource/__tests__/internalDevResourceBlocklist.test.ts @@ -4,7 +4,7 @@ * Copyright 2016-Present Datadog, Inc. */ -import { filterDevResource } from '../internalDevResourceBlocklist'; +import { filterDevResource } from '../../../common/internalDevResourceBlocklist'; import { ResourceMockFactory } from './__utils__/ResourceMockFactory'; diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/XHRProxy.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/XHRProxy.ts index 4f42292d7..d413ceb2d 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/XHRProxy.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/XHRProxy.ts @@ -6,56 +6,30 @@ import { InternalLog } from '../../../../../InternalLog'; import { SdkVerbosity } from '../../../../../config/types'; -import { Timer } from '../../../../../utils/Timer'; -import { - getCachedAccountId, - getCachedSessionId, - getCachedUserId -} from '../../../../helper'; -import type { DdRumResourceTracingAttributes } from '../../distributedTracing/distributedTracingAttributes'; -import { getTracingHeadersFromAttributes } from '../../distributedTracing/distributedTracingHeaders'; -import { getTracingAttributes } from '../../distributedTracing/distributedTracing'; -import { - BAGGAGE_HEADER_KEY, - TRACKED_BY_HEADER_KEY, - TRACKED_BY_HEADER_VALUE -} from '../../distributedTracing/headers'; -import { - DATADOG_GRAPH_QL_ERROR_HEADER, - DATADOG_GRAPH_QL_OPERATION_NAME_HEADER, - DATADOG_GRAPH_QL_OPERATION_TYPE_HEADER, - DATADOG_GRAPH_QL_PAYLOAD_HEADER, - DATADOG_GRAPH_QL_VARIABLES_HEADER -} from '../../graphql/graphqlHeaders'; import { extractGraphQLErrors } from '../../graphql/graphqlUtils'; -import { DATADOG_BAGGAGE_HEADER, isDatadogCustomHeader } from '../../headers'; +import { isRunningWithinFetchProxy } from '../common/FetchProxyState'; +import type { RequestContext } from '../common/RequestContext'; +import { createRequestContext } from '../common/RequestContext'; +import { ResourceReporter } from '../common/ResourceReporter'; +import { filterDevResource } from '../common/internalDevResourceBlocklist'; +import { + getInstrumentationHeaders, + processRequestHeader +} from '../common/requestHeaders'; import type { RequestProxyOptions } from '../interfaces/RequestProxy'; import { RequestProxy } from '../interfaces/RequestProxy'; -import type { DdRumResourceGraphqlAttributes } from '../interfaces/RumResource'; -import { ResourceReporter } from './DatadogRumResource/ResourceReporter'; -import { filterDevResource } from './DatadogRumResource/internalDevResourceBlocklist'; -import { URLHostParser } from './URLHostParser'; -import { formatBaggageHeader } from './baggageHeaderUtils'; import { calculateResponseSize } from './responseSize'; import { getErrorData, readXhrJsonBody } from './xhrUtils'; const RESPONSE_START_LABEL = 'response_start'; interface DdRumXhr extends XMLHttpRequest { - _datadog_xhr: DdRumXhrContext; + _datadog_xhr?: DdRumXhrContext; } -interface DdRumXhrContext { - graphql: DdRumResourceGraphqlAttributes & { - trackErrors?: boolean; - }; - method: string; - url: string; +interface DdRumXhrContext extends RequestContext { reported: boolean; - timer: Timer; - tracingAttributes: DdRumResourceTracingAttributes; - baggageHeaderEntries: Set; } interface XHRProxyProviders { @@ -129,24 +103,17 @@ const proxyOpen = ( method: string, url: string ) { - const hostname = URLHostParser(url); + if (isRunningWithinFetchProxy()) { + this._datadog_xhr = undefined; + // eslint-disable-next-line prefer-rest-params + return originalXhrOpen.apply(this, arguments as any); + } + // Keep track of the method and url // start time is tracked by the `send` method this._datadog_xhr = { - method, - url, - reported: false, - timer: new Timer(), - graphql: {}, - tracingAttributes: getTracingAttributes({ - hostname, - firstPartyHostsRegexMap: context.firstPartyHostsRegexMap, - tracingSamplingRate: context.tracingSamplingRate, - rumSessionId: getCachedSessionId(), - userId: getCachedUserId(), - accountId: getCachedAccountId() - }), - baggageHeaderEntries: new Set() + ...createRequestContext({ method, url, options: context }), + reported: false }; // eslint-disable-next-line prefer-rest-params return originalXhrOpen.apply(this, arguments as any); @@ -162,31 +129,14 @@ const proxySend = (providers: XHRProxyProviders): void => { // keep track of start time this._datadog_xhr.timer.start(); - // Tracing Headers - const tracingHeaders = getTracingHeadersFromAttributes( - this._datadog_xhr.tracingAttributes - ); - - tracingHeaders.forEach(({ header, value }) => { - this.setRequestHeader(header, value); - }); - - // Join all baggage header entries - const baggageHeader = formatBaggageHeader( - this._datadog_xhr.baggageHeaderEntries - ); - if (baggageHeader) { - this.setRequestHeader(DATADOG_BAGGAGE_HEADER, baggageHeader); - } - - this.setRequestHeader( - TRACKED_BY_HEADER_KEY, - TRACKED_BY_HEADER_VALUE + getInstrumentationHeaders(this._datadog_xhr).forEach( + ({ header, value }) => { + this.setRequestHeader(header, value); + } ); + proxyOnReadyStateChange(this, providers); } - proxyOnReadyStateChange(this, providers); - // eslint-disable-next-line prefer-rest-params return originalXhrSend.apply(this, arguments as any); }; @@ -197,11 +147,15 @@ const proxyOnReadyStateChange = ( providers: XHRProxyProviders ): void => { const xhrType = providers.xhrType; + const requestContext = xhrProxy._datadog_xhr; + if (!requestContext) { + return; + } const originalOnreadystatechange = xhrProxy.onreadystatechange; xhrProxy.onreadystatechange = function onreadystatechange() { if (xhrProxy.readyState === xhrType.DONE) { - if (!xhrProxy._datadog_xhr.reported) { + if (!requestContext.reported) { reportXhr(xhrProxy, providers.resourceReporter).catch(error => { const errorData = getErrorData(error); if (errorData) { @@ -211,10 +165,10 @@ const proxyOnReadyStateChange = ( ); } }); - xhrProxy._datadog_xhr.reported = true; + requestContext.reported = true; } } else if (xhrProxy.readyState === xhrType.HEADERS_RECEIVED) { - xhrProxy._datadog_xhr.timer.recordTick(RESPONSE_START_LABEL); + requestContext.timer.recordTick(RESPONSE_START_LABEL); } if (originalOnreadystatechange) { @@ -231,6 +185,9 @@ const reportXhr = async ( const responseSize = calculateResponseSize(xhrProxy); const context = xhrProxy._datadog_xhr; + if (!context) { + return; + } const key = `${context.timer.startTime}/${context.method}`; @@ -290,44 +247,21 @@ const proxySetRequestHeader = (providers: XHRProxyProviders): void => { header: string, value: string ) { - const key = header.toLowerCase(); - if (isDatadogCustomHeader(key)) { - switch (key) { - case DATADOG_GRAPH_QL_OPERATION_NAME_HEADER: - this._datadog_xhr.graphql.operationName = value; - break; - case DATADOG_GRAPH_QL_OPERATION_TYPE_HEADER: - this._datadog_xhr.graphql.operationType = value; - break; - case DATADOG_GRAPH_QL_VARIABLES_HEADER: - this._datadog_xhr.graphql.variables = value; - break; - case DATADOG_GRAPH_QL_PAYLOAD_HEADER: - this._datadog_xhr.graphql.payload = value; - break; - case DATADOG_GRAPH_QL_ERROR_HEADER: - this._datadog_xhr.graphql.trackErrors = - value === 'true' || value === '1'; - break; - case DATADOG_BAGGAGE_HEADER: - // Apply Baggage Header only if pre-processed by Datadog - return originalXhrSetRequestHeader.apply(this, [ - BAGGAGE_HEADER_KEY, - value - ]); - default: - return originalXhrSetRequestHeader.apply( - this, - // eslint-disable-next-line prefer-rest-params - arguments as any - ); - } - } else if (key === BAGGAGE_HEADER_KEY) { - // Intercept User Baggage Header entries to apply them later - this._datadog_xhr.baggageHeaderEntries?.add(value); - } else { - // eslint-disable-next-line prefer-rest-params - return originalXhrSetRequestHeader.apply(this, arguments as any); + if (!this._datadog_xhr) { + return originalXhrSetRequestHeader.apply(this, [header, value]); + } + + const processedHeader = processRequestHeader({ + context: this._datadog_xhr, + header, + value + }); + + if (processedHeader.type === 'send') { + return originalXhrSetRequestHeader.apply(this, [ + processedHeader.header, + processedHeader.value + ]); } }; }; diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/__tests__/XHRProxy.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/__tests__/XHRProxy.test.ts index 9d5c82ca2..18ce799ba 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/__tests__/XHRProxy.test.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/__tests__/XHRProxy.test.ts @@ -43,7 +43,7 @@ import { DATADOG_GRAPH_QL_OPERATION_TYPE_HEADER, DATADOG_GRAPH_QL_VARIABLES_HEADER } from '../../../graphql/graphqlHeaders'; -import { ResourceReporter } from '../DatadogRumResource/ResourceReporter'; +import { ResourceReporter } from '../../common/ResourceReporter'; import { XHRProxy } from '../XHRProxy'; import { calculateResponseSize, diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/FetchProxyState.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/FetchProxyState.ts new file mode 100644 index 000000000..b713591d2 --- /dev/null +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/FetchProxyState.ts @@ -0,0 +1,25 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +let fetchProxyCallDepth = 0; + +/** + * Calls the original Fetch implementation while marking its synchronous work. + * XHR-backed Fetch implementations create and send their XMLHttpRequest before + * returning a Promise, so the XHR proxy can avoid reporting that request twice. + */ +export const callOriginalFetch = (callback: () => T): T => { + fetchProxyCallDepth += 1; + try { + return callback(); + } finally { + fetchProxyCallDepth -= 1; + } +}; + +export const isRunningWithinFetchProxy = (): boolean => { + return fetchProxyCallDepth > 0; +}; diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/RequestContext.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/RequestContext.ts new file mode 100644 index 000000000..b09001a06 --- /dev/null +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/RequestContext.ts @@ -0,0 +1,54 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import { Timer } from '../../../../../utils/Timer'; +import { + getCachedAccountId, + getCachedSessionId, + getCachedUserId +} from '../../../../helper'; +import type { DdRumResourceTracingAttributes } from '../../distributedTracing/distributedTracingAttributes'; +import { getTracingAttributes } from '../../distributedTracing/distributedTracing'; +import { URLHostParser } from '../XHRProxy/URLHostParser'; +import type { RequestProxyOptions } from '../interfaces/RequestProxy'; +import type { DdRumResourceGraphqlAttributes } from '../interfaces/RumResource'; + +export interface RequestContext { + graphql: DdRumResourceGraphqlAttributes & { + trackErrors?: boolean; + }; + method: string; + url: string; + timer: Timer; + tracingAttributes: DdRumResourceTracingAttributes; + baggageHeaderEntries: Set; +} + +export const createRequestContext = ({ + method, + url, + options +}: { + method: string; + url: string; + options: RequestProxyOptions; +}): RequestContext => { + return { + method, + url, + timer: new Timer(), + graphql: {}, + tracingAttributes: getTracingAttributes({ + hostname: URLHostParser(url), + firstPartyHostsRegexMap: options.firstPartyHostsRegexMap, + tracingSamplingRate: options.tracingSamplingRate, + rumSessionId: getCachedSessionId(), + userId: getCachedUserId(), + accountId: getCachedAccountId() + }), + baggageHeaderEntries: new Set() + }; +}; diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/DatadogRumResource/ResourceReporter.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/ResourceReporter.ts similarity index 91% rename from packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/DatadogRumResource/ResourceReporter.ts rename to packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/ResourceReporter.ts index abbf07513..ba0f2f293 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/DatadogRumResource/ResourceReporter.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/ResourceReporter.ts @@ -4,9 +4,9 @@ * Copyright 2016-Present Datadog, Inc. */ -import { DdRum } from '../../../../../DdRum'; -import { TracingIdFormat } from '../../../distributedTracing/TracingIdentifier'; -import type { RUMResource } from '../../interfaces/RumResource'; +import { DdRum } from '../../../../DdRum'; +import { TracingIdFormat } from '../../distributedTracing/TracingIdentifier'; +import type { RUMResource } from '../interfaces/RumResource'; import { createTimings } from './resourceTiming'; @@ -15,8 +15,8 @@ type ResourceMapper = (resource: RUMResource) => RUMResource | null; export class ResourceReporter { private mappers: ResourceMapper[]; - constructor(resourceMappers: ResourceMapper[]) { - this.mappers = resourceMappers; + constructor(mappers: ResourceMapper[]) { + this.mappers = mappers; } reportResource = (resource: RUMResource) => { diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/DatadogRumResource/internalDevResourceBlocklist.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/internalDevResourceBlocklist.ts similarity index 58% rename from packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/DatadogRumResource/internalDevResourceBlocklist.ts rename to packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/internalDevResourceBlocklist.ts index c8dae8ef5..e5d877bd2 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/DatadogRumResource/internalDevResourceBlocklist.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/internalDevResourceBlocklist.ts @@ -4,24 +4,12 @@ * Copyright 2016-Present Datadog, Inc. */ -import type { RUMResource } from '../../interfaces/RumResource'; +import type { RUMResource } from '../interfaces/RumResource'; -/** - * Expo sends all console.* calls to the packager. As we log all API calls - * when the SDKVerbosity is DEBUG, this would result in an infinite loop of - * `console.log` and API calls, creating 250 RUM resources per second. - * - * The hostname is always going to be localhost or a local IP. - * - * An example URL is http://192.168.1.20:8081/logs or http://10.46.29.155:19000/logs - */ const EXPO_DEV_LOGS_REGEX = new RegExp( '^http://((10|172|192).[0-9]+.[0-9]+.[0-9]+|localhost|127.0.0.1):808[0-9]/logs$' ); -/** - * This call is made every time the RN packager reloads the js in dev mode. - */ const RN_PACKAGER_SYMBOLICATE_REGEX = new RegExp( '^http://localhost:808[0-9]/symbolicate$' ); @@ -31,10 +19,6 @@ const internalDevResourceBlocklist: RegExp[] = [ RN_PACKAGER_SYMBOLICATE_REGEX ]; -/** - * Filters RN symbolicate calls and Expo logs calls that happen only in dev. - * @param resource RUMResource - */ export const filterDevResource = ( resource: RUMResource ): RUMResource | null => { diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/requestHeaders.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/requestHeaders.ts new file mode 100644 index 000000000..d8032a269 --- /dev/null +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/requestHeaders.ts @@ -0,0 +1,106 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import { getTracingHeadersFromAttributes } from '../../distributedTracing/distributedTracingHeaders'; +import { + BAGGAGE_HEADER_KEY, + TRACKED_BY_HEADER_KEY, + TRACKED_BY_HEADER_VALUE +} from '../../distributedTracing/headers'; +import { + DATADOG_GRAPH_QL_ERROR_HEADER, + DATADOG_GRAPH_QL_OPERATION_NAME_HEADER, + DATADOG_GRAPH_QL_OPERATION_TYPE_HEADER, + DATADOG_GRAPH_QL_PAYLOAD_HEADER, + DATADOG_GRAPH_QL_VARIABLES_HEADER +} from '../../graphql/graphqlHeaders'; +import { DATADOG_BAGGAGE_HEADER, isDatadogCustomHeader } from '../../headers'; +import { formatBaggageHeader } from '../XHRProxy/baggageHeaderUtils'; + +import type { RequestContext } from './RequestContext'; + +export type ProcessedRequestHeader = + | { type: 'drop' } + | { type: 'send'; header: string; value: string }; + +export const processRequestHeader = ({ + context, + header, + value +}: { + context: RequestContext; + header: string; + value: string; +}): ProcessedRequestHeader => { + const key = header.toLowerCase(); + + if (isDatadogCustomHeader(key)) { + switch (key) { + case DATADOG_GRAPH_QL_OPERATION_NAME_HEADER: + context.graphql.operationName = value; + return { type: 'drop' }; + case DATADOG_GRAPH_QL_OPERATION_TYPE_HEADER: + context.graphql.operationType = value; + return { type: 'drop' }; + case DATADOG_GRAPH_QL_VARIABLES_HEADER: + context.graphql.variables = value; + return { type: 'drop' }; + case DATADOG_GRAPH_QL_PAYLOAD_HEADER: + context.graphql.payload = value; + return { type: 'drop' }; + case DATADOG_GRAPH_QL_ERROR_HEADER: + context.graphql.trackErrors = value === 'true' || value === '1'; + return { type: 'drop' }; + case DATADOG_BAGGAGE_HEADER: + return { + type: 'send', + header: BAGGAGE_HEADER_KEY, + value + }; + default: + return { type: 'send', header, value }; + } + } + + if (key === BAGGAGE_HEADER_KEY) { + context.baggageHeaderEntries.add(value); + return { type: 'drop' }; + } + + return { type: 'send', header, value }; +}; + +export const getInstrumentationHeaders = ( + context: RequestContext +): { header: string; value: string }[] => { + const headers: { header: string; value: string }[] = []; + getTracingHeadersFromAttributes(context.tracingAttributes).forEach( + ({ header, value }) => { + if (header.toLowerCase() === BAGGAGE_HEADER_KEY) { + context.baggageHeaderEntries.add(value); + } else { + headers.push({ header, value }); + } + } + ); + const baggageHeader = formatBaggageHeader(context.baggageHeaderEntries); + + if (baggageHeader) { + headers.push({ + // Use the internal header so XHR can distinguish SDK-generated + // baggage from user-provided baggage during interception. + header: DATADOG_BAGGAGE_HEADER, + value: baggageHeader + }); + } + + headers.push({ + header: TRACKED_BY_HEADER_KEY, + value: TRACKED_BY_HEADER_VALUE + }); + + return headers; +}; diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/DatadogRumResource/resourceTiming.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/resourceTiming.ts similarity index 64% rename from packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/DatadogRumResource/resourceTiming.ts rename to packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/resourceTiming.ts index 61a7c3bff..d24bf1316 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/DatadogRumResource/resourceTiming.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/resourceTiming.ts @@ -7,23 +7,13 @@ import { Platform } from 'react-native'; interface Timing { - /** - * Time relative (absolute in case of iOS) to some point, in ns. - */ startTime: number; - /** - * Duration in ns. - */ duration: number; } interface ResourceTimings { - // unlike in Performance API it is not the time until request - // starts (requestStart, before it can be connect, SSL, DNS), - // but the time until the response is first seen firstByte: Timing; download: Timing; - // required by iOS, total timing from the beginning to the end fetch: Timing; } @@ -38,7 +28,6 @@ export function createTimings( responseStartTime, responseEndTime ); - // needed for iOS, simply total duration from start to end const fetch = formatTiming(startTime, startTime, responseEndTime); return { @@ -48,16 +37,9 @@ export function createTimings( }; } -/** - * @param origin Start time (absolute) of the request - * @param start Start time (absolute) of the timing - * @param end End time (absolute) of the timing - */ function formatTiming(origin: number, start: number, end: number): Timing { return { duration: timeToNanos(end - start), - // if it is Android, startTime should be relative to the origin, - // if it is iOS - absolute (unix timestamp) startTime: Platform.OS === 'ios' ? timeToNanos(start) diff --git a/packages/core/src/sdk/DatadogProvider/__tests__/initialization.test.tsx b/packages/core/src/sdk/DatadogProvider/__tests__/initialization.test.tsx index 1380fb0c2..224e49f0a 100644 --- a/packages/core/src/sdk/DatadogProvider/__tests__/initialization.test.tsx +++ b/packages/core/src/sdk/DatadogProvider/__tests__/initialization.test.tsx @@ -104,6 +104,7 @@ describe('DatadogProvider', () => { "telemetrySampleRate": 20, "trackBackgroundEvents": false, "trackErrors": true, + "trackFetchResources": false, "trackFrustrations": true, "trackInteractions": true, "trackMemoryWarnings": true, From 839644bbb424fd69f0152240fb68d059231ea5e4 Mon Sep 17 00:00:00 2001 From: Marco Saia Date: Thu, 13 Aug 2026 16:41:45 +0200 Subject: [PATCH 2/2] pr(fix): improve fetch tracking support --- packages/core/README.md | 5 + .../requestProxy/FetchProxy/FetchProxy.ts | 44 ++++- .../FetchProxy/__tests__/FetchProxy.test.ts | 152 +++++++++++++++++- 3 files changed, 192 insertions(+), 9 deletions(-) diff --git a/packages/core/README.md b/packages/core/README.md index 60978e9ae..6b61e7510 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -85,6 +85,11 @@ export default function App() { `fetch`. Direct imports retained from `expo/fetch` are not intercepted. XHR tracking remains enabled for clients such as axios. +GraphQL error extraction (via `DatadogLink({ trackErrors: true })`) for +requests made through `expo/fetch` requires Expo SDK 56 or later, as older +versions of `expo/fetch` do not implement `Response.clone()`. On earlier +versions, GraphQL metadata is still reported, but response errors are not. + ### Track view navigation Because React Native offers a wide range of libraries to create screen navigation, by default only manual View tracking is supported. You can manually start and stop a View using the following `startView()` and `stopView` methods. diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/FetchProxy/FetchProxy.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/FetchProxy/FetchProxy.ts index f60b40486..87c4c7df8 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/FetchProxy/FetchProxy.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/FetchProxy/FetchProxy.ts @@ -4,6 +4,10 @@ * Copyright 2016-Present Datadog, Inc. */ +import { InternalLog } from '../../../../../InternalLog'; +import { SdkVerbosity } from '../../../../../config/types'; +import { extractGraphQLErrors } from '../../graphql/graphqlUtils'; +import { getErrorData } from '../XHRProxy/xhrUtils'; import { callOriginalFetch } from '../common/FetchProxyState'; import type { RequestContext } from '../common/RequestContext'; import { createRequestContext } from '../common/RequestContext'; @@ -51,13 +55,14 @@ export class FetchProxy extends RequestProxy { onTrackingStart = (context: RequestProxyOptions) => { this.context = context; - this.originalFetch = this.providers.fetchGlobal.fetch; + const originalFetch = this.providers.fetchGlobal.fetch; + this.originalFetch = originalFetch; const installedFetch: typeof fetch = (input, init) => { return trackFetch({ input, init, - originalFetch: this.originalFetch as typeof fetch, + originalFetch, fetchThis: this.providers.fetchGlobal, headersType: this.providers.headersType, resourceReporter: this.providers.resourceReporter, @@ -124,7 +129,15 @@ const trackFetch = async ({ context.timer.recordTick(RESPONSE_START_LABEL); context.timer.stop(); - reportFetch({ context, response, resourceReporter }); + reportFetch({ context, response, resourceReporter }).catch(error => { + const errorData = getErrorData(error); + if (errorData) { + InternalLog.log( + `reportFetch failed: ${errorData}`, + SdkVerbosity.WARN + ); + } + }); return response; } catch (error) { context.timer.stop(); @@ -215,7 +228,7 @@ const applyHeader = ({ } }; -const reportFetch = ({ +const reportFetch = async ({ context, response, resourceReporter @@ -223,7 +236,28 @@ const reportFetch = ({ context: RequestContext; response: Response; resourceReporter: ResourceReporter; -}) => { +}): Promise => { + // Only extract GraphQL errors if operationType is set AND error tracking is enabled + if (context.graphql.operationType && context.graphql.trackErrors) { + try { + const body = await response.clone().json(); + + const errors = body?.errors; + if (Array.isArray(errors) && errors.length > 0) { + const filtered = extractGraphQLErrors(errors); + + if (filtered.length > 0) { + context.graphql.errors = filtered; + } + } + } catch (error) { + const errorData = getErrorData(error); + if (errorData) { + InternalLog.log(`reportFetch: ${errorData}`, SdkVerbosity.WARN); + } + } + } + resourceReporter.reportResource({ key: `${context.timer.startTime}/${context.method}`, request: { diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/FetchProxy/__tests__/FetchProxy.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/FetchProxy/__tests__/FetchProxy.test.ts index 7bcc7be96..f50d8f744 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/FetchProxy/__tests__/FetchProxy.test.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/FetchProxy/__tests__/FetchProxy.test.ts @@ -12,7 +12,10 @@ import { TRACKED_BY_HEADER_KEY, TRACKED_BY_HEADER_VALUE } from '../../../distributedTracing/headers'; -import { DATADOG_GRAPH_QL_OPERATION_TYPE_HEADER } from '../../../graphql/graphqlHeaders'; +import { + DATADOG_GRAPH_QL_ERROR_HEADER, + DATADOG_GRAPH_QL_OPERATION_TYPE_HEADER +} from '../../../graphql/graphqlHeaders'; import type { ResourceReporter } from '../../common/ResourceReporter'; import type { RUMResource } from '../../interfaces/RumResource'; import { FetchProxy } from '../FetchProxy'; @@ -65,17 +68,28 @@ const getOptions = (traceSampleRate = 100) => ({ const createResponse = ({ status = 200, - headers = {} + headers = {}, + body }: { status?: number; headers?: Record; + body?: unknown; } = {}) => { - return ({ + const response = ({ status, - headers: new HeadersMock(headers) + headers: new HeadersMock(headers), + clone() { + return response; + }, + json: () => Promise.resolve(body) } as unknown) as Response; + + return response; }; +const flushPromises = () => + new Promise(jest.requireActual('timers').setImmediate); + const createProxy = ({ originalFetch, reportResource @@ -246,4 +260,134 @@ describe('FetchProxy', () => { expect(fetchGlobal.fetch).toBe(laterFetch); }); + + it('keeps a Fetch wrapper retained by another library callable after tracking stops', async () => { + const response = createResponse(); + const originalFetch = jest + .fn() + .mockResolvedValue(response) as jest.MockedFunction; + const { fetchGlobal, proxy } = createProxy({ + originalFetch, + reportResource: jest.fn() + }); + proxy.onTrackingStart(getOptions()); + + // Another library installs its own wrapper after Datadog, capturing + // Datadog's installed Fetch as its own "original" delegate. + const capturedDatadogFetch = fetchGlobal.fetch; + fetchGlobal.fetch = ((input, init) => + capturedDatadogFetch(input, init)) as typeof fetch; + + proxy.onTrackingStop(); + + await expect( + capturedDatadogFetch('https://api.example.com/users') + ).resolves.toBe(response); + }); + + describe('GraphQL error filtering', () => { + it('extracts GraphQL errors from the response body when error tracking is enabled', async () => { + const graphqlResponse = { + data: { user: null }, + errors: [ + { + message: 'User not found', + locations: [{ line: 2, column: 3 }], + path: ['user', 0, 'id'], + extensions: { code: 'NOT_FOUND' } + } + ] + }; + const originalFetch = jest + .fn() + .mockResolvedValue( + createResponse({ body: graphqlResponse }) + ) as jest.MockedFunction; + const reportResource = jest.fn(); + const { fetchGlobal, proxy } = createProxy({ + originalFetch, + reportResource + }); + proxy.onTrackingStart(getOptions()); + + await fetchGlobal.fetch('https://api.example.com/graphql', { + headers: { + [DATADOG_GRAPH_QL_OPERATION_TYPE_HEADER]: 'query', + [DATADOG_GRAPH_QL_ERROR_HEADER]: 'true' + } + }); + await flushPromises(); + + const resource = reportResource.mock.calls[0][0] as RUMResource; + expect(resource.graphqlAttributes?.errors).toEqual([ + { + message: 'User not found', + code: 'NOT_FOUND', + locations: [{ line: 2, column: 3 }], + path: ['user', 0, 'id'] + } + ]); + }); + + it('reports without errors when the Fetch implementation does not support clone()', async () => { + // Some Fetch implementations (e.g. `expo/fetch` prior to Expo SDK + // 56) throw on `response.clone()`. + const response = createResponse({ body: { data: {} } }); + response.clone = () => { + throw new Error('Not implemented'); + }; + const originalFetch = jest + .fn() + .mockResolvedValue(response) as jest.MockedFunction< + typeof fetch + >; + const reportResource = jest.fn(); + const { fetchGlobal, proxy } = createProxy({ + originalFetch, + reportResource + }); + proxy.onTrackingStart(getOptions()); + + await expect( + fetchGlobal.fetch('https://api.example.com/graphql', { + headers: { + [DATADOG_GRAPH_QL_OPERATION_TYPE_HEADER]: 'query', + [DATADOG_GRAPH_QL_ERROR_HEADER]: 'true' + } + }) + ).resolves.toBe(response); + await flushPromises(); + + const resource = reportResource.mock.calls[0][0] as RUMResource; + expect(resource.graphqlAttributes?.operationType).toBe('query'); + expect(resource.graphqlAttributes?.errors).toBeUndefined(); + }); + + it('does not read the response body when error tracking is disabled', async () => { + const response = createResponse({ body: { data: {} } }); + const cloneSpy = jest.spyOn(response, 'clone'); + const originalFetch = jest + .fn() + .mockResolvedValue(response) as jest.MockedFunction< + typeof fetch + >; + const reportResource = jest.fn(); + const { fetchGlobal, proxy } = createProxy({ + originalFetch, + reportResource + }); + proxy.onTrackingStart(getOptions()); + + await fetchGlobal.fetch('https://api.example.com/graphql', { + headers: { + [DATADOG_GRAPH_QL_OPERATION_TYPE_HEADER]: 'query' + } + }); + await flushPromises(); + + expect(cloneSpy).not.toHaveBeenCalled(); + const resource = reportResource.mock.calls[0][0] as RUMResource; + expect(resource.graphqlAttributes?.errors).toBeUndefined(); + }); + }); });