Skip to content

Commit 39c2e4a

Browse files
committed
fix(integration): Ensure HttpClient integration works with axios
This streamlines the integration to use the general fetch/xhr instrumentation, instead of adding it's own. This also adds `request_headers` to the xhr info (which we'll also need for replay). It also ensures that we always correctly get the method/url from fetch options, even when they are other valid objects.
1 parent 1cab46b commit 39c2e4a

File tree

6 files changed

+146
-86
lines changed

6 files changed

+146
-86
lines changed
Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import axios from 'axios';
22

3-
axios.get('http://localhost:7654/foo').then(response => {
4-
console.log(response.data);
5-
});
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+
});

packages/browser-integration-tests/suites/integrations/httpclient/axios/test.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,6 @@ sentryTest(
4949
headers: {
5050
Accept: 'application/json',
5151
Cache: 'no-cache',
52-
'Content-Type': 'application/json',
5352
},
5453
},
5554
contexts: {

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/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: 65 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,55 @@ 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+
return {
231+
url: getUrlFromResource(fetchArgs[0] as FetchResource),
232+
method: 'GET',
233+
};
205234
}
206-
/* eslint-enable @typescript-eslint/no-unsafe-member-access */
207235

208236
/** JSDoc */
209237
function instrumentXHR(): void {
@@ -220,6 +248,7 @@ function instrumentXHR(): void {
220248
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
221249
method: isString(args[0]) ? args[0].toUpperCase() : args[0],
222250
url: args[1],
251+
request_headers: {},
223252
});
224253

225254
// if Sentry key appears in URL, don't capture it as a request
@@ -265,6 +294,23 @@ function instrumentXHR(): void {
265294
this.addEventListener('readystatechange', onreadystatechangeHandler);
266295
}
267296

297+
// Intercepting `setRequestHeader` to access the request headers of XHR instance.
298+
// This will only work for user/library defined headers, not for the default/browser-assigned headers.
299+
// Request cookies are also unavailable for XHR, as `Cookie` header can't be defined by `setRequestHeader`.
300+
fill(this, 'setRequestHeader', function (original: WrappedFunction): Function {
301+
return function (this: SentryWrappedXMLHttpRequest, ...setRequestHeaderArgs: unknown[]): void {
302+
const [header, value] = setRequestHeaderArgs as [string, string];
303+
304+
const xhrInfo = this.__sentry_xhr__;
305+
306+
if (xhrInfo) {
307+
xhrInfo.request_headers[header] = value;
308+
}
309+
310+
return original.apply(this, setRequestHeaderArgs);
311+
};
312+
});
313+
268314
return originalOpen.apply(this, args);
269315
};
270316
});
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { parseFetchArgs } from '../src/instrument';
2+
3+
describe('instrument', () => {
4+
describe('parseFetchArgs', () => {
5+
it.each([
6+
['string URL only', ['http://example.com'], { method: 'GET', url: 'http://example.com' }],
7+
['URL object only', [new URL('http://example.com')], { method: 'GET', url: 'http://example.com/' }],
8+
['Request URL only', [{ url: 'http://example.com' }], { method: 'GET', url: 'http://example.com' }],
9+
[
10+
'string URL & options',
11+
['http://example.com', { method: 'post' }],
12+
{ method: 'POST', url: 'http://example.com' },
13+
],
14+
[
15+
'URL object & options',
16+
[new URL('http://example.com'), { method: 'post' }],
17+
{ method: 'POST', url: 'http://example.com/' },
18+
],
19+
[
20+
'Request URL & options',
21+
[{ url: 'http://example.com' }, { method: 'post' }],
22+
{ method: 'POST', url: 'http://example.com' },
23+
],
24+
])('%s', (_name, args, expected) => {
25+
const actual = parseFetchArgs(args as unknown[]);
26+
27+
expect(actual).toEqual(expected);
28+
});
29+
});
30+
});

0 commit comments

Comments
 (0)