Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/browser/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
30 changes: 30 additions & 0 deletions packages/browser/src/integrations/xhrTracing.ts
Original file line number Diff line number Diff line change
@@ -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);
44 changes: 30 additions & 14 deletions packages/browser/src/tracing/browserTracingIntegration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -322,8 +323,18 @@ export const browserTracingIntegration = ((options: Partial<BrowserTracingOption
});
}

// oxlint-disable-next-line typescript/no-deprecated -- still honoured until it is removed
const { enableInp = true, webVitals, enableLongTask = true, enableLongAnimationFrame = true } = options;
const {
// oxlint-disable-next-line typescript/no-deprecated -- still honoured until it is removed
enableInp = true,
webVitals,
enableLongTask = true,
enableLongAnimationFrame = true,
traceXHR = true,
shouldCreateSpanForRequest,
enableHTTPTimings,
onRequestSpanStart,
onRequestSpanEnd,
} = options;

return _browserTracingIntegration(options, client => {
// Custom clients and test doubles may not implement it.
Expand All @@ -342,6 +353,12 @@ export const browserTracingIntegration = ((options: Partial<BrowserTracingOption
if (enableLongTask || enableLongAnimationFrame) {
client.addIntegration(longTasksIntegration({ enableLongTask, enableLongAnimationFrame }));
}

if (traceXHR) {
client.addIntegration(
xhrTracingIntegration({ shouldCreateSpanForRequest, enableHTTPTimings, onRequestSpanStart, onRequestSpanEnd }),
);
}
});
}) satisfies IntegrationFn;

Expand Down Expand Up @@ -372,7 +389,6 @@ function _browserTracingIntegration(
childSpanTimeout,
markBackgroundSpan,
traceFetch,
traceXHR,
shouldCreateSpanForRequest,
enableHTTPTimings,
ignoreResourceSpans,
Expand Down Expand Up @@ -761,15 +777,15 @@ function _browserTracingIntegration(
registerBackgroundTabDetection();
}

instrumentOutgoingRequests(client, {
traceFetch,
traceXHR,
tracePropagationTargets: client.getOptions().tracePropagationTargets,
shouldCreateSpanForRequest,
enableHTTPTimings,
onRequestSpanStart,
onRequestSpanEnd,
});
if (traceFetch) {
instrumentFetchRequests(client, {
tracePropagationTargets: client.getOptions().tracePropagationTargets,
shouldCreateSpanForRequest,
enableHTTPTimings,
onRequestSpanStart,
onRequestSpanEnd,
});
}
},
} satisfies Integration;
}
Expand Down
148 changes: 87 additions & 61 deletions packages/browser/src/tracing/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,81 +127,107 @@ export const defaultRequestInstrumentationOptions: RequestInstrumentationOptions

/** Registers span creators for xhr and fetch requests */
export function instrumentOutgoingRequests(client: Client, _options?: Partial<RequestInstrumentationOptions>): 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<string, Span> = {};
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<RequestInstrumentationOptions>): 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<RequestInstrumentationOptions>): 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<RequestInstrumentationOptions>,
): {
shouldCreateSpan: (url: string) => boolean;
shouldAttachHeadersWithTargets: (url: string) => boolean;
spans: Record<string, Span>;
propagateTraceparent: boolean | undefined;
} {
return {
shouldCreateSpan: typeof shouldCreateSpanForRequest === 'function' ? shouldCreateSpanForRequest : () => true,
shouldAttachHeadersWithTargets: url => shouldAttachHeaders(url, tracePropagationTargets),
spans: {},
propagateTraceparent: (client as BrowserClient).getOptions().propagateTraceparent,
};
}

/**
Expand Down
16 changes: 16 additions & 0 deletions packages/browser/test/tracing/browserTracingIntegration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });

Expand Down
Loading