diff --git a/packages/core/src/DdSdkReactNative.tsx b/packages/core/src/DdSdkReactNative.tsx index 4c8042815..ae1d06945 100644 --- a/packages/core/src/DdSdkReactNative.tsx +++ b/packages/core/src/DdSdkReactNative.tsx @@ -553,7 +553,12 @@ export class DdSdkReactNative { if (trackResources) { DdRumResourceTracking.startTracking({ resourceTraceSampleRate, - firstPartyHosts + firstPartyHosts, + resourceEventMapper, + resourceReporters: { + startResource: DdRum.startResource, + stopResource: DdRum.stopResource + } }); } diff --git a/packages/core/src/__tests__/DdSdkReactNative.test.tsx b/packages/core/src/__tests__/DdSdkReactNative.test.tsx index 4a3902d32..478fdb3ea 100644 --- a/packages/core/src/__tests__/DdSdkReactNative.test.tsx +++ b/packages/core/src/__tests__/DdSdkReactNative.test.tsx @@ -47,7 +47,10 @@ jest.mock( () => { return { DdRumResourceTracking: { - startTracking: jest.fn().mockImplementation(() => {}) + startTracking: jest.fn().mockImplementation(() => {}), + updateResourceEventMapper: jest + .fn() + .mockImplementation(() => {}) } }; } @@ -75,6 +78,9 @@ beforeEach(async () => { (DdRumResourceTracking.startTracking as jest.MockedFunction< typeof DdRumResourceTracking.startTracking >).mockClear(); + (DdRumResourceTracking.updateResourceEventMapper as jest.MockedFunction< + typeof DdRumResourceTracking.updateResourceEventMapper + >).mockClear(); (DdRumErrorTracking.startTracking as jest.MockedFunction< typeof DdRumErrorTracking.startTracking >).mockClear(); @@ -676,7 +682,12 @@ describe('DdSdkReactNative', () => { match: 'something.fr', propagatorTypes: ['datadog'] } - ] + ], + resourceEventMapper: null, + resourceReporters: { + startResource: DdRum.startResource, + stopResource: DdRum.stopResource + } }); }); @@ -835,6 +846,15 @@ describe('DdSdkReactNative', () => { await DdRum.stopResource('key', 200, 'xhr', 22, {}, 345); // THEN + expect(NativeModules.DdRum.startResource).toHaveBeenCalledWith( + 'key', + 'GET', + 'https://datadoghq.com', + { + body: 'content' + }, + 234 + ); expect(NativeModules.DdRum.stopResource).toHaveBeenCalledWith( 'key', 200, diff --git a/packages/core/src/rum/DdRum.ts b/packages/core/src/rum/DdRum.ts index 9a7f15dae..59bd245a1 100644 --- a/packages/core/src/rum/DdRum.ts +++ b/packages/core/src/rum/DdRum.ts @@ -33,6 +33,7 @@ import { getCachedUserId, setCachedSessionId } from './helper'; +import { DdRumResourceTracking } from './instrumentation/resourceTracking/DdRumResourceTracking'; import type { DatadogTracingContext } from './instrumentation/resourceTracking/distributedTracing/DatadogTracingContext'; import { DatadogTracingIdentifier } from './instrumentation/resourceTracking/distributedTracing/DatadogTracingIdentifier'; import { TracingIdentifier } from './instrumentation/resourceTracking/distributedTracing/TracingIdentifier'; @@ -49,6 +50,7 @@ import type { } from './types'; const RUM_MODULE = 'com.datadog.reactnative.rum'; +const DROP_RESOURCE_ATTRIBUTE = '_dd.resource.drop_resource'; const generateEmptyPromise = () => new Promise(resolve => resolve()); @@ -85,6 +87,7 @@ class DdRumWrapper implements DdRumType { private resourceEventMapper = generateResourceEventMapper(undefined); private actionEventMapper = generateActionEventMapper(undefined); private timeProvider: TimeProvider = new DefaultTimeProvider(); + private startedResourceKeys = new Set(); startView = ( key: string, @@ -251,10 +254,42 @@ class DdRumWrapper implements DdRumType { method: string, url: string, context: object = {}, - timestampMs: number = this.timeProvider.now() + timestampMs: number = this.timeProvider.now(), + kind: ResourceKind = 'other', + resourceContext?: XMLHttpRequest ): Promise => { + const resourceContextForMapper = resourceContext + ? { ...resourceContext, responseURL: url } + : ({ responseURL: url } as XMLHttpRequest); + + const mappedEvent = this.resourceEventMapper.applyEventMapper({ + key, + statusCode: 0, + kind, + size: -1, + context, + timestampMs, + resourceContext: resourceContextForMapper + }); + /** + * The keep/drop decision is made here, where the request URL is + * available through `resourceContext.responseURL` for both XHR and + * manual resources. When the mapper drops the event (non-first-party + * host or missing consent) the resource is never started, so the + * matching `stopResource` is a native no-op. We always use the + * original `key` for the native call so start/stop correlate, and only + * sanitize the displayed URL and context. + */ + if (!mappedEvent) { + this.startedResourceKeys.delete(key); + return generateEmptyPromise(); + } + + const startUrl = mappedEvent.resourceContext?.responseURL || url; + this.startedResourceKeys.add(key); + InternalLog.log( - `Starting RUM Resource #${key} ${method}: ${url}`, + `Starting RUM Resource #${key} ${method}: ${startUrl}`, SdkVerbosity.DEBUG ); @@ -262,8 +297,8 @@ class DdRumWrapper implements DdRumType { this.nativeRum.startResource( key, method, - url, - encodeAttributes(context), + startUrl, + encodeAttributes(mappedEvent.context), timestampMs ) ); @@ -287,38 +322,36 @@ class DdRumWrapper implements DdRumType { timestampMs, resourceContext }); - if (!mappedEvent) { - /** - * To drop the resource we call `stopResource` and pass the `_dd.drop_resource` attribute in the context. - * It will be picked up by the resource mappers we implement on the native side that will drop the resource. - * This ensures we don't have any "started" resource left in memory on the native side. - */ - return bufferVoidNativeCall(() => - this.nativeRum.stopResource( - key, - statusCode, - kind, - size, - { - '_dd.resource.drop_resource': true - }, - timestampMs - ) - ); + const wasStarted = this.startedResourceKeys.delete(key); + + if (!wasStarted) { + return generateEmptyPromise(); } + const nativeResource = mappedEvent ?? { + key, + statusCode, + kind, + size, + context: { + [DROP_RESOURCE_ATTRIBUTE]: true + }, + timestampMs, + resourceContext + }; + InternalLog.log( `Stopping RUM Resource #${key} status:${statusCode}`, SdkVerbosity.DEBUG ); return bufferVoidNativeCall(() => this.nativeRum.stopResource( - mappedEvent.key, - mappedEvent.statusCode, - mappedEvent.kind, - mappedEvent.size, - encodeAttributes(mappedEvent.context), - mappedEvent.timestampMs + nativeResource.key, + nativeResource.statusCode, + nativeResource.kind, + nativeResource.size, + encodeAttributes(nativeResource.context), + nativeResource.timestampMs ) ); }; @@ -507,10 +540,12 @@ class DdRumWrapper implements DdRumType { this.resourceEventMapper = generateResourceEventMapper( resourceEventMapper ); + DdRumResourceTracking.updateResourceEventMapper(resourceEventMapper); } unregisterResourceEventMapper() { this.resourceEventMapper = generateResourceEventMapper(undefined); + DdRumResourceTracking.updateResourceEventMapper(undefined); } registerActionEventMapper(actionEventMapper: ActionEventMapper) { diff --git a/packages/core/src/rum/__tests__/DdRum.test.ts b/packages/core/src/rum/__tests__/DdRum.test.ts index 7d0a3688b..c70679ae2 100644 --- a/packages/core/src/rum/__tests__/DdRum.test.ts +++ b/packages/core/src/rum/__tests__/DdRum.test.ts @@ -401,6 +401,53 @@ describe('DdRum', () => { expect.anything() ); }); + + test('passes the provided resource kind to the event mapper', async () => { + const resourceEventMapper: jest.MockedFunction = jest.fn( + resource => resource + ); + DdRum.registerResourceEventMapper(resourceEventMapper); + + await DdRum.startResource( + 'key', + 'GET', + 'https://my-api.com/', + {}, + 234, + 'fetch' + ); + + expect(resourceEventMapper).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'fetch' + }) + ); + + DdRum.unregisterResourceEventMapper(); + }); + + test('uses other as the default resource kind for event mapper', async () => { + const resourceEventMapper: jest.MockedFunction = jest.fn( + resource => resource + ); + DdRum.registerResourceEventMapper(resourceEventMapper); + + await DdRum.startResource( + 'key', + 'GET', + 'https://my-api.com/', + {}, + 234 + ); + + expect(resourceEventMapper).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'other' + }) + ); + + DdRum.unregisterResourceEventMapper(); + }); }); describe('DdRum.stopResource', () => { @@ -1673,7 +1720,7 @@ describe('DdRum', () => { ); }); - it('adds the drop context key to the event if the mapper returns null', async () => { + it('does not start or stop the resource if the mapper returns null', async () => { const resourceEventMapper: ResourceEventMapper = resource => { return null; }; @@ -1696,12 +1743,40 @@ describe('DdRum', () => { 245 ); + expect(NativeModules.DdRum.startResource).not.toHaveBeenCalled(); + expect(NativeModules.DdRum.stopResource).not.toHaveBeenCalled(); + }); + + it('stops a started native resource when the mapper returns null at stop', async () => { + let shouldDropResource = false; + const resourceEventMapper: ResourceEventMapper = resource => { + if (shouldDropResource) { + return null; + } + return resource; + }; + DdRum.registerResourceEventMapper(resourceEventMapper); + + await DdRum.startResource( + 'key', + 'GET', + 'https://my-api.com/', + { retry: false }, + 234 + ); + + shouldDropResource = true; + await DdRum.stopResource('key', 200, 'xhr', 302, {}, 245); + + expect(NativeModules.DdRum.startResource).toHaveBeenCalled(); expect(NativeModules.DdRum.stopResource).toHaveBeenCalledWith( 'key', 200, 'xhr', 302, - { '_dd.resource.drop_resource': true }, + { + '_dd.resource.drop_resource': true + }, 245 ); }); diff --git a/packages/core/src/rum/eventMappers/resourceEventMapper.ts b/packages/core/src/rum/eventMappers/resourceEventMapper.ts index dd070be84..e43bfc744 100644 --- a/packages/core/src/rum/eventMappers/resourceEventMapper.ts +++ b/packages/core/src/rum/eventMappers/resourceEventMapper.ts @@ -18,7 +18,7 @@ type RawResource = { resourceContext: XMLHttpRequest | undefined; }; -type ResourceEvent = RawResource & AdditionalEventDataForMapper; +export type ResourceEvent = RawResource & AdditionalEventDataForMapper; type NativeResource = { key: string; @@ -27,6 +27,7 @@ type NativeResource = { size: number; context: object; timestampMs: number; + resourceContext: XMLHttpRequest | undefined; }; export type ResourceEventMapper = ( diff --git a/packages/core/src/rum/instrumentation/resourceTracking/DdRumResourceTracking.tsx b/packages/core/src/rum/instrumentation/resourceTracking/DdRumResourceTracking.tsx index 5db4153a6..c1d35948b 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/DdRumResourceTracking.tsx +++ b/packages/core/src/rum/instrumentation/resourceTracking/DdRumResourceTracking.tsx @@ -9,10 +9,12 @@ import BigInt from 'big-integer'; import { InternalLog } from '../../../InternalLog'; import { SdkVerbosity } from '../../../config/types/SdkVerbosity'; import { getGlobalInstance } from '../../../utils/singletonUtils'; +import type { ResourceEventMapper } from '../../eventMappers/resourceEventMapper'; import type { FirstPartyHost } from '../../types'; import { DistributedTracingSampling } from './distributedTracing/distributedTracingSampling'; import { firstPartyHostsRegexMapBuilder } from './distributedTracing/firstPartyHosts'; +import type { RumResourceReporters } from './requestProxy/XHRProxy/DatadogRumResource/ResourceReporter'; import { XHRProxy } from './requestProxy/XHRProxy/XHRProxy'; import type { RequestProxy } from './requestProxy/interfaces/RequestProxy'; @@ -40,10 +42,14 @@ class RumResourceTracking { */ startTracking({ resourceTraceSampleRate, - firstPartyHosts + firstPartyHosts, + resourceReporters, + resourceEventMapper }: { resourceTraceSampleRate: number; firstPartyHosts: FirstPartyHost[]; + resourceReporters: RumResourceReporters; + resourceEventMapper?: ResourceEventMapper | null; }): void { // extra safety to avoid proxying the XHR class twice if (this._isTracking) { @@ -54,7 +60,10 @@ class RumResourceTracking { return; } - this._requestProxy = XHRProxy.createWithResourceReporter(); + this._requestProxy = XHRProxy.createWithResourceReporter( + resourceReporters, + resourceEventMapper + ); this._requestProxy.onTrackingStart({ tracingSamplingRate: resourceTraceSampleRate, firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder( @@ -99,6 +108,17 @@ class RumResourceTracking { ); } + updateResourceEventMapper( + resourceEventMapper?: ResourceEventMapper | null + ): void { + if (!this._isTracking || !this._requestProxy) { + return; + } + this._requestProxy.onTrackingUpdate({ + resourceEventMapper + }); + } + stopTracking(): void { if (this._isTracking) { this._isTracking = false; 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..67d9a6e8f 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/__tests__/DdRumResourceTracking.test.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/__tests__/DdRumResourceTracking.test.ts @@ -7,6 +7,7 @@ import { NativeModules } from 'react-native'; import { BufferSingleton } from '../../../../sdk/DatadogProvider/Buffer/BufferSingleton'; +import { DdRum as DdRumWrapper } from '../../../DdRum'; import { PropagatorType } from '../../../types'; import { DdRumResourceTracking } from '../DdRumResourceTracking'; import { SAMPLING_PRIORITY_HEADER_KEY } from '../distributedTracing/headers'; @@ -26,7 +27,9 @@ beforeEach(() => { }); afterEach(() => { - global.XMLHttpRequest = undefined; + DdRumWrapper.unregisterResourceEventMapper(); + DdRumResourceTracking.stopTracking(); + delete (global as any).XMLHttpRequest; }); const executeRequest = (url: string = 'https://api.example.com/v2/user') => { @@ -48,7 +51,8 @@ describe('DdRumResourceTracking', () => { match: 'example.com', propagatorTypes: [PropagatorType.DATADOG] } - ] + ], + resourceReporters: DdRumWrapper }); // WHEN @@ -80,7 +84,8 @@ describe('DdRumResourceTracking', () => { match: 'example.com', propagatorTypes: [PropagatorType.DATADOG] } - ] + ], + resourceReporters: DdRumWrapper }); // WHEN @@ -92,6 +97,69 @@ describe('DdRumResourceTracking', () => { expect(DdRum.stopResource).not.toHaveBeenCalled(); }); + it('applies a resource event mapper registered after tracking has started', async () => { + // GIVEN + DdRumResourceTracking.startTracking({ + resourceTraceSampleRate: 100, + firstPartyHosts: [], + resourceReporters: DdRumWrapper + }); + DdRumWrapper.registerResourceEventMapper(resource => { + return { + ...resource, + resourceContext: { + responseURL: 'https://sanitized.example.com/' + } as XMLHttpRequest + }; + }); + + // WHEN + executeRequest('https://api.example.com/users/123'); + await flushPromises(); + + // THEN + expect(DdRum.startResource).toHaveBeenCalledWith( + expect.anything(), + 'GET', + 'https://sanitized.example.com/', + expect.anything(), + expect.anything() + ); + expect(DdRum.stopResource).toHaveBeenCalledTimes(1); + }); + + it('stops applying a resource event mapper after it is unregistered', async () => { + // GIVEN + DdRumResourceTracking.startTracking({ + resourceTraceSampleRate: 100, + firstPartyHosts: [], + resourceReporters: DdRumWrapper + }); + DdRumWrapper.registerResourceEventMapper(resource => { + return { + ...resource, + resourceContext: { + responseURL: 'https://sanitized.example.com/' + } as XMLHttpRequest + }; + }); + DdRumWrapper.unregisterResourceEventMapper(); + + // WHEN + executeRequest('https://api.example.com/users/123'); + await flushPromises(); + + // THEN + expect(DdRum.startResource).toHaveBeenCalledWith( + expect.anything(), + 'GET', + 'https://api.example.com/users/123', + expect.anything(), + expect.anything() + ); + expect(DdRum.stopResource).toHaveBeenCalledTimes(1); + }); + describe('updateTrackingContext', () => { beforeEach(() => { DdRumResourceTracking.stopTracking(); @@ -126,7 +194,8 @@ describe('DdRumResourceTracking', () => { match: 'api.example.com', propagatorTypes: [PropagatorType.DATADOG] } - ] + ], + resourceReporters: DdRumWrapper }); // pre-update request gets sampling priority '0' @@ -160,7 +229,8 @@ describe('DdRumResourceTracking', () => { match: 'api.example.com', propagatorTypes: [PropagatorType.DATADOG] } - ] + ], + resourceReporters: DdRumWrapper }); DdRumResourceTracking.stopTracking(); diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/DatadogRumResource/ResourceReporter.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/DatadogRumResource/ResourceReporter.ts index abbf07513..e34371827 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/DatadogRumResource/ResourceReporter.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/DatadogRumResource/ResourceReporter.ts @@ -4,32 +4,125 @@ * Copyright 2016-Present Datadog, Inc. */ -import { DdRum } from '../../../../../DdRum'; +import type { + ResourceEvent, + ResourceEventMapper +} from '../../../../../eventMappers/resourceEventMapper'; +import type { ResourceKind } from '../../../../../types'; import { TracingIdFormat } from '../../../distributedTracing/TracingIdentifier'; import type { RUMResource } from '../../interfaces/RumResource'; import { createTimings } from './resourceTiming'; -type ResourceMapper = (resource: RUMResource) => RUMResource | null; +export type ReportedResourceEvent = RUMResource & ResourceEvent; +type ResourceEventMapperAdapter = ( + resource: ReportedResourceEvent +) => Partial | null; +type RumResourceMapper = (resource: RUMResource) => RUMResource | null; +export type ResourceMapper = + | ResourceEventMapperAdapter + | RumResourceMapper + | null + | undefined; +export type RumResourceReporters = { + startResource( + key: string, + method: string, + url: string, + context?: object, + timestampMs?: number, + kind?: ResourceKind, + resourceContext?: XMLHttpRequest + ): Promise; + stopResource( + key: string, + statusCode: number, + kind: ResourceKind, + size?: number, + context?: object, + timestampMs?: number, + resourceContext?: XMLHttpRequest + ): Promise; +}; export class ResourceReporter { - private mappers: ResourceMapper[]; - - constructor(resourceMappers: ResourceMapper[]) { - this.mappers = resourceMappers; + private resourceReporters: RumResourceReporters; + private resourceMappers: ResourceMapper[]; + private resourceEventMapper?: ResourceEventMapper | null; + + constructor( + resourceReporters: RumResourceReporters, + resourceMappers: ResourceMapper[], + resourceEventMapper?: ResourceEventMapper | null + ) { + this.resourceReporters = resourceReporters; + this.resourceMappers = resourceMappers; + this.resourceEventMapper = resourceEventMapper; } reportResource = (resource: RUMResource) => { - let modifiedResource: RUMResource | null = resource; - - for (const mapper of this.mappers) { - modifiedResource = mapper(resource); - if (modifiedResource === null) { - return; + let modifiedResource = toReportedResourceEvent(resource); + + for (const mapper of this.getMappers()) { + try { + const mappedResource = mapper?.(modifiedResource) ?? null; + if (mappedResource === null) { + return; + } + modifiedResource = { + ...modifiedResource, + ...mappedResource + }; + } catch { + continue; } } - reportResource(modifiedResource); + this.reportResourceToRum({ + ...modifiedResource, + request: { + ...modifiedResource.request, + url: + modifiedResource.resourceContext?.responseURL || + modifiedResource.request.url + } + }); + }; + + setResourceEventMapper = ( + resourceEventMapper?: ResourceEventMapper | null + ): void => { + this.resourceEventMapper = resourceEventMapper; + }; + + private getMappers = (): ResourceMapper[] => [ + ...this.resourceMappers, + ...(this.resourceEventMapper ? [this.resourceEventMapper] : []) + ]; + + private reportResourceToRum = async (resource: ReportedResourceEvent) => { + await this.resourceReporters.startResource( + resource.key, + resource.request.method, + resource.request.url, + formatResourceStartContext(resource.tracingAttributes), + resource.timings.startTime, + resource.request.kind, + resource.resourceContext + ); + + this.resourceReporters.stopResource( + resource.key, + resource.response.statusCode, + resource.request.kind, + resource.response.size, + formatResourceStopContext( + resource.timings, + resource.graphqlAttributes + ), + resource.timings.stopTime, + resource.resourceContext + ); }; } @@ -50,6 +143,19 @@ const formatResourceStartContext = ( return attributes; }; +const toReportedResourceEvent = ( + resource: RUMResource +): ReportedResourceEvent => ({ + ...resource, + statusCode: resource.response.statusCode, + kind: resource.request.kind, + size: resource.response.size, + context: {}, + timestampMs: resource.timings.stopTime, + resourceContext: resource.resourceContext, + attributes: {} +}); + const formatResourceStopContext = ( timings: RUMResource['timings'], graphqlAttributes: RUMResource['graphqlAttributes'] @@ -88,23 +194,3 @@ const formatResourceStopContext = ( return attributes; }; - -const reportResource = async (resource: RUMResource) => { - await DdRum.startResource( - resource.key, - resource.request.method, - resource.request.url, - formatResourceStartContext(resource.tracingAttributes), - resource.timings.startTime - ); - - DdRum.stopResource( - resource.key, - resource.response.statusCode, - resource.request.kind, - resource.response.size, - formatResourceStopContext(resource.timings, resource.graphqlAttributes), - resource.timings.stopTime, - resource.resourceContext - ); -}; 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..e0c96db05 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,6 +7,8 @@ import { NativeModules } from 'react-native'; import { BufferSingleton } from '../../../../../../../sdk/DatadogProvider/Buffer/BufferSingleton'; +import { DdRum as DdRumWrapper } from '../../../../../../DdRum'; +import type { ResourceEventMapper } from '../../../../../../eventMappers/resourceEventMapper'; import type { RUMResource } from '../../../interfaces/RumResource'; import { ResourceReporter } from '../ResourceReporter'; @@ -26,7 +28,7 @@ beforeEach(() => { describe('Resource reporter', () => { it('reports resource when no mapper is passed', async () => { // GIVEN - const resourceReporter = new ResourceReporter([]); + const resourceReporter = new ResourceReporter(DdRumWrapper, []); const resource = resourceMockFactory.getBasicResource(); // WHEN @@ -43,7 +45,9 @@ describe('Resource reporter', () => { resource.request.url = 'https://google.com/'; return resource; }; - const resourceReporter = new ResourceReporter([setURLToGoogle]); + const resourceReporter = new ResourceReporter(DdRumWrapper, [ + setURLToGoogle + ]); const resource = resourceMockFactory.getCustomResource({ request: { method: 'GET', @@ -67,12 +71,141 @@ describe('Resource reporter', () => { expect(DdRum.stopResource).toHaveBeenCalledTimes(1); }); + it('uses resource context response URL from resource event mapper', async () => { + // GIVEN + const sanitizeURL: ResourceEventMapper = resource => { + return { + ...resource, + resourceContext: { + responseURL: 'https://sanitized.example.com/' + } as XMLHttpRequest + }; + }; + const resourceReporter = new ResourceReporter(DdRumWrapper, [ + sanitizeURL + ]); + const resource = resourceMockFactory.getCustomResource({ + request: { + method: 'GET', + url: 'https://api.example.com/users/123', + kind: 'xhr' + } + }); + + // WHEN + resourceReporter.reportResource(resource); + await flushPromises(); + + // THEN + expect(DdRum.startResource).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + 'https://sanitized.example.com/', + expect.anything(), + expect.anything() + ); + expect(DdRum.stopResource).toHaveBeenCalledTimes(1); + }); + + it('builds the resource event mapper fields from the RUM resource', async () => { + // GIVEN + const inspectResourceEventMapper = jest.fn(resource => resource); + const resourceReporter = new ResourceReporter(DdRumWrapper, [ + inspectResourceEventMapper + ]); + const resource = resourceMockFactory.getCustomResource({ + request: { + method: 'GET', + url: 'https://api.example.com/users/123', + kind: 'xhr' + }, + response: { + statusCode: 201, + size: 1234 + }, + timings: { + startTime: 1000, + stopTime: 1500 + } + }); + + // WHEN + resourceReporter.reportResource(resource); + await flushPromises(); + + // THEN + expect(inspectResourceEventMapper).toHaveBeenCalledWith( + expect.objectContaining({ + key: resource.key, + statusCode: resource.response.statusCode, + kind: resource.request.kind, + size: resource.response.size, + context: {}, + timestampMs: resource.timings.stopTime, + attributes: {} + }) + ); + expect(DdRum.startResource).toHaveBeenCalledTimes(1); + expect(DdRum.stopResource).toHaveBeenCalledTimes(1); + }); + + it('updates the resource event mapper while keeping existing resource mappers', async () => { + // GIVEN + const setURLToGoogle = (resource: RUMResource) => { + resource.request.url = 'https://google.com/'; + return resource; + }; + const sanitizeURL: ResourceEventMapper = resource => { + return { + ...resource, + resourceContext: { + responseURL: 'https://sanitized.example.com/' + } as XMLHttpRequest + }; + }; + const resourceReporter = new ResourceReporter(DdRumWrapper, [ + setURLToGoogle + ]); + resourceReporter.setResourceEventMapper(sanitizeURL); + + // WHEN + resourceReporter.reportResource(resourceMockFactory.getBasicResource()); + await flushPromises(); + + // THEN + expect(DdRum.startResource).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + 'https://sanitized.example.com/', + expect.anything(), + expect.anything() + ); + + // WHEN + DdRum.startResource.mockClear(); + DdRum.stopResource.mockClear(); + resourceReporter.setResourceEventMapper(undefined); + resourceReporter.reportResource(resourceMockFactory.getBasicResource()); + await flushPromises(); + + // THEN + expect(DdRum.startResource).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + 'https://google.com/', + expect.anything(), + expect.anything() + ); + }); + it('drops the resource when a mapper returns null', async () => { // GIVEN const discardResource = (resource: RUMResource) => { return null; }; - const resourceReporter = new ResourceReporter([discardResource]); + const resourceReporter = new ResourceReporter(DdRumWrapper, [ + discardResource + ]); const resource = resourceMockFactory.getBasicResource(); // WHEN diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/DatadogRumResource/internalDevResourceBlocklist.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/DatadogRumResource/internalDevResourceBlocklist.ts index c8dae8ef5..e3af2351c 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/DatadogRumResource/internalDevResourceBlocklist.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/DatadogRumResource/internalDevResourceBlocklist.ts @@ -4,7 +4,7 @@ * Copyright 2016-Present Datadog, Inc. */ -import type { RUMResource } from '../../interfaces/RumResource'; +import type { ResourceMapper } from './ResourceReporter'; /** * Expo sends all console.* calls to the packager. As we log all API calls @@ -33,11 +33,11 @@ const internalDevResourceBlocklist: RegExp[] = [ /** * Filters RN symbolicate calls and Expo logs calls that happen only in dev. - * @param resource RUMResource + * @param resource Resource event passed through the resource mapper. */ export const filterDevResource = ( - resource: RUMResource -): RUMResource | null => { + resource: Parameters[0] +): Parameters[0] | null => { if (__DEV__) { for (const resourceRegex of internalDevResourceBlocklist) { if (resourceRegex.test(resource.request.url)) { 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..c0ad336f9 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/XHRProxy.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/XHRProxy.ts @@ -7,6 +7,7 @@ import { InternalLog } from '../../../../../InternalLog'; import { SdkVerbosity } from '../../../../../config/types'; import { Timer } from '../../../../../utils/Timer'; +import type { ResourceEventMapper } from '../../../../eventMappers/resourceEventMapper'; import { getCachedAccountId, getCachedSessionId, @@ -33,6 +34,7 @@ import type { RequestProxyOptions } from '../interfaces/RequestProxy'; import { RequestProxy } from '../interfaces/RequestProxy'; import type { DdRumResourceGraphqlAttributes } from '../interfaces/RumResource'; +import type { RumResourceReporters } from './DatadogRumResource/ResourceReporter'; import { ResourceReporter } from './DatadogRumResource/ResourceReporter'; import { filterDevResource } from './DatadogRumResource/internalDevResourceBlocklist'; import { URLHostParser } from './URLHostParser'; @@ -78,10 +80,17 @@ export class XHRProxy extends RequestProxy { this.providers = providers; } - static createWithResourceReporter() { + static createWithResourceReporter( + resourceReporters: RumResourceReporters, + resourceEventMapper?: ResourceEventMapper | null + ) { return new XHRProxy({ xhrType: XMLHttpRequest, - resourceReporter: new ResourceReporter([filterDevResource]) + resourceReporter: new ResourceReporter( + resourceReporters, + [filterDevResource], + resourceEventMapper + ) }); } @@ -101,11 +110,21 @@ export class XHRProxy extends RequestProxy { this.context = null; }; - onTrackingUpdate = (options: { tracingSamplingRate: number }) => { + onTrackingUpdate = (options: { + tracingSamplingRate?: number; + resourceEventMapper?: ResourceEventMapper | null; + }) => { if (this.context === null) { return; } - this.context.tracingSamplingRate = options.tracingSamplingRate; + if (options.tracingSamplingRate !== undefined) { + this.context.tracingSamplingRate = options.tracingSamplingRate; + } + if ('resourceEventMapper' in options) { + this.providers.resourceReporter.setResourceEventMapper( + options.resourceEventMapper + ); + } }; } 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..a7b78ccf9 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 @@ -78,7 +78,7 @@ beforeEach(() => { xhrProxy = new XHRProxy({ xhrType: XMLHttpRequestMock, - resourceReporter: new ResourceReporter([]) + resourceReporter: new ResourceReporter(DdRum, []) } as { xhrType: typeof XMLHttpRequest; resourceReporter: ResourceReporter; diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/interfaces/RequestProxy.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/interfaces/RequestProxy.ts index 37abc1eec..6d3c531db 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/interfaces/RequestProxy.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/interfaces/RequestProxy.ts @@ -4,6 +4,7 @@ * Copyright 2016-Present Datadog, Inc. */ +import type { ResourceEventMapper } from '../../../../eventMappers/resourceEventMapper'; import type { PropagatorType } from '../../../../types'; export interface RequestProxyOptions { @@ -20,6 +21,7 @@ export abstract class RequestProxy { abstract onTrackingStart: (context: RequestProxyOptions) => void; abstract onTrackingStop: () => void; abstract onTrackingUpdate: (context: { - tracingSamplingRate: number; + tracingSamplingRate?: number; + resourceEventMapper?: ResourceEventMapper | null; }) => void; } diff --git a/packages/core/src/rum/types.ts b/packages/core/src/rum/types.ts index b879010f7..20221270d 100644 --- a/packages/core/src/rum/types.ts +++ b/packages/core/src/rum/types.ts @@ -100,13 +100,15 @@ export type DdRumType = { * @param url: The resource url. * @param context: The additional context to send. * @param timestampMs: The timestamp when the resource started (in milliseconds). If not provided, current timestamp will be used. + * @param kind: The resource's kind (xhr, fetch, document, image, css, font, …). Defaults to other. */ startResource( key: string, method: string, url: string, context?: object, - timestampMs?: number + timestampMs?: number, + kind?: ResourceKind ): Promise; /**