From 087f3ba00c3da923b110d1638922bf82c0098749 Mon Sep 17 00:00:00 2001 From: Marco Saia Date: Mon, 19 Oct 2026 12:45:33 +0200 Subject: [PATCH] fix: duplicated XHR resource keys --- .../requestProxy/FetchProxy/FetchProxy.ts | 6 +-- .../FetchProxy/__tests__/FetchProxy.test.ts | 29 ++++++++++++++ .../requestProxy/XHRProxy/XHRProxy.ts | 4 +- .../XHRProxy/__tests__/XHRProxy.test.ts | 40 +++++++++++++++++++ .../requestProxy/common/RequestContext.ts | 9 +++++ 5 files changed, 83 insertions(+), 5 deletions(-) diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/FetchProxy/FetchProxy.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/FetchProxy/FetchProxy.ts index 87c4c7df8..670717881 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/FetchProxy/FetchProxy.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/FetchProxy/FetchProxy.ts @@ -10,7 +10,7 @@ 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 { createRequestContext, getResourceKey } from '../common/RequestContext'; import { ResourceReporter } from '../common/ResourceReporter'; import { filterDevResource } from '../common/internalDevResourceBlocklist'; import { @@ -259,7 +259,7 @@ const reportFetch = async ({ } resourceReporter.reportResource({ - key: `${context.timer.startTime}/${context.method}`, + key: getResourceKey(context), request: { method: context.method, url: context.url, @@ -287,7 +287,7 @@ const reportFetchFailure = ({ resourceReporter: ResourceReporter; }) => { resourceReporter.reportResource({ - key: `${context.timer.startTime}/${context.method}`, + key: getResourceKey(context), request: { method: context.method, url: context.url, diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/FetchProxy/__tests__/FetchProxy.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/FetchProxy/__tests__/FetchProxy.test.ts index f50d8f744..10e37fe16 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/FetchProxy/__tests__/FetchProxy.test.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/FetchProxy/__tests__/FetchProxy.test.ts @@ -157,6 +157,35 @@ describe('FetchProxy', () => { expect(outgoingHeaders.get(PARENT_ID_HEADER_KEY)).not.toBeNull(); }); + it('reports distinct keys for same-method requests started in the same millisecond', async () => { + const now = Date.now(); + const dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(now); + 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 Promise.all([ + fetchGlobal.fetch('https://api.example.com/users'), + fetchGlobal.fetch('https://api.example.com/settings') + ]); + dateNowSpy.mockRestore(); + + expect(reportResource).toHaveBeenCalledTimes(2); + const [first, second] = reportResource.mock.calls.map( + call => call[0] as RUMResource + ); + expect(first.timings.startTime).toBe(second.timings.startTime); + expect(first.key).not.toBe(second.key); + }); + it('extracts GraphQL metadata and removes its internal header', async () => { const originalFetch = jest .fn() 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 d413ceb2d..54a67e774 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/XHRProxy.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/XHRProxy.ts @@ -9,7 +9,7 @@ import { SdkVerbosity } from '../../../../../config/types'; import { extractGraphQLErrors } from '../../graphql/graphqlUtils'; import { isRunningWithinFetchProxy } from '../common/FetchProxyState'; import type { RequestContext } from '../common/RequestContext'; -import { createRequestContext } from '../common/RequestContext'; +import { createRequestContext, getResourceKey } from '../common/RequestContext'; import { ResourceReporter } from '../common/ResourceReporter'; import { filterDevResource } from '../common/internalDevResourceBlocklist'; import { @@ -189,7 +189,7 @@ const reportXhr = async ( return; } - const key = `${context.timer.startTime}/${context.method}`; + const key = getResourceKey(context); context.timer.stop(); 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 18ce799ba..c8b5bd7dc 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 @@ -145,6 +145,46 @@ describe('XHRProxy', () => { expect(xhr.originalOnReadyStateChangeCalled).toBe(true); }); + it('reports distinct resources for same-method XHR requests started in the same millisecond', async () => { + // GIVEN + const now = Date.now(); + (Date.now as jest.MockedFunction< + typeof Date.now + >).mockImplementation(() => now); + xhrProxy.onTrackingStart({ + tracingSamplingRate: 100, + firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([]) + }); + + // WHEN + const xhr1 = new XMLHttpRequestMock(); + const xhr2 = new XMLHttpRequestMock(); + xhr1.open('GET', 'https://api.example.com/v2/user'); + xhr2.open('GET', 'https://api.example.com/v2/settings'); + xhr1.send(); + xhr2.send(); + xhr1.notifyResponseArrived(); + xhr2.notifyResponseArrived(); + xhr1.complete(200, 'ok'); + xhr2.complete(200, 'ok'); + await flushPromises(); + + // THEN + expect(DdNativeRum.startResource.mock.calls.length).toBe(2); + expect(DdNativeRum.startResource.mock.calls[0][4]).toBe( + DdNativeRum.startResource.mock.calls[1][4] + ); + expect(DdNativeRum.startResource.mock.calls[0][0]).not.toBe( + DdNativeRum.startResource.mock.calls[1][0] + ); + expect(DdNativeRum.stopResource.mock.calls.length).toBe(2); + expect( + DdNativeRum.stopResource.mock.calls.map(call => call[0]).sort() + ).toEqual( + DdNativeRum.startResource.mock.calls.map(call => call[0]).sort() + ); + }); + it('intercepts failing XHR request when startTracking() + XHR.open() + XHR.send()', async () => { // GIVEN const method = 'GET'; diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/RequestContext.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/RequestContext.ts index b09001a06..c292c7f8f 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/RequestContext.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/RequestContext.ts @@ -17,6 +17,7 @@ import type { RequestProxyOptions } from '../interfaces/RequestProxy'; import type { DdRumResourceGraphqlAttributes } from '../interfaces/RumResource'; export interface RequestContext { + resourceId: number; graphql: DdRumResourceGraphqlAttributes & { trackErrors?: boolean; }; @@ -27,6 +28,10 @@ export interface RequestContext { baggageHeaderEntries: Set; } +// Disambiguates resources sharing the same start millisecond and method, +// which would otherwise collide on the native side. +let resourceSequence = 0; + export const createRequestContext = ({ method, url, @@ -37,6 +42,7 @@ export const createRequestContext = ({ options: RequestProxyOptions; }): RequestContext => { return { + resourceId: ++resourceSequence, method, url, timer: new Timer(), @@ -52,3 +58,6 @@ export const createRequestContext = ({ baggageHeaderEntries: new Set() }; }; + +export const getResourceKey = (context: RequestContext): string => + `${context.timer.startTime}/${context.method}/${context.resourceId}`;