Skip to content

Commit 839644b

Browse files
pr(fix): improve fetch tracking support
1 parent 9f45729 commit 839644b

3 files changed

Lines changed: 192 additions & 9 deletions

File tree

packages/core/README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,11 @@ export default function App() {
8585
`fetch`. Direct imports retained from `expo/fetch` are not intercepted. XHR
8686
tracking remains enabled for clients such as axios.
8787

88+
GraphQL error extraction (via `DatadogLink({ trackErrors: true })`) for
89+
requests made through `expo/fetch` requires Expo SDK 56 or later, as older
90+
versions of `expo/fetch` do not implement `Response.clone()`. On earlier
91+
versions, GraphQL metadata is still reported, but response errors are not.
92+
8893
### Track view navigation
8994

9095
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.

packages/core/src/rum/instrumentation/resourceTracking/requestProxy/FetchProxy/FetchProxy.ts

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44
* Copyright 2016-Present Datadog, Inc.
55
*/
66

7+
import { InternalLog } from '../../../../../InternalLog';
8+
import { SdkVerbosity } from '../../../../../config/types';
9+
import { extractGraphQLErrors } from '../../graphql/graphqlUtils';
10+
import { getErrorData } from '../XHRProxy/xhrUtils';
711
import { callOriginalFetch } from '../common/FetchProxyState';
812
import type { RequestContext } from '../common/RequestContext';
913
import { createRequestContext } from '../common/RequestContext';
@@ -51,13 +55,14 @@ export class FetchProxy extends RequestProxy {
5155

5256
onTrackingStart = (context: RequestProxyOptions) => {
5357
this.context = context;
54-
this.originalFetch = this.providers.fetchGlobal.fetch;
58+
const originalFetch = this.providers.fetchGlobal.fetch;
59+
this.originalFetch = originalFetch;
5560

5661
const installedFetch: typeof fetch = (input, init) => {
5762
return trackFetch({
5863
input,
5964
init,
60-
originalFetch: this.originalFetch as typeof fetch,
65+
originalFetch,
6166
fetchThis: this.providers.fetchGlobal,
6267
headersType: this.providers.headersType,
6368
resourceReporter: this.providers.resourceReporter,
@@ -124,7 +129,15 @@ const trackFetch = async ({
124129

125130
context.timer.recordTick(RESPONSE_START_LABEL);
126131
context.timer.stop();
127-
reportFetch({ context, response, resourceReporter });
132+
reportFetch({ context, response, resourceReporter }).catch(error => {
133+
const errorData = getErrorData(error);
134+
if (errorData) {
135+
InternalLog.log(
136+
`reportFetch failed: ${errorData}`,
137+
SdkVerbosity.WARN
138+
);
139+
}
140+
});
128141
return response;
129142
} catch (error) {
130143
context.timer.stop();
@@ -215,15 +228,36 @@ const applyHeader = ({
215228
}
216229
};
217230

218-
const reportFetch = ({
231+
const reportFetch = async ({
219232
context,
220233
response,
221234
resourceReporter
222235
}: {
223236
context: RequestContext;
224237
response: Response;
225238
resourceReporter: ResourceReporter;
226-
}) => {
239+
}): Promise<void> => {
240+
// Only extract GraphQL errors if operationType is set AND error tracking is enabled
241+
if (context.graphql.operationType && context.graphql.trackErrors) {
242+
try {
243+
const body = await response.clone().json();
244+
245+
const errors = body?.errors;
246+
if (Array.isArray(errors) && errors.length > 0) {
247+
const filtered = extractGraphQLErrors(errors);
248+
249+
if (filtered.length > 0) {
250+
context.graphql.errors = filtered;
251+
}
252+
}
253+
} catch (error) {
254+
const errorData = getErrorData(error);
255+
if (errorData) {
256+
InternalLog.log(`reportFetch: ${errorData}`, SdkVerbosity.WARN);
257+
}
258+
}
259+
}
260+
227261
resourceReporter.reportResource({
228262
key: `${context.timer.startTime}/${context.method}`,
229263
request: {

packages/core/src/rum/instrumentation/resourceTracking/requestProxy/FetchProxy/__tests__/FetchProxy.test.ts

Lines changed: 148 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@ import {
1212
TRACKED_BY_HEADER_KEY,
1313
TRACKED_BY_HEADER_VALUE
1414
} from '../../../distributedTracing/headers';
15-
import { DATADOG_GRAPH_QL_OPERATION_TYPE_HEADER } from '../../../graphql/graphqlHeaders';
15+
import {
16+
DATADOG_GRAPH_QL_ERROR_HEADER,
17+
DATADOG_GRAPH_QL_OPERATION_TYPE_HEADER
18+
} from '../../../graphql/graphqlHeaders';
1619
import type { ResourceReporter } from '../../common/ResourceReporter';
1720
import type { RUMResource } from '../../interfaces/RumResource';
1821
import { FetchProxy } from '../FetchProxy';
@@ -65,17 +68,28 @@ const getOptions = (traceSampleRate = 100) => ({
6568

6669
const createResponse = ({
6770
status = 200,
68-
headers = {}
71+
headers = {},
72+
body
6973
}: {
7074
status?: number;
7175
headers?: Record<string, string>;
76+
body?: unknown;
7277
} = {}) => {
73-
return ({
78+
const response = ({
7479
status,
75-
headers: new HeadersMock(headers)
80+
headers: new HeadersMock(headers),
81+
clone() {
82+
return response;
83+
},
84+
json: () => Promise.resolve(body)
7685
} as unknown) as Response;
86+
87+
return response;
7788
};
7889

90+
const flushPromises = () =>
91+
new Promise(jest.requireActual('timers').setImmediate);
92+
7993
const createProxy = ({
8094
originalFetch,
8195
reportResource
@@ -246,4 +260,134 @@ describe('FetchProxy', () => {
246260

247261
expect(fetchGlobal.fetch).toBe(laterFetch);
248262
});
263+
264+
it('keeps a Fetch wrapper retained by another library callable after tracking stops', async () => {
265+
const response = createResponse();
266+
const originalFetch = jest
267+
.fn()
268+
.mockResolvedValue(response) as jest.MockedFunction<typeof fetch>;
269+
const { fetchGlobal, proxy } = createProxy({
270+
originalFetch,
271+
reportResource: jest.fn()
272+
});
273+
proxy.onTrackingStart(getOptions());
274+
275+
// Another library installs its own wrapper after Datadog, capturing
276+
// Datadog's installed Fetch as its own "original" delegate.
277+
const capturedDatadogFetch = fetchGlobal.fetch;
278+
fetchGlobal.fetch = ((input, init) =>
279+
capturedDatadogFetch(input, init)) as typeof fetch;
280+
281+
proxy.onTrackingStop();
282+
283+
await expect(
284+
capturedDatadogFetch('https://api.example.com/users')
285+
).resolves.toBe(response);
286+
});
287+
288+
describe('GraphQL error filtering', () => {
289+
it('extracts GraphQL errors from the response body when error tracking is enabled', async () => {
290+
const graphqlResponse = {
291+
data: { user: null },
292+
errors: [
293+
{
294+
message: 'User not found',
295+
locations: [{ line: 2, column: 3 }],
296+
path: ['user', 0, 'id'],
297+
extensions: { code: 'NOT_FOUND' }
298+
}
299+
]
300+
};
301+
const originalFetch = jest
302+
.fn()
303+
.mockResolvedValue(
304+
createResponse({ body: graphqlResponse })
305+
) as jest.MockedFunction<typeof fetch>;
306+
const reportResource = jest.fn();
307+
const { fetchGlobal, proxy } = createProxy({
308+
originalFetch,
309+
reportResource
310+
});
311+
proxy.onTrackingStart(getOptions());
312+
313+
await fetchGlobal.fetch('https://api.example.com/graphql', {
314+
headers: {
315+
[DATADOG_GRAPH_QL_OPERATION_TYPE_HEADER]: 'query',
316+
[DATADOG_GRAPH_QL_ERROR_HEADER]: 'true'
317+
}
318+
});
319+
await flushPromises();
320+
321+
const resource = reportResource.mock.calls[0][0] as RUMResource;
322+
expect(resource.graphqlAttributes?.errors).toEqual([
323+
{
324+
message: 'User not found',
325+
code: 'NOT_FOUND',
326+
locations: [{ line: 2, column: 3 }],
327+
path: ['user', 0, 'id']
328+
}
329+
]);
330+
});
331+
332+
it('reports without errors when the Fetch implementation does not support clone()', async () => {
333+
// Some Fetch implementations (e.g. `expo/fetch` prior to Expo SDK
334+
// 56) throw on `response.clone()`.
335+
const response = createResponse({ body: { data: {} } });
336+
response.clone = () => {
337+
throw new Error('Not implemented');
338+
};
339+
const originalFetch = jest
340+
.fn()
341+
.mockResolvedValue(response) as jest.MockedFunction<
342+
typeof fetch
343+
>;
344+
const reportResource = jest.fn();
345+
const { fetchGlobal, proxy } = createProxy({
346+
originalFetch,
347+
reportResource
348+
});
349+
proxy.onTrackingStart(getOptions());
350+
351+
await expect(
352+
fetchGlobal.fetch('https://api.example.com/graphql', {
353+
headers: {
354+
[DATADOG_GRAPH_QL_OPERATION_TYPE_HEADER]: 'query',
355+
[DATADOG_GRAPH_QL_ERROR_HEADER]: 'true'
356+
}
357+
})
358+
).resolves.toBe(response);
359+
await flushPromises();
360+
361+
const resource = reportResource.mock.calls[0][0] as RUMResource;
362+
expect(resource.graphqlAttributes?.operationType).toBe('query');
363+
expect(resource.graphqlAttributes?.errors).toBeUndefined();
364+
});
365+
366+
it('does not read the response body when error tracking is disabled', async () => {
367+
const response = createResponse({ body: { data: {} } });
368+
const cloneSpy = jest.spyOn(response, 'clone');
369+
const originalFetch = jest
370+
.fn()
371+
.mockResolvedValue(response) as jest.MockedFunction<
372+
typeof fetch
373+
>;
374+
const reportResource = jest.fn();
375+
const { fetchGlobal, proxy } = createProxy({
376+
originalFetch,
377+
reportResource
378+
});
379+
proxy.onTrackingStart(getOptions());
380+
381+
await fetchGlobal.fetch('https://api.example.com/graphql', {
382+
headers: {
383+
[DATADOG_GRAPH_QL_OPERATION_TYPE_HEADER]: 'query'
384+
}
385+
});
386+
await flushPromises();
387+
388+
expect(cloneSpy).not.toHaveBeenCalled();
389+
const resource = reportResource.mock.calls[0][0] as RUMResource;
390+
expect(resource.graphqlAttributes?.errors).toBeUndefined();
391+
});
392+
});
249393
});

0 commit comments

Comments
 (0)