diff --git a/dev-packages/browser-integration-tests/suites/integrations/httpclient/axios/test.ts b/dev-packages/browser-integration-tests/suites/integrations/httpclient/axios/test.ts index d4e7ba222b30..19161be50ad3 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/httpclient/axios/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/httpclient/axios/test.ts @@ -40,6 +40,15 @@ sentryTest( type: 'http.client', handled: false, }, + stacktrace: { + frames: expect.arrayContaining([ + expect.objectContaining({ + filename: 'http://sentry-test.io/subject.bundle.js', + function: '?', + in_app: true, + }), + ]), + }, }, ], }, diff --git a/dev-packages/browser-integration-tests/suites/integrations/httpclient/fetch/simple/test.ts b/dev-packages/browser-integration-tests/suites/integrations/httpclient/fetch/simple/test.ts index 09390fffd573..c92419aae72f 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/httpclient/fetch/simple/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/httpclient/fetch/simple/test.ts @@ -42,6 +42,15 @@ sentryTest( type: 'http.client', handled: false, }, + stacktrace: { + frames: expect.arrayContaining([ + expect.objectContaining({ + filename: 'http://sentry-test.io/subject.bundle.js', + function: '?', + in_app: true, + }), + ]), + }, }, ], }, diff --git a/dev-packages/browser-integration-tests/suites/integrations/httpclient/fetch/withRequest/test.ts b/dev-packages/browser-integration-tests/suites/integrations/httpclient/fetch/withRequest/test.ts index f1e23bbd080b..4ceec6d51c38 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/httpclient/fetch/withRequest/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/httpclient/fetch/withRequest/test.ts @@ -38,6 +38,15 @@ sentryTest('works with a Request passed in', async ({ getLocalTestUrl, page }) = type: 'http.client', handled: false, }, + stacktrace: { + frames: expect.arrayContaining([ + expect.objectContaining({ + filename: 'http://sentry-test.io/subject.bundle.js', + function: '?', + in_app: true, + }), + ]), + }, }, ], }, diff --git a/dev-packages/browser-integration-tests/suites/integrations/httpclient/fetch/withRequestAndBodyAndOptions/test.ts b/dev-packages/browser-integration-tests/suites/integrations/httpclient/fetch/withRequestAndBodyAndOptions/test.ts index ba34c4fdfa0f..9c408c8ade50 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/httpclient/fetch/withRequestAndBodyAndOptions/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/httpclient/fetch/withRequestAndBodyAndOptions/test.ts @@ -40,6 +40,15 @@ sentryTest( type: 'http.client', handled: false, }, + stacktrace: { + frames: expect.arrayContaining([ + expect.objectContaining({ + filename: 'http://sentry-test.io/subject.bundle.js', + function: '?', + in_app: true, + }), + ]), + }, }, ], }, diff --git a/dev-packages/browser-integration-tests/suites/integrations/httpclient/fetch/withRequestAndOptions/test.ts b/dev-packages/browser-integration-tests/suites/integrations/httpclient/fetch/withRequestAndOptions/test.ts index 0ea8fc7bc0cb..92e31819673f 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/httpclient/fetch/withRequestAndOptions/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/httpclient/fetch/withRequestAndOptions/test.ts @@ -38,6 +38,15 @@ sentryTest('works with a Request (without body) & options passed in', async ({ g type: 'http.client', handled: false, }, + stacktrace: { + frames: expect.arrayContaining([ + expect.objectContaining({ + filename: 'http://sentry-test.io/subject.bundle.js', + function: '?', + in_app: true, + }), + ]), + }, }, ], }, diff --git a/dev-packages/browser-integration-tests/suites/integrations/httpclient/xhr/test.ts b/dev-packages/browser-integration-tests/suites/integrations/httpclient/xhr/test.ts index d342e9abf748..d323c73725d6 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/httpclient/xhr/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/httpclient/xhr/test.ts @@ -40,6 +40,15 @@ sentryTest( type: 'http.client', handled: false, }, + stacktrace: { + frames: expect.arrayContaining([ + expect.objectContaining({ + filename: 'http://sentry-test.io/subject.bundle.js', + function: '?', + in_app: true, + }), + ]), + }, }, ], }, diff --git a/dev-packages/browser-integration-tests/utils/generatePlugin.ts b/dev-packages/browser-integration-tests/utils/generatePlugin.ts index c3b515ada32d..b9b4dcb4c1f3 100644 --- a/dev-packages/browser-integration-tests/utils/generatePlugin.ts +++ b/dev-packages/browser-integration-tests/utils/generatePlugin.ts @@ -176,7 +176,6 @@ class SentryScenarioGenerationPlugin { } : {}; - // Checking if the current scenario has imported `@sentry/integrations`. compiler.hooks.normalModuleFactory.tap(this._name, factory => { factory.hooks.parser.for('javascript/auto').tap(this._name, parser => { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access diff --git a/packages/browser-utils/src/instrument/xhr.ts b/packages/browser-utils/src/instrument/xhr.ts index df67d1c1f1be..468155ef3b2a 100644 --- a/packages/browser-utils/src/instrument/xhr.ts +++ b/packages/browser-utils/src/instrument/xhr.ts @@ -32,6 +32,13 @@ export function instrumentXHR(): void { // eslint-disable-next-line @typescript-eslint/unbound-method xhrproto.open = new Proxy(xhrproto.open, { apply(originalOpen, xhrOpenThisArg: XMLHttpRequest & SentryWrappedXMLHttpRequest, xhrOpenArgArray) { + // NOTE: If you are a Sentry user, and you are seeing this stack frame, + // it means the error, that was caused by your XHR call did not + // have a stack trace. If you are using HttpClient integration, + // this is the expected behavior, as we are using this virtual error to capture + // the location of your XHR call, and group your HttpClient events accordingly. + const virtualError = new Error(); + const startTimestamp = timestampInSeconds() * 1000; // open() should always be called with two or more arguments @@ -75,6 +82,7 @@ export function instrumentXHR(): void { endTimestamp: timestampInSeconds() * 1000, startTimestamp, xhr: xhrOpenThisArg, + virtualError, }; triggerHandlers('xhr', handlerData); } diff --git a/packages/browser/src/integrations/httpclient.ts b/packages/browser/src/integrations/httpclient.ts index cf1dc6ab6896..3ff5218f1ab8 100644 --- a/packages/browser/src/integrations/httpclient.ts +++ b/packages/browser/src/integrations/httpclient.ts @@ -70,6 +70,7 @@ function _fetchResponseHandler( requestInfo: RequestInfo, response: Response, requestInit?: RequestInit, + error?: unknown, ): void { if (_shouldCaptureResponse(options, response.status, response.url)) { const request = _getRequest(requestInfo, requestInit); @@ -89,6 +90,7 @@ function _fetchResponseHandler( responseHeaders, requestCookies, responseCookies, + error, }); captureEvent(event); @@ -127,6 +129,7 @@ function _xhrResponseHandler( xhr: XMLHttpRequest, method: string, headers: Record, + error?: unknown, ): void { if (_shouldCaptureResponse(options, xhr.status, xhr.responseURL)) { let requestHeaders, responseCookies, responseHeaders; @@ -159,6 +162,7 @@ function _xhrResponseHandler( // Can't access request cookies from XHR responseHeaders, responseCookies, + error, }); captureEvent(event); @@ -288,15 +292,15 @@ function _wrapFetch(client: Client, options: HttpClientOptions): void { return; } - const { response, args } = handlerData; + const { response, args, error, virtualError } = handlerData; const [requestInfo, requestInit] = args as [RequestInfo, RequestInit | undefined]; if (!response) { return; } - _fetchResponseHandler(options, requestInfo, response as Response, requestInit); - }); + _fetchResponseHandler(options, requestInfo, response as Response, requestInit, error || virtualError); + }, false); } /** @@ -312,6 +316,8 @@ function _wrapXHR(client: Client, options: HttpClientOptions): void { return; } + const { error, virtualError } = handlerData; + const xhr = handlerData.xhr as SentryWrappedXMLHttpRequest & XMLHttpRequest; const sentryXhrData = xhr[SENTRY_XHR_DATA_KEY]; @@ -323,7 +329,7 @@ function _wrapXHR(client: Client, options: HttpClientOptions): void { const { method, request_headers: headers } = sentryXhrData; try { - _xhrResponseHandler(options, xhr, method, headers); + _xhrResponseHandler(options, xhr, method, headers, error || virtualError); } catch (e) { DEBUG_BUILD && logger.warn('Error while extracting response event form XHR response', e); } @@ -358,7 +364,12 @@ function _createEvent(data: { responseCookies?: Record; requestHeaders?: Record; requestCookies?: Record; + error?: unknown; }): SentryEvent { + const client = getClient(); + const virtualStackTrace = client && data.error && data.error instanceof Error ? data.error.stack : undefined; + // Remove the first frame from the stack as it's the HttpClient call + const stack = virtualStackTrace && client ? client.getOptions().stackParser(virtualStackTrace, 0, 1) : undefined; const message = `HTTP Client Error with status code: ${data.status}`; const event: SentryEvent = { @@ -368,6 +379,7 @@ function _createEvent(data: { { type: 'Error', value: message, + stacktrace: stack ? { frames: stack } : undefined, }, ], }, diff --git a/packages/core/src/types-hoist/instrument.ts b/packages/core/src/types-hoist/instrument.ts index f0b239e86b14..420482579dd9 100644 --- a/packages/core/src/types-hoist/instrument.ts +++ b/packages/core/src/types-hoist/instrument.ts @@ -32,6 +32,9 @@ export interface HandlerDataXhr { xhr: SentryWrappedXMLHttpRequest; startTimestamp?: number; endTimestamp?: number; + error?: unknown; + // This is to be consumed by the HttpClient integration + virtualError?: unknown; } interface SentryFetchData { @@ -56,6 +59,8 @@ export interface HandlerDataFetch { headers: WebFetchHeaders; }; error?: unknown; + // This is to be consumed by the HttpClient integration + virtualError?: unknown; } export interface HandlerDataDom { diff --git a/packages/core/src/utils-hoist/instrument/fetch.ts b/packages/core/src/utils-hoist/instrument/fetch.ts index 39c8862ba618..954ab50a7536 100644 --- a/packages/core/src/utils-hoist/instrument/fetch.ts +++ b/packages/core/src/utils-hoist/instrument/fetch.ts @@ -48,6 +48,15 @@ function instrumentFetch(onFetchResolved?: (response: Response) => void, skipNat fill(GLOBAL_OBJ, 'fetch', function (originalFetch: () => void): () => void { return function (...args: any[]): void { + // We capture the error right here and not in the Promise error callback because Safari (and probably other + // browsers too) will wipe the stack trace up to this point, only leaving us with this file which is useless. + + // NOTE: If you are a Sentry user, and you are seeing this stack frame, + // it means the error, that was caused by your fetch call did not + // have a stack trace, so the SDK backfilled the stack trace so + // you can see which fetch call failed. + const virtualError = new Error(); + const { method, url } = parseFetchArgs(args); const handlerData: HandlerDataFetch = { args, @@ -56,6 +65,8 @@ function instrumentFetch(onFetchResolved?: (response: Response) => void, skipNat url, }, startTimestamp: timestampInSeconds() * 1000, + // // Adding the error to be able to fingerprint the failed fetch event in HttpClient instrumentation + virtualError, }; // if there is no callback, fetch is instrumented directly @@ -65,15 +76,6 @@ function instrumentFetch(onFetchResolved?: (response: Response) => void, skipNat }); } - // We capture the stack right here and not in the Promise error callback because Safari (and probably other - // browsers too) will wipe the stack trace up to this point, only leaving us with this file which is useless. - - // NOTE: If you are a Sentry user, and you are seeing this stack frame, - // it means the error, that was caused by your fetch call did not - // have a stack trace, so the SDK backfilled the stack trace so - // you can see which fetch call failed. - const virtualStackTrace = new Error().stack; - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access return originalFetch.apply(GLOBAL_OBJ, args).then( async (response: Response) => { @@ -101,7 +103,7 @@ function instrumentFetch(onFetchResolved?: (response: Response) => void, skipNat // it means the error, that was caused by your fetch call did not // have a stack trace, so the SDK backfilled the stack trace so // you can see which fetch call failed. - error.stack = virtualStackTrace; + error.stack = virtualError.stack; addNonEnumerableProperty(error, 'framesToPop', 1); }