diff --git a/packages/core/README.md b/packages/core/README.md index 38582ceb2..6b61e7510 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,15 @@ 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. + +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/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..87c4c7df8 --- /dev/null +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/FetchProxy/FetchProxy.ts @@ -0,0 +1,316 @@ +/* + * 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 { 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'; +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; + const originalFetch = this.providers.fetchGlobal.fetch; + this.originalFetch = originalFetch; + + const installedFetch: typeof fetch = (input, init) => { + return trackFetch({ + input, + init, + originalFetch, + 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 }).catch(error => { + const errorData = getErrorData(error); + if (errorData) { + InternalLog.log( + `reportFetch failed: ${errorData}`, + SdkVerbosity.WARN + ); + } + }); + 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 = async ({ + context, + response, + resourceReporter +}: { + 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: { + 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..f50d8f744 --- /dev/null +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/FetchProxy/__tests__/FetchProxy.test.ts @@ -0,0 +1,393 @@ +/* + * 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_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'; + +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 = {}, + body +}: { + status?: number; + headers?: Record; + body?: unknown; +} = {}) => { + const response = ({ + status, + 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 +}: { + 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); + }); + + 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(); + }); + }); +}); 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,