Skip to content

Commit 01d7c5c

Browse files
authored
fix(integrations): Ensure HttpClient integration works with Axios (#7714)
1 parent 776d9ce commit 01d7c5c

File tree

9 files changed

+233
-93
lines changed

9 files changed

+233
-93
lines changed

packages/browser-integration-tests/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
"dependencies": {
4747
"@babel/preset-typescript": "^7.16.7",
4848
"@playwright/test": "^1.31.1",
49+
"axios": "1.3.4",
4950
"babel-loader": "^8.2.2",
5051
"html-webpack-plugin": "^5.5.0",
5152
"pako": "^2.1.0",
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import axios from 'axios';
2+
3+
axios
4+
.get('http://localhost:7654/foo', {
5+
headers: { Accept: 'application/json', 'Content-Type': 'application/json', Cache: 'no-cache' },
6+
})
7+
.then(response => {
8+
console.log(response.data);
9+
});
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { expect } from '@playwright/test';
2+
import type { Event } from '@sentry/types';
3+
4+
import { sentryTest } from '../../../../utils/fixtures';
5+
import { getFirstSentryEnvelopeRequest } from '../../../../utils/helpers';
6+
7+
sentryTest(
8+
'should assign request and response context from a failed 500 XHR request',
9+
async ({ getLocalTestPath, page }) => {
10+
const url = await getLocalTestPath({ testDir: __dirname });
11+
12+
await page.route('**/foo', route => {
13+
return route.fulfill({
14+
status: 500,
15+
body: JSON.stringify({
16+
error: {
17+
message: 'Internal Server Error',
18+
},
19+
}),
20+
headers: {
21+
'Content-Type': 'text/html',
22+
},
23+
});
24+
});
25+
26+
const eventData = await getFirstSentryEnvelopeRequest<Event>(page, url);
27+
28+
expect(eventData.exception?.values).toHaveLength(1);
29+
30+
// Not able to get the cookies from the request/response because of Playwright bug
31+
// https://github.com/microsoft/playwright/issues/11035
32+
expect(eventData).toMatchObject({
33+
message: 'HTTP Client Error with status code: 500',
34+
exception: {
35+
values: [
36+
{
37+
type: 'Error',
38+
value: 'HTTP Client Error with status code: 500',
39+
mechanism: {
40+
type: 'http.client',
41+
handled: true,
42+
},
43+
},
44+
],
45+
},
46+
request: {
47+
url: 'http://localhost:7654/foo',
48+
method: 'GET',
49+
headers: {
50+
Accept: 'application/json',
51+
Cache: 'no-cache',
52+
},
53+
},
54+
contexts: {
55+
response: {
56+
status_code: 500,
57+
body_size: 45,
58+
headers: {
59+
'content-type': 'text/html',
60+
'content-length': '45',
61+
},
62+
},
63+
},
64+
});
65+
},
66+
);

packages/integrations/src/httpclient.ts

Lines changed: 43 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,19 @@
1-
import type { Event as SentryEvent, EventProcessor, Hub, Integration } from '@sentry/types';
2-
import { addExceptionMechanism, fill, GLOBAL_OBJ, logger, supportsNativeFetch } from '@sentry/utils';
1+
import type {
2+
Event as SentryEvent,
3+
EventProcessor,
4+
HandlerDataFetch,
5+
HandlerDataXhr,
6+
Hub,
7+
Integration,
8+
SentryWrappedXMLHttpRequest,
9+
} from '@sentry/types';
10+
import {
11+
addExceptionMechanism,
12+
addInstrumentationHandler,
13+
GLOBAL_OBJ,
14+
logger,
15+
supportsNativeFetch,
16+
} from '@sentry/utils';
317

418
export type HttpStatusCodeRange = [number, number] | number;
519
export type HttpRequestTarget = string | RegExp;
@@ -283,25 +297,15 @@ export class HttpClient implements Integration {
283297
return;
284298
}
285299

286-
// eslint-disable-next-line @typescript-eslint/no-this-alias
287-
const self = this;
288-
289-
fill(GLOBAL_OBJ, 'fetch', function (originalFetch: (...args: unknown[]) => Promise<Response>) {
290-
return function (this: Window, ...args: unknown[]): Promise<Response> {
291-
const [requestInfo, requestInit] = args as [RequestInfo, RequestInit | undefined];
292-
const responsePromise: Promise<Response> = originalFetch.apply(this, args);
293-
294-
responsePromise
295-
.then((response: Response) => {
296-
self._fetchResponseHandler(requestInfo, response, requestInit);
297-
return response;
298-
})
299-
.catch((error: Error) => {
300-
throw error;
301-
});
300+
addInstrumentationHandler('fetch', (handlerData: HandlerDataFetch & { response?: Response }) => {
301+
const { response, args } = handlerData;
302+
const [requestInfo, requestInit] = args as [RequestInfo, RequestInit | undefined];
303+
304+
if (!response) {
305+
return;
306+
}
302307

303-
return responsePromise;
304-
};
308+
this._fetchResponseHandler(requestInfo, response, requestInit);
305309
});
306310
}
307311

@@ -313,52 +317,28 @@ export class HttpClient implements Integration {
313317
return;
314318
}
315319

316-
// eslint-disable-next-line @typescript-eslint/no-this-alias
317-
const self = this;
318-
319-
fill(XMLHttpRequest.prototype, 'open', function (originalOpen: (...openArgs: unknown[]) => void): () => void {
320-
return function (this: XMLHttpRequest, ...openArgs: unknown[]): void {
321-
// eslint-disable-next-line @typescript-eslint/no-this-alias
322-
const xhr = this;
323-
const method = openArgs[0] as string;
324-
const headers: Record<string, string> = {};
325-
326-
// Intercepting `setRequestHeader` to access the request headers of XHR instance.
327-
// This will only work for user/library defined headers, not for the default/browser-assigned headers.
328-
// Request cookies are also unavailable for XHR, as `Cookie` header can't be defined by `setRequestHeader`.
329-
fill(
330-
xhr,
331-
'setRequestHeader',
332-
// eslint-disable-next-line @typescript-eslint/ban-types
333-
function (originalSetRequestHeader: (...setRequestHeaderArgs: unknown[]) => void): Function {
334-
return function (...setRequestHeaderArgs: unknown[]): void {
335-
const [header, value] = setRequestHeaderArgs as [string, string];
336-
337-
headers[header] = value;
338-
339-
return originalSetRequestHeader.apply(xhr, setRequestHeaderArgs);
340-
};
341-
},
342-
);
320+
addInstrumentationHandler(
321+
'xhr',
322+
(handlerData: HandlerDataXhr & { xhr: SentryWrappedXMLHttpRequest & XMLHttpRequest }) => {
323+
const { xhr } = handlerData;
343324

344-
// eslint-disable-next-line @typescript-eslint/ban-types
345-
fill(xhr, 'onloadend', function (original?: (...onloadendArgs: unknown[]) => void): Function {
346-
return function (...onloadendArgs: unknown[]): void {
347-
try {
348-
self._xhrResponseHandler(xhr, method, headers);
349-
} catch (e) {
350-
__DEBUG_BUILD__ && logger.warn('Error while extracting response event form XHR response', e);
351-
}
325+
if (!xhr.__sentry_xhr__) {
326+
return;
327+
}
352328

353-
if (original) {
354-
return original.apply(xhr, onloadendArgs);
355-
}
356-
};
357-
});
329+
const { method, request_headers: headers } = xhr.__sentry_xhr__;
358330

359-
return originalOpen.apply(this, openArgs);
360-
};
361-
});
331+
if (!method) {
332+
return;
333+
}
334+
335+
try {
336+
this._xhrResponseHandler(xhr, method, headers);
337+
} catch (e) {
338+
__DEBUG_BUILD__ && logger.warn('Error while extracting response event form XHR response', e);
339+
}
340+
},
341+
);
362342
}
363343

364344
/**

packages/replay/test/unit/coreHandlers/handleXhr.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { HandlerDataXhr, SentryWrappedXMLHttpRequest } from '@sentry/types';
1+
import type { HandlerDataXhr, SentryWrappedXMLHttpRequest, SentryXhrData } from '@sentry/types';
22

33
import { handleXhr } from '../../../src/coreHandlers/handleXhr';
44

@@ -9,6 +9,7 @@ const DEFAULT_DATA: HandlerDataXhr = {
99
method: 'GET',
1010
url: '/api/0/organizations/sentry/',
1111
status_code: 200,
12+
request_headers: {},
1213
},
1314
} as SentryWrappedXMLHttpRequest,
1415
startTimestamp: 10000,
@@ -45,7 +46,7 @@ describe('Unit | coreHandlers | handleXhr', () => {
4546
xhr: {
4647
...DEFAULT_DATA.xhr,
4748
__sentry_xhr__: {
48-
...DEFAULT_DATA.xhr.__sentry_xhr__,
49+
...(DEFAULT_DATA.xhr.__sentry_xhr__ as SentryXhrData),
4950
request_body_size: 123,
5051
response_body_size: 456,
5152
},

packages/types/src/instrument.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export interface SentryXhrData {
1515
body?: XHRSendInput;
1616
request_body_size?: number;
1717
response_body_size?: number;
18+
request_headers: Record<string, string>;
1819
}
1920

2021
export interface HandlerDataXhr {

packages/utils/src/instrument.ts

Lines changed: 66 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import type {
99
WrappedFunction,
1010
} from '@sentry/types';
1111

12-
import { isInstanceOf, isString } from './is';
12+
import { isString } from './is';
1313
import { CONSOLE_LEVELS, logger } from './logger';
1414
import { fill } from './object';
1515
import { getFunctionName } from './stacktrace';
@@ -142,11 +142,13 @@ function instrumentFetch(): void {
142142

143143
fill(WINDOW, 'fetch', function (originalFetch: () => void): () => void {
144144
return function (...args: any[]): void {
145+
const { method, url } = parseFetchArgs(args);
146+
145147
const handlerData: HandlerDataFetch = {
146148
args,
147149
fetchData: {
148-
method: getFetchMethod(args),
149-
url: getFetchUrl(args),
150+
method,
151+
url,
150152
},
151153
startTimestamp: Date.now(),
152154
};
@@ -181,29 +183,56 @@ function instrumentFetch(): void {
181183
});
182184
}
183185

184-
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
185-
/** Extract `method` from fetch call arguments */
186-
function getFetchMethod(fetchArgs: any[] = []): string {
187-
if ('Request' in WINDOW && isInstanceOf(fetchArgs[0], Request) && fetchArgs[0].method) {
188-
return String(fetchArgs[0].method).toUpperCase();
186+
function hasProp<T extends string>(obj: unknown, prop: T): obj is Record<string, string> {
187+
return !!obj && typeof obj === 'object' && !!(obj as Record<string, string>)[prop];
188+
}
189+
190+
type FetchResource = string | { toString(): string } | { url: string };
191+
192+
function getUrlFromResource(resource: FetchResource): string {
193+
if (typeof resource === 'string') {
194+
return resource;
195+
}
196+
197+
if (!resource) {
198+
return '';
189199
}
190-
if (fetchArgs[1] && fetchArgs[1].method) {
191-
return String(fetchArgs[1].method).toUpperCase();
200+
201+
if (hasProp(resource, 'url')) {
202+
return resource.url;
203+
}
204+
205+
if (resource.toString) {
206+
return resource.toString();
192207
}
193-
return 'GET';
208+
209+
return '';
194210
}
195211

196-
/** Extract `url` from fetch call arguments */
197-
function getFetchUrl(fetchArgs: any[] = []): string {
198-
if (typeof fetchArgs[0] === 'string') {
199-
return fetchArgs[0];
212+
/**
213+
* Exported only for tests.
214+
* @hidden
215+
* */
216+
export function parseFetchArgs(fetchArgs: unknown[]): { method: string; url: string } {
217+
if (fetchArgs.length === 0) {
218+
return { method: 'GET', url: '' };
200219
}
201-
if ('Request' in WINDOW && isInstanceOf(fetchArgs[0], Request)) {
202-
return fetchArgs[0].url;
220+
221+
if (fetchArgs.length === 2) {
222+
const [url, options] = fetchArgs as [FetchResource, object];
223+
224+
return {
225+
url: getUrlFromResource(url),
226+
method: hasProp(options, 'method') ? String(options.method).toUpperCase() : 'GET',
227+
};
203228
}
204-
return String(fetchArgs[0]);
229+
230+
const arg = fetchArgs[0];
231+
return {
232+
url: getUrlFromResource(arg as FetchResource),
233+
method: hasProp(arg, 'method') ? String(arg.method).toUpperCase() : 'GET',
234+
};
205235
}
206-
/* eslint-enable @typescript-eslint/no-unsafe-member-access */
207236

208237
/** JSDoc */
209238
function instrumentXHR(): void {
@@ -220,6 +249,7 @@ function instrumentXHR(): void {
220249
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
221250
method: isString(args[0]) ? args[0].toUpperCase() : args[0],
222251
url: args[1],
252+
request_headers: {},
223253
});
224254

225255
// if Sentry key appears in URL, don't capture it as a request
@@ -265,6 +295,23 @@ function instrumentXHR(): void {
265295
this.addEventListener('readystatechange', onreadystatechangeHandler);
266296
}
267297

298+
// Intercepting `setRequestHeader` to access the request headers of XHR instance.
299+
// This will only work for user/library defined headers, not for the default/browser-assigned headers.
300+
// Request cookies are also unavailable for XHR, as `Cookie` header can't be defined by `setRequestHeader`.
301+
fill(this, 'setRequestHeader', function (original: WrappedFunction): Function {
302+
return function (this: SentryWrappedXMLHttpRequest, ...setRequestHeaderArgs: unknown[]): void {
303+
const [header, value] = setRequestHeaderArgs as [string, string];
304+
305+
const xhrInfo = this.__sentry_xhr__;
306+
307+
if (xhrInfo) {
308+
xhrInfo.request_headers[header] = value;
309+
}
310+
311+
return original.apply(this, setRequestHeaderArgs);
312+
};
313+
});
314+
268315
return originalOpen.apply(this, args);
269316
};
270317
});

0 commit comments

Comments
 (0)