Skip to content

Commit 63476fa

Browse files
authored
feat(browser): Attach virtual stack traces to HttpClient events (#14515)
1 parent 5fe6345 commit 63476fa

File tree

11 files changed

+95
-15
lines changed

11 files changed

+95
-15
lines changed

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,15 @@ sentryTest(
4040
type: 'http.client',
4141
handled: false,
4242
},
43+
stacktrace: {
44+
frames: expect.arrayContaining([
45+
expect.objectContaining({
46+
filename: 'http://sentry-test.io/subject.bundle.js',
47+
function: '?',
48+
in_app: true,
49+
}),
50+
]),
51+
},
4352
},
4453
],
4554
},

dev-packages/browser-integration-tests/suites/integrations/httpclient/fetch/simple/test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,15 @@ sentryTest(
4242
type: 'http.client',
4343
handled: false,
4444
},
45+
stacktrace: {
46+
frames: expect.arrayContaining([
47+
expect.objectContaining({
48+
filename: 'http://sentry-test.io/subject.bundle.js',
49+
function: '?',
50+
in_app: true,
51+
}),
52+
]),
53+
},
4554
},
4655
],
4756
},

dev-packages/browser-integration-tests/suites/integrations/httpclient/fetch/withRequest/test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,15 @@ sentryTest('works with a Request passed in', async ({ getLocalTestUrl, page }) =
3838
type: 'http.client',
3939
handled: false,
4040
},
41+
stacktrace: {
42+
frames: expect.arrayContaining([
43+
expect.objectContaining({
44+
filename: 'http://sentry-test.io/subject.bundle.js',
45+
function: '?',
46+
in_app: true,
47+
}),
48+
]),
49+
},
4150
},
4251
],
4352
},

dev-packages/browser-integration-tests/suites/integrations/httpclient/fetch/withRequestAndBodyAndOptions/test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,15 @@ sentryTest(
4040
type: 'http.client',
4141
handled: false,
4242
},
43+
stacktrace: {
44+
frames: expect.arrayContaining([
45+
expect.objectContaining({
46+
filename: 'http://sentry-test.io/subject.bundle.js',
47+
function: '?',
48+
in_app: true,
49+
}),
50+
]),
51+
},
4352
},
4453
],
4554
},

dev-packages/browser-integration-tests/suites/integrations/httpclient/fetch/withRequestAndOptions/test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,15 @@ sentryTest('works with a Request (without body) & options passed in', async ({ g
3838
type: 'http.client',
3939
handled: false,
4040
},
41+
stacktrace: {
42+
frames: expect.arrayContaining([
43+
expect.objectContaining({
44+
filename: 'http://sentry-test.io/subject.bundle.js',
45+
function: '?',
46+
in_app: true,
47+
}),
48+
]),
49+
},
4150
},
4251
],
4352
},

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,15 @@ sentryTest(
4040
type: 'http.client',
4141
handled: false,
4242
},
43+
stacktrace: {
44+
frames: expect.arrayContaining([
45+
expect.objectContaining({
46+
filename: 'http://sentry-test.io/subject.bundle.js',
47+
function: '?',
48+
in_app: true,
49+
}),
50+
]),
51+
},
4352
},
4453
],
4554
},

dev-packages/browser-integration-tests/utils/generatePlugin.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,6 @@ class SentryScenarioGenerationPlugin {
176176
}
177177
: {};
178178

179-
// Checking if the current scenario has imported `@sentry/integrations`.
180179
compiler.hooks.normalModuleFactory.tap(this._name, factory => {
181180
factory.hooks.parser.for('javascript/auto').tap(this._name, parser => {
182181
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access

packages/browser-utils/src/instrument/xhr.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,13 @@ export function instrumentXHR(): void {
3131
// eslint-disable-next-line @typescript-eslint/unbound-method
3232
xhrproto.open = new Proxy(xhrproto.open, {
3333
apply(originalOpen, xhrOpenThisArg: XMLHttpRequest & SentryWrappedXMLHttpRequest, xhrOpenArgArray) {
34+
// NOTE: If you are a Sentry user, and you are seeing this stack frame,
35+
// it means the error, that was caused by your XHR call did not
36+
// have a stack trace. If you are using HttpClient integration,
37+
// this is the expected behavior, as we are using this virtual error to capture
38+
// the location of your XHR call, and group your HttpClient events accordingly.
39+
const virtualError = new Error();
40+
3441
const startTimestamp = timestampInSeconds() * 1000;
3542

3643
// open() should always be called with two or more arguments
@@ -74,6 +81,7 @@ export function instrumentXHR(): void {
7481
endTimestamp: timestampInSeconds() * 1000,
7582
startTimestamp,
7683
xhr: xhrOpenThisArg,
84+
virtualError,
7785
};
7886
triggerHandlers('xhr', handlerData);
7987
}

packages/browser/src/integrations/httpclient.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ function _fetchResponseHandler(
7373
requestInfo: RequestInfo,
7474
response: Response,
7575
requestInit?: RequestInit,
76+
error?: unknown,
7677
): void {
7778
if (_shouldCaptureResponse(options, response.status, response.url)) {
7879
const request = _getRequest(requestInfo, requestInit);
@@ -92,6 +93,7 @@ function _fetchResponseHandler(
9293
responseHeaders,
9394
requestCookies,
9495
responseCookies,
96+
error,
9597
});
9698

9799
captureEvent(event);
@@ -130,6 +132,7 @@ function _xhrResponseHandler(
130132
xhr: XMLHttpRequest,
131133
method: string,
132134
headers: Record<string, string>,
135+
error?: unknown,
133136
): void {
134137
if (_shouldCaptureResponse(options, xhr.status, xhr.responseURL)) {
135138
let requestHeaders, responseCookies, responseHeaders;
@@ -162,6 +165,7 @@ function _xhrResponseHandler(
162165
// Can't access request cookies from XHR
163166
responseHeaders,
164167
responseCookies,
168+
error,
165169
});
166170

167171
captureEvent(event);
@@ -291,15 +295,15 @@ function _wrapFetch(client: Client, options: HttpClientOptions): void {
291295
return;
292296
}
293297

294-
const { response, args } = handlerData;
298+
const { response, args, error, virtualError } = handlerData;
295299
const [requestInfo, requestInit] = args as [RequestInfo, RequestInit | undefined];
296300

297301
if (!response) {
298302
return;
299303
}
300304

301-
_fetchResponseHandler(options, requestInfo, response as Response, requestInit);
302-
});
305+
_fetchResponseHandler(options, requestInfo, response as Response, requestInit, error || virtualError);
306+
}, false);
303307
}
304308

305309
/**
@@ -315,6 +319,8 @@ function _wrapXHR(client: Client, options: HttpClientOptions): void {
315319
return;
316320
}
317321

322+
const { error, virtualError } = handlerData;
323+
318324
const xhr = handlerData.xhr as SentryWrappedXMLHttpRequest & XMLHttpRequest;
319325

320326
const sentryXhrData = xhr[SENTRY_XHR_DATA_KEY];
@@ -326,7 +332,7 @@ function _wrapXHR(client: Client, options: HttpClientOptions): void {
326332
const { method, request_headers: headers } = sentryXhrData;
327333

328334
try {
329-
_xhrResponseHandler(options, xhr, method, headers);
335+
_xhrResponseHandler(options, xhr, method, headers, error || virtualError);
330336
} catch (e) {
331337
DEBUG_BUILD && logger.warn('Error while extracting response event form XHR response', e);
332338
}
@@ -361,7 +367,12 @@ function _createEvent(data: {
361367
responseCookies?: Record<string, string>;
362368
requestHeaders?: Record<string, string>;
363369
requestCookies?: Record<string, string>;
370+
error?: unknown;
364371
}): SentryEvent {
372+
const client = getClient();
373+
const virtualStackTrace = client && data.error && data.error instanceof Error ? data.error.stack : undefined;
374+
// Remove the first frame from the stack as it's the HttpClient call
375+
const stack = virtualStackTrace && client ? client.getOptions().stackParser(virtualStackTrace, 0, 1) : undefined;
365376
const message = `HTTP Client Error with status code: ${data.status}`;
366377

367378
const event: SentryEvent = {
@@ -371,6 +382,7 @@ function _createEvent(data: {
371382
{
372383
type: 'Error',
373384
value: message,
385+
stacktrace: stack ? { frames: stack } : undefined,
374386
},
375387
],
376388
},

packages/core/src/types-hoist/instrument.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ export interface HandlerDataXhr {
3232
xhr: SentryWrappedXMLHttpRequest;
3333
startTimestamp?: number;
3434
endTimestamp?: number;
35+
error?: unknown;
36+
// This is to be consumed by the HttpClient integration
37+
virtualError?: unknown;
3538
}
3639

3740
interface SentryFetchData {
@@ -56,6 +59,8 @@ export interface HandlerDataFetch {
5659
headers: WebFetchHeaders;
5760
};
5861
error?: unknown;
62+
// This is to be consumed by the HttpClient integration
63+
virtualError?: unknown;
5964
}
6065

6166
export interface HandlerDataDom {

packages/core/src/utils-hoist/instrument/fetch.ts

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,15 @@ function instrumentFetch(onFetchResolved?: (response: Response) => void, skipNat
4848

4949
fill(GLOBAL_OBJ, 'fetch', function (originalFetch: () => void): () => void {
5050
return function (...args: any[]): void {
51+
// We capture the error right here and not in the Promise error callback because Safari (and probably other
52+
// browsers too) will wipe the stack trace up to this point, only leaving us with this file which is useless.
53+
54+
// NOTE: If you are a Sentry user, and you are seeing this stack frame,
55+
// it means the error, that was caused by your fetch call did not
56+
// have a stack trace, so the SDK backfilled the stack trace so
57+
// you can see which fetch call failed.
58+
const virtualError = new Error();
59+
5160
const { method, url } = parseFetchArgs(args);
5261
const handlerData: HandlerDataFetch = {
5362
args,
@@ -56,6 +65,8 @@ function instrumentFetch(onFetchResolved?: (response: Response) => void, skipNat
5665
url,
5766
},
5867
startTimestamp: timestampInSeconds() * 1000,
68+
// // Adding the error to be able to fingerprint the failed fetch event in HttpClient instrumentation
69+
virtualError,
5970
};
6071

6172
// if there is no callback, fetch is instrumented directly
@@ -65,15 +76,6 @@ function instrumentFetch(onFetchResolved?: (response: Response) => void, skipNat
6576
});
6677
}
6778

68-
// We capture the stack right here and not in the Promise error callback because Safari (and probably other
69-
// browsers too) will wipe the stack trace up to this point, only leaving us with this file which is useless.
70-
71-
// NOTE: If you are a Sentry user, and you are seeing this stack frame,
72-
// it means the error, that was caused by your fetch call did not
73-
// have a stack trace, so the SDK backfilled the stack trace so
74-
// you can see which fetch call failed.
75-
const virtualStackTrace = new Error().stack;
76-
7779
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
7880
return originalFetch.apply(GLOBAL_OBJ, args).then(
7981
async (response: Response) => {
@@ -101,7 +103,7 @@ function instrumentFetch(onFetchResolved?: (response: Response) => void, skipNat
101103
// it means the error, that was caused by your fetch call did not
102104
// have a stack trace, so the SDK backfilled the stack trace so
103105
// you can see which fetch call failed.
104-
error.stack = virtualStackTrace;
106+
error.stack = virtualError.stack;
105107
addNonEnumerableProperty(error, 'framesToPop', 1);
106108
}
107109

0 commit comments

Comments
 (0)