From a4f2c3f635adef9e128e5599b3f148784f62878b Mon Sep 17 00:00:00 2001 From: Kunal Vashisht Date: Sat, 7 Nov 2026 18:20:38 +0530 Subject: [PATCH 1/3] fix(browser): Instrument empty-URL XHR and use the page URL in breadcrumbs --- .../browser-utils/src/instrumentation/xhr.ts | 2 +- .../test/instrumentation/xhr.test.ts | 27 +++++- .../browser/src/integrations/breadcrumbs.ts | 12 ++- .../test/integrations/breadcrumbs.test.ts | 96 ++++++++++++++++++- 4 files changed, 131 insertions(+), 6 deletions(-) diff --git a/packages/browser-utils/src/instrumentation/xhr.ts b/packages/browser-utils/src/instrumentation/xhr.ts index 41dbe93a42ae..5973d130b51f 100644 --- a/packages/browser-utils/src/instrumentation/xhr.ts +++ b/packages/browser-utils/src/instrumentation/xhr.ts @@ -51,7 +51,7 @@ export function instrumentXHR(): void { const method = isString(xhrOpenArgArray[0]) ? xhrOpenArgArray[0].toUpperCase() : undefined; const url = parseXhrUrlArg(xhrOpenArgArray[1]); - if (!method || !url) { + if (!method || url === undefined) { return originalOpen.apply(xhrOpenThisArg, xhrOpenArgArray); } diff --git a/packages/browser-utils/test/instrumentation/xhr.test.ts b/packages/browser-utils/test/instrumentation/xhr.test.ts index 91991a579439..9ab78118f15e 100644 --- a/packages/browser-utils/test/instrumentation/xhr.test.ts +++ b/packages/browser-utils/test/instrumentation/xhr.test.ts @@ -1,11 +1,28 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { addXhrInstrumentationHandler, instrumentXHR } from '../../src/instrumentation/xhr'; -import type { HandlerDataXhr } from '../../src/types'; +import type { HandlerDataXhr, SentryWrappedXMLHttpRequest } from '../../src/types'; import { WINDOW } from '../../src/types'; const win = WINDOW as typeof WINDOW & { XMLHttpRequest?: typeof XMLHttpRequest }; const originalXMLHttpRequest = win.XMLHttpRequest; +function openWithUrl(url: unknown): SentryWrappedXMLHttpRequest['__sentry_xhr_v3__'] { + class MockXMLHttpRequest { + public addEventListener(): void {} + public removeEventListener(): void {} + public open(_method: string, _url: string): void {} + public send(): void {} + public setRequestHeader(_header: string, _value: string): void {} + } + + win.XMLHttpRequest = MockXMLHttpRequest as unknown as typeof XMLHttpRequest; + instrumentXHR(); + + const xhr = new MockXMLHttpRequest(); + xhr.open('GET', url as string); + return (xhr as unknown as SentryWrappedXMLHttpRequest).__sentry_xhr_v3__; +} + describe('instrumentXHR', () => { afterEach(() => { win.XMLHttpRequest = originalXMLHttpRequest; @@ -16,6 +33,14 @@ describe('instrumentXHR', () => { expect(instrumentXHR).not.toThrow(); }); + it('instruments requests with an empty URL', () => { + expect(openWithUrl('')).toMatchObject({ method: 'GET', url: '' }); + }); + + it('does not instrument requests whose URL cannot be parsed', () => { + expect(openWithUrl(undefined)).toBeUndefined(); + }); + it('removes readystatechange event listener on readyState 4 when registered with addEventListener', () => { const addEventListenerSpy = vi.fn(); const removeEventListenerSpy = vi.fn(); diff --git a/packages/browser/src/integrations/breadcrumbs.ts b/packages/browser/src/integrations/breadcrumbs.ts index aa7f438d770e..58e35a2f4d8c 100644 --- a/packages/browser/src/integrations/breadcrumbs.ts +++ b/packages/browser/src/integrations/breadcrumbs.ts @@ -19,6 +19,7 @@ import { getClient, getEventDescription, parseUrl, + stripUrlQueryAndFragment, } from '@sentry/core'; import type { FetchHint, HandlerDataDom, HandlerDataHistory, HandlerDataXhr } from '@sentry/browser-utils'; import { @@ -193,7 +194,7 @@ function _getXhrBreadcrumbHandler(client: Client): (handlerData: HandlerDataXhr) const data: XhrBreadcrumbData = { method, - url: filterCollectedUrl(url), + url: getBreadcrumbUrl(url), status_code, }; @@ -248,7 +249,7 @@ function _getFetchBreadcrumbHandler(client: Client): (handlerData: HandlerDataFe const breadcrumb = { category: 'fetch', - data: { ...handlerData.fetchData, url: filterCollectedUrl(handlerData.fetchData.url) }, + data: { ...handlerData.fetchData, url: getBreadcrumbUrl(handlerData.fetchData.url) }, level: 'error', type: 'http', } satisfies Breadcrumb; @@ -260,7 +261,7 @@ function _getFetchBreadcrumbHandler(client: Client): (handlerData: HandlerDataFe const response = handlerData.response as Response | undefined; const data: FetchBreadcrumbData = { ...handlerData.fetchData, - url: filterCollectedUrl(handlerData.fetchData.url), + url: getBreadcrumbUrl(handlerData.fetchData.url), status_code: response?.status, }; @@ -324,6 +325,11 @@ function _getHistoryBreadcrumbHandler(client: Client): (handlerData: HandlerData }; } +function getBreadcrumbUrl(url: string): string { + const pageurl = url === '' ? WINDOW.location?.href : undefined; + return filterCollectedUrl(pageurl ? stripUrlQueryAndFragment(pageurl) : url); +} + function _isEvent(event: unknown): event is Event { return !!event && !!(event as Record).target; } diff --git a/packages/browser/test/integrations/breadcrumbs.test.ts b/packages/browser/test/integrations/breadcrumbs.test.ts index 56df65c3a6ae..9e2b3b9aa284 100644 --- a/packages/browser/test/integrations/breadcrumbs.test.ts +++ b/packages/browser/test/integrations/breadcrumbs.test.ts @@ -1,9 +1,15 @@ import * as SentryCore from '@sentry/core'; -import { describe, expect, it, vi } from 'vitest'; +import * as browserUtils from '@sentry/browser-utils'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { breadcrumbsIntegration, BrowserClient, flush } from '../../src'; import { getDefaultBrowserClientOptions } from '../helper/browser-client-options'; describe('Breadcrumbs', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.resetAllMocks(); + }); + it('Should add sentry breadcrumb', async () => { const client = new BrowserClient({ ...getDefaultBrowserClientOptions(), @@ -22,4 +28,92 @@ describe('Breadcrumbs', () => { expect(addBreadcrumbSpy.mock.calls[0]?.[0]?.category).toEqual('sentry.event'); expect(addBreadcrumbSpy.mock.calls[0]?.[0]?.message).toEqual('test'); }); + + describe('empty request URLs', () => { + function setup() { + let fetchHandler: ((data: SentryCore.HandlerDataFetch) => void) | undefined; + let xhrHandler: ((data: browserUtils.HandlerDataXhr) => void) | undefined; + + vi.spyOn(SentryCore, 'addFetchInstrumentationHandler').mockImplementation(handler => { + fetchHandler = handler; + }); + vi.spyOn(browserUtils, 'addXhrInstrumentationHandler').mockImplementation(handler => { + xhrHandler = handler; + }); + const addBreadcrumbSpy = vi.spyOn(SentryCore, 'addBreadcrumb').mockImplementation(() => {}); + + const client = new BrowserClient({ + ...getDefaultBrowserClientOptions(), + dsn: 'https://username@domain/123', + integrations: [breadcrumbsIntegration()], + }); + SentryCore.setCurrentClient(client); + client.init(); + + return { addBreadcrumbSpy, fetchHandler: fetchHandler!, xhrHandler: xhrHandler! }; + } + + const pageHref = 'https://app.example.com/users/42?token=secret#section'; + const pagePath = 'https://app.example.com/users/42'; + + it('shows the page URL without query or fragment in a fetch breadcrumb', () => { + vi.stubGlobal('location', { href: pageHref }); + const { addBreadcrumbSpy, fetchHandler } = setup(); + + fetchHandler({ + fetchData: { method: 'GET', url: '' }, + args: [''], + startTimestamp: 1, + endTimestamp: 2, + response: { status: 200 } as Response, + }); + + expect(addBreadcrumbSpy.mock.calls[0]?.[0]?.data?.url).toBe(pagePath); + }); + + it('shows the page URL without query or fragment in a failed fetch breadcrumb', () => { + vi.stubGlobal('location', { href: pageHref }); + const { addBreadcrumbSpy, fetchHandler } = setup(); + + fetchHandler({ + fetchData: { method: 'GET', url: '' }, + args: [''], + startTimestamp: 1, + endTimestamp: 2, + error: new TypeError('Failed to fetch'), + }); + + expect(addBreadcrumbSpy.mock.calls[0]?.[0]?.data?.url).toBe(pagePath); + }); + + it('shows the page URL without query or fragment in an XHR breadcrumb', () => { + vi.stubGlobal('location', { href: pageHref }); + const { addBreadcrumbSpy, xhrHandler } = setup(); + + xhrHandler({ + xhr: { + [browserUtils.SENTRY_XHR_DATA_KEY]: { method: 'GET', url: '', status_code: 200, request_headers: {} }, + }, + startTimestamp: 1, + endTimestamp: 2, + } as browserUtils.HandlerDataXhr); + + expect(addBreadcrumbSpy.mock.calls[0]?.[0]?.data?.url).toBe(pagePath); + }); + + it('leaves non-empty URLs unchanged', () => { + vi.stubGlobal('location', { href: pageHref }); + const { addBreadcrumbSpy, fetchHandler } = setup(); + + fetchHandler({ + fetchData: { method: 'GET', url: '/api/users' }, + args: ['/api/users'], + startTimestamp: 1, + endTimestamp: 2, + response: { status: 200 } as Response, + }); + + expect(addBreadcrumbSpy.mock.calls[0]?.[0]?.data?.url).toBe('/api/users'); + }); + }); }); From 149a4b30f8661e4ccb4a589706c8356278b2121a Mon Sep 17 00:00:00 2001 From: Kunal Vashisht Date: Tue, 10 Nov 2026 20:31:26 +0530 Subject: [PATCH 2/3] fix(browser): only strip fragment on empty breadcrumb URLs --- .../browser/src/integrations/breadcrumbs.ts | 7 ++- .../test/integrations/breadcrumbs.test.ts | 48 ++++++++++++++----- 2 files changed, 40 insertions(+), 15 deletions(-) diff --git a/packages/browser/src/integrations/breadcrumbs.ts b/packages/browser/src/integrations/breadcrumbs.ts index 58e35a2f4d8c..7506c8ce0de0 100644 --- a/packages/browser/src/integrations/breadcrumbs.ts +++ b/packages/browser/src/integrations/breadcrumbs.ts @@ -19,7 +19,6 @@ import { getClient, getEventDescription, parseUrl, - stripUrlQueryAndFragment, } from '@sentry/core'; import type { FetchHint, HandlerDataDom, HandlerDataHistory, HandlerDataXhr } from '@sentry/browser-utils'; import { @@ -325,9 +324,9 @@ function _getHistoryBreadcrumbHandler(client: Client): (handlerData: HandlerData }; } -function getBreadcrumbUrl(url: string): string { - const pageurl = url === '' ? WINDOW.location?.href : undefined; - return filterCollectedUrl(pageurl ? stripUrlQueryAndFragment(pageurl) : url); +function getBreadcrumbUrl(url: string, client?: Client): string { + const pageUrl = url === '' ? WINDOW.location?.href : undefined; + return filterCollectedUrl(pageUrl ? pageUrl.split('#')[0]! : url, client); } function _isEvent(event: unknown): event is Event { diff --git a/packages/browser/test/integrations/breadcrumbs.test.ts b/packages/browser/test/integrations/breadcrumbs.test.ts index 9e2b3b9aa284..e65bdd213875 100644 --- a/packages/browser/test/integrations/breadcrumbs.test.ts +++ b/packages/browser/test/integrations/breadcrumbs.test.ts @@ -7,7 +7,8 @@ import { getDefaultBrowserClientOptions } from '../helper/browser-client-options describe('Breadcrumbs', () => { afterEach(() => { vi.unstubAllGlobals(); - vi.resetAllMocks(); + vi.restoreAllMocks(); + SentryCore.getCurrentScope().setClient(undefined); }); it('Should add sentry breadcrumb', async () => { @@ -25,8 +26,13 @@ describe('Breadcrumbs', () => { client.captureMessage('test'); await flush(2000); - expect(addBreadcrumbSpy.mock.calls[0]?.[0]?.category).toEqual('sentry.event'); - expect(addBreadcrumbSpy.mock.calls[0]?.[0]?.message).toEqual('test'); + expect(addBreadcrumbSpy).toHaveBeenCalledWith( + expect.objectContaining({ + category: 'sentry.event', + message: 'test', + }), + expect.anything(), + ); }); describe('empty request URLs', () => { @@ -54,9 +60,9 @@ describe('Breadcrumbs', () => { } const pageHref = 'https://app.example.com/users/42?token=secret#section'; - const pagePath = 'https://app.example.com/users/42'; + const expectedBreadcrumbUrl = 'https://app.example.com/users/42?token=[Filtered]'; - it('shows the page URL without query or fragment in a fetch breadcrumb', () => { + it('resolves an empty fetch URL against location.href, drops fragment, and filters query params', () => { vi.stubGlobal('location', { href: pageHref }); const { addBreadcrumbSpy, fetchHandler } = setup(); @@ -68,10 +74,15 @@ describe('Breadcrumbs', () => { response: { status: 200 } as Response, }); - expect(addBreadcrumbSpy.mock.calls[0]?.[0]?.data?.url).toBe(pagePath); + expect(addBreadcrumbSpy).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ url: expectedBreadcrumbUrl }), + }), + expect.anything(), + ); }); - it('shows the page URL without query or fragment in a failed fetch breadcrumb', () => { + it('resolves an empty failed fetch URL against location.href, drops fragment, and filters query params', () => { vi.stubGlobal('location', { href: pageHref }); const { addBreadcrumbSpy, fetchHandler } = setup(); @@ -83,10 +94,15 @@ describe('Breadcrumbs', () => { error: new TypeError('Failed to fetch'), }); - expect(addBreadcrumbSpy.mock.calls[0]?.[0]?.data?.url).toBe(pagePath); + expect(addBreadcrumbSpy).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ url: expectedBreadcrumbUrl }), + }), + expect.anything(), + ); }); - it('shows the page URL without query or fragment in an XHR breadcrumb', () => { + it('resolves an empty XHR URL against location.href, drops fragment, and filters query params', () => { vi.stubGlobal('location', { href: pageHref }); const { addBreadcrumbSpy, xhrHandler } = setup(); @@ -98,7 +114,12 @@ describe('Breadcrumbs', () => { endTimestamp: 2, } as browserUtils.HandlerDataXhr); - expect(addBreadcrumbSpy.mock.calls[0]?.[0]?.data?.url).toBe(pagePath); + expect(addBreadcrumbSpy).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ url: expectedBreadcrumbUrl }), + }), + expect.anything(), + ); }); it('leaves non-empty URLs unchanged', () => { @@ -113,7 +134,12 @@ describe('Breadcrumbs', () => { response: { status: 200 } as Response, }); - expect(addBreadcrumbSpy.mock.calls[0]?.[0]?.data?.url).toBe('/api/users'); + expect(addBreadcrumbSpy).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ url: '/api/users' }), + }), + expect.anything(), + ); }); }); }); From 933852e00cc90a95dd5982047828d237251f8b72 Mon Sep 17 00:00:00 2001 From: Kunal Vashisht Date: Tue, 10 Nov 2026 21:30:24 +0530 Subject: [PATCH 3/3] fix(browser): pass client to getBreadcrumbUrl for XHR and fetch breadcrumbs --- packages/browser/src/integrations/breadcrumbs.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/browser/src/integrations/breadcrumbs.ts b/packages/browser/src/integrations/breadcrumbs.ts index 7506c8ce0de0..1d2f3ab3ea5a 100644 --- a/packages/browser/src/integrations/breadcrumbs.ts +++ b/packages/browser/src/integrations/breadcrumbs.ts @@ -193,7 +193,7 @@ function _getXhrBreadcrumbHandler(client: Client): (handlerData: HandlerDataXhr) const data: XhrBreadcrumbData = { method, - url: getBreadcrumbUrl(url), + url: getBreadcrumbUrl(url, client), status_code, }; @@ -248,7 +248,7 @@ function _getFetchBreadcrumbHandler(client: Client): (handlerData: HandlerDataFe const breadcrumb = { category: 'fetch', - data: { ...handlerData.fetchData, url: getBreadcrumbUrl(handlerData.fetchData.url) }, + data: { ...handlerData.fetchData, url: getBreadcrumbUrl(handlerData.fetchData.url, client) }, level: 'error', type: 'http', } satisfies Breadcrumb; @@ -260,7 +260,7 @@ function _getFetchBreadcrumbHandler(client: Client): (handlerData: HandlerDataFe const response = handlerData.response as Response | undefined; const data: FetchBreadcrumbData = { ...handlerData.fetchData, - url: getBreadcrumbUrl(handlerData.fetchData.url), + url: getBreadcrumbUrl(handlerData.fetchData.url, client), status_code: response?.status, }; @@ -326,7 +326,7 @@ function _getHistoryBreadcrumbHandler(client: Client): (handlerData: HandlerData function getBreadcrumbUrl(url: string, client?: Client): string { const pageUrl = url === '' ? WINDOW.location?.href : undefined; - return filterCollectedUrl(pageUrl ? pageUrl.split('#')[0]! : url, client); + return filterCollectedUrl(pageUrl ? (pageUrl.split('#')[0] ?? '') : url, client); } function _isEvent(event: unknown): event is Event {