From 9eb974fcd113fcf19eb3af8df8d4a0a19fafdce1 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 10 Nov 2026 17:35:06 +0100 Subject: [PATCH] feat(browser): Extract XHR request instrumentation into `xhrTracingIntegration` `browserTracingIntegration` registers it by default, so nothing changes for its users. `browserTracingCoreIntegration` only instruments fetch, so fetch-only apps can leave XHR out of their bundle. --- packages/browser/src/index.ts | 1 + .../browser/src/integrations/xhrTracing.ts | 30 ++++ .../src/tracing/browserTracingIntegration.ts | 44 ++++-- packages/browser/src/tracing/request.ts | 148 ++++++++++-------- .../tracing/browserTracingIntegration.test.ts | 16 ++ 5 files changed, 164 insertions(+), 75 deletions(-) create mode 100644 packages/browser/src/integrations/xhrTracing.ts diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 0cab1fdf912f..94521c672d3d 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -52,6 +52,7 @@ export { export type { CachedRouteProvider, RouteProvider } from '@sentry/browser-utils'; export { interactionsIntegration } from './integrations/interactions'; export { longTasksIntegration } from './integrations/longTasks'; +export { xhrTracingIntegration } from './integrations/xhrTracing'; export type { RequestInstrumentationOptions } from './tracing/request'; export { diff --git a/packages/browser/src/integrations/xhrTracing.ts b/packages/browser/src/integrations/xhrTracing.ts new file mode 100644 index 000000000000..cbb5af79332d --- /dev/null +++ b/packages/browser/src/integrations/xhrTracing.ts @@ -0,0 +1,30 @@ +import type { IntegrationFn } from '@sentry/core'; +import { defineIntegration } from '@sentry/core'; +import type { RequestInstrumentationOptions } from '../tracing/request'; +import { instrumentXhrRequests } from '../tracing/request'; + +const INTEGRATION_NAME = 'XhrTracing'; + +type XhrTracingOptions = Partial< + Pick< + RequestInstrumentationOptions, + 'shouldCreateSpanForRequest' | 'enableHTTPTimings' | 'onRequestSpanStart' | 'onRequestSpanEnd' + > +>; + +const _xhrTracingIntegration = ((options: XhrTracingOptions = {}) => { + return { + name: INTEGRATION_NAME, + setup(client) { + instrumentXhrRequests(client, { + ...options, + tracePropagationTargets: client.getOptions().tracePropagationTargets, + }); + }, + }; +}) satisfies IntegrationFn; + +/** + * Creates spans for outgoing XHR requests and attaches trace propagation headers to them. + */ +export const xhrTracingIntegration = defineIntegration(_xhrTracingIntegration); diff --git a/packages/browser/src/tracing/browserTracingIntegration.ts b/packages/browser/src/tracing/browserTracingIntegration.ts index 252a27032b2f..5274d006b762 100644 --- a/packages/browser/src/tracing/browserTracingIntegration.ts +++ b/packages/browser/src/tracing/browserTracingIntegration.ts @@ -48,7 +48,8 @@ import type { WebVitalsOptions } from '../integrations/webVitals'; import { webVitalsIntegration } from '../integrations/webVitals'; import { registerBackgroundTabDetection } from './backgroundtab'; import { linkTraces } from './linkedTraces'; -import { defaultRequestInstrumentationOptions, instrumentOutgoingRequests } from './request'; +import { xhrTracingIntegration } from '../integrations/xhrTracing'; +import { defaultRequestInstrumentationOptions, instrumentFetchRequests } from './request'; import { SENTRY_SEGMENT_NAME_SOURCE, SENTRY_OP, @@ -286,7 +287,7 @@ export interface BrowserTracingOptions { */ export type BrowserTracingCoreOptions = Omit< BrowserTracingOptions, - 'enableInp' | 'webVitals' | 'enableElementTiming' | 'enableLongTask' | 'enableLongAnimationFrame' + 'enableInp' | 'webVitals' | 'enableElementTiming' | 'enableLongTask' | 'enableLongAnimationFrame' | 'traceXHR' >; const DEFAULT_BROWSER_TRACING_OPTIONS: BrowserTracingCoreOptions = { @@ -322,8 +323,18 @@ export const browserTracingIntegration = ((options: Partial { // Custom clients and test doubles may not implement it. @@ -342,6 +353,12 @@ export const browserTracingIntegration = ((options: Partial): void { - const { - traceFetch, - traceXHR, - shouldCreateSpanForRequest, - enableHTTPTimings, - tracePropagationTargets, - onRequestSpanStart, - onRequestSpanEnd, - } = { + const options = { ...defaultRequestInstrumentationOptions, ..._options, }; - const shouldCreateSpan = - typeof shouldCreateSpanForRequest === 'function' ? shouldCreateSpanForRequest : (_: string) => true; - - const shouldAttachHeadersWithTargets = (url: string): boolean => shouldAttachHeaders(url, tracePropagationTargets); + if (options.traceFetch) { + instrumentFetchRequests(client, options); + } - const spans: Record = {}; + if (options.traceXHR) { + instrumentXhrRequests(client, options); + } +} - const propagateTraceparent = (client as BrowserClient).getOptions().propagateTraceparent; +/** + * Registers the span creator for fetch requests. + */ +export function instrumentFetchRequests(client: Client, options: Partial): void { + const { enableHTTPTimings = true, onRequestSpanStart, onRequestSpanEnd } = options; + const { shouldCreateSpan, shouldAttachHeadersWithTargets, spans, propagateTraceparent } = getRequestSpanContext( + client, + options, + ); + + addFetchInstrumentationHandler(handlerData => { + const createdSpan = instrumentFetchRequest(handlerData, shouldCreateSpan, shouldAttachHeadersWithTargets, spans, { + propagateTraceparent, + onRequestSpanEnd, + // The generic fetch instrumentation has no page origin to resolve relative URLs against. + urlBase: WINDOW.location?.href, + }); - if (traceFetch) { - addFetchInstrumentationHandler(handlerData => { - const createdSpan = instrumentFetchRequest(handlerData, shouldCreateSpan, shouldAttachHeadersWithTargets, spans, { - propagateTraceparent, - onRequestSpanEnd, - // The generic fetch instrumentation has no page origin to resolve relative URLs against. - urlBase: WINDOW.location?.href, + // We cannot use `window.location` in the generic fetch instrumentation, + // but we need it for reliable `server.address` attribute. + // so we extend this in here + if (createdSpan) { + const fullUrl = getFullURL(handlerData.fetchData.url); + // `parseUrl` returns the raw authority — userinfo credentials must never reach an attribute. + const host = fullUrl ? parseUrl(fullUrl).host?.replace(/^.*@/, '') : undefined; + const sanitizedFullUrl = fullUrl ? stripDataUrlContent(fullUrl) : undefined; + createdSpan.setAttributes({ + [URL_FULL]: filterCollectedUrl(sanitizedFullUrl), + [SERVER_ADDRESS]: host, }); - // We cannot use `window.location` in the generic fetch instrumentation, - // but we need it for reliable `server.address` attribute. - // so we extend this in here - if (createdSpan) { - const fullUrl = getFullURL(handlerData.fetchData.url); - // `parseUrl` returns the raw authority — userinfo credentials must never reach an attribute. - const host = fullUrl ? parseUrl(fullUrl).host?.replace(/^.*@/, '') : undefined; - const sanitizedFullUrl = fullUrl ? stripDataUrlContent(fullUrl) : undefined; - createdSpan.setAttributes({ - [URL_FULL]: filterCollectedUrl(sanitizedFullUrl), - [SERVER_ADDRESS]: host, - }); - - if (enableHTTPTimings) { - addHTTPTimings(createdSpan, client); - } - - onRequestSpanStart?.(createdSpan, { headers: handlerData.headers }); + if (enableHTTPTimings) { + addHTTPTimings(createdSpan, client); } - }); - } - if (traceXHR) { - addXhrInstrumentationHandler(handlerData => { - const createdSpan = xhrCallback( - handlerData, - shouldCreateSpan, - shouldAttachHeadersWithTargets, - spans, - propagateTraceparent, - onRequestSpanEnd, - ); + onRequestSpanStart?.(createdSpan, { headers: handlerData.headers }); + } + }); +} - if (createdSpan) { - if (enableHTTPTimings) { - addHTTPTimings(createdSpan, client); - } +/** + * Registers the span creator for XHR requests. + */ +export function instrumentXhrRequests(client: Client, options: Partial): void { + const { enableHTTPTimings = true, onRequestSpanStart, onRequestSpanEnd } = options; + const { shouldCreateSpan, shouldAttachHeadersWithTargets, spans, propagateTraceparent } = getRequestSpanContext( + client, + options, + ); + + addXhrInstrumentationHandler(handlerData => { + const createdSpan = xhrCallback( + handlerData, + shouldCreateSpan, + shouldAttachHeadersWithTargets, + spans, + propagateTraceparent, + onRequestSpanEnd, + ); - onRequestSpanStart?.(createdSpan, { - headers: createHeadersSafely(handlerData.xhr.__sentry_xhr_v3__?.request_headers), - }); + if (createdSpan) { + if (enableHTTPTimings) { + addHTTPTimings(createdSpan, client); } - }); - } + + onRequestSpanStart?.(createdSpan, { + headers: createHeadersSafely(handlerData.xhr.__sentry_xhr_v3__?.request_headers), + }); + } + }); +} + +function getRequestSpanContext( + client: Client, + { shouldCreateSpanForRequest, tracePropagationTargets }: Partial, +): { + shouldCreateSpan: (url: string) => boolean; + shouldAttachHeadersWithTargets: (url: string) => boolean; + spans: Record; + propagateTraceparent: boolean | undefined; +} { + return { + shouldCreateSpan: typeof shouldCreateSpanForRequest === 'function' ? shouldCreateSpanForRequest : () => true, + shouldAttachHeadersWithTargets: url => shouldAttachHeaders(url, tracePropagationTargets), + spans: {}, + propagateTraceparent: (client as BrowserClient).getOptions().propagateTraceparent, + }; } /** diff --git a/packages/browser/test/tracing/browserTracingIntegration.test.ts b/packages/browser/test/tracing/browserTracingIntegration.test.ts index 0ad2754a5ea4..7c8e37845aeb 100644 --- a/packages/browser/test/tracing/browserTracingIntegration.test.ts +++ b/packages/browser/test/tracing/browserTracingIntegration.test.ts @@ -246,6 +246,22 @@ describe('browserTracingIntegration', () => { expect(!!client.getIntegrationByName('LongTasks')).toBe(registered); }); + it.each([ + ['registers xhrTracingIntegration by default', {}, true], + ['does not register xhrTracingIntegration when traceXHR is off', { traceXHR: false }, false], + ])('%s', (_name, options, registered) => { + const client = new BrowserClient( + getDefaultBrowserClientOptions({ + tracesSampleRate: 1, + integrations: [browserTracingIntegration(options)], + }), + ); + setCurrentClient(client); + client.init(); + + expect(!!client.getIntegrationByName('XhrTracing')).toBe(registered); + }); + it('keeps the webVitalsIntegration the user supplies', () => { const userWebVitals = webVitalsModule.webVitalsIntegration({ softNavigations: false });