diff --git a/.size-limit.js b/.size-limit.js index eed705e16da6..ca26288b07b3 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -139,7 +139,7 @@ module.exports = [ path: 'packages/vue/build/esm/index.js', import: createImport('init', 'browserTracingIntegration'), gzip: true, - limit: '39.5 KB', + limit: '40 KB', }, // Svelte SDK (ESM) { diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-start/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-start/init.js new file mode 100644 index 000000000000..2c85bd05b765 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-start/init.js @@ -0,0 +1,18 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ + idleTimeout: 1000, + onRequestSpanStart(span, { headers }) { + if (headers) { + span.setAttribute('hook.called.headers', headers.get('foo')); + } + }, + }), + ], + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-start/subject.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-start/subject.js new file mode 100644 index 000000000000..494ce7d23a05 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-start/subject.js @@ -0,0 +1,11 @@ +fetch('http://sentry-test-site-fetch.example/', { + headers: { + foo: 'fetch', + }, +}); + +const xhr = new XMLHttpRequest(); + +xhr.open('GET', 'http://sentry-test-site-xhr.example/'); +xhr.setRequestHeader('foo', 'xhr'); +xhr.send(); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-start/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-start/test.ts new file mode 100644 index 000000000000..91b0c1333298 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-start/test.ts @@ -0,0 +1,52 @@ +import { expect } from '@playwright/test'; +import type { Event } from '@sentry/core'; + +import { sentryTest } from '../../../../utils/fixtures'; +import { getMultipleSentryEnvelopeRequests, shouldSkipTracingTest } from '../../../../utils/helpers'; + +sentryTest('should call onRequestSpanStart hook', async ({ browserName, getLocalTestUrl, page }) => { + const supportedBrowsers = ['chromium', 'firefox']; + + if (shouldSkipTracingTest() || !supportedBrowsers.includes(browserName)) { + sentryTest.skip(); + } + + await page.route('http://sentry-test-site-fetch.example/', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: '', + }); + }); + await page.route('http://sentry-test-site-xhr.example/', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: '', + }); + }); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const envelopes = await getMultipleSentryEnvelopeRequests(page, 2, { url, timeout: 10000 }); + + const tracingEvent = envelopes[envelopes.length - 1]; // last envelope contains tracing data on all browsers + + expect(tracingEvent.spans).toContainEqual( + expect.objectContaining({ + op: 'http.client', + data: expect.objectContaining({ + 'hook.called.headers': 'xhr', + }), + }), + ); + + expect(tracingEvent.spans).toContainEqual( + expect.objectContaining({ + op: 'http.client', + data: expect.objectContaining({ + 'hook.called.headers': 'fetch', + }), + }), + ); +}); diff --git a/packages/browser/src/tracing/browserTracingIntegration.ts b/packages/browser/src/tracing/browserTracingIntegration.ts index 062b308527d6..fab45cd1ed4f 100644 --- a/packages/browser/src/tracing/browserTracingIntegration.ts +++ b/packages/browser/src/tracing/browserTracingIntegration.ts @@ -9,7 +9,7 @@ import { startTrackingLongTasks, startTrackingWebVitals, } from '@sentry-internal/browser-utils'; -import type { Client, IntegrationFn, Span, StartSpanOptions, TransactionSource } from '@sentry/core'; +import type { Client, IntegrationFn, Span, StartSpanOptions, TransactionSource, WebFetchHeaders } from '@sentry/core'; import { GLOBAL_OBJ, SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON, @@ -195,6 +195,13 @@ export interface BrowserTracingOptions { * Default: (url: string) => true */ shouldCreateSpanForRequest?(this: void, url: string): boolean; + + /** + * This callback is invoked directly after a span is started for an outgoing fetch or XHR request. + * You can use it to annotate the span with additional data or attributes, for example by setting + * attributes based on the passed request headers. + */ + onRequestSpanStart?(span: Span, requestInformation: { headers?: WebFetchHeaders }): void; } const DEFAULT_BROWSER_TRACING_OPTIONS: BrowserTracingOptions = { @@ -246,6 +253,7 @@ export const browserTracingIntegration = ((_options: Partial true */ shouldCreateSpanForRequest?(this: void, url: string): boolean; + + /** + * Is called when spans are started for outgoing requests. + */ + onRequestSpanStart?(span: Span, requestInformation: { headers?: WebFetchHeaders }): void; } const responseToSpanId = new WeakMap(); @@ -119,10 +124,9 @@ export function instrumentOutgoingRequests(client: Client, _options?: Partial { const createdSpan = xhrCallback(handlerData, shouldCreateSpan, shouldAttachHeadersWithTargets, spans); - if (enableHTTPTimings && createdSpan) { - addHTTPTimings(createdSpan); + if (createdSpan) { + if (enableHTTPTimings) { + addHTTPTimings(createdSpan); + } + + let headers; + try { + headers = new Headers(handlerData.xhr.__sentry_xhr_v3__?.request_headers); + } catch { + // noop + } + onRequestSpanStart?.(createdSpan, { headers }); } }); } diff --git a/packages/core/src/fetch.ts b/packages/core/src/fetch.ts index 5f0c9cc30b56..379097936ef4 100644 --- a/packages/core/src/fetch.ts +++ b/packages/core/src/fetch.ts @@ -4,7 +4,7 @@ import { SPAN_STATUS_ERROR, setHttpStatus, startInactiveSpan } from './tracing'; import { SentryNonRecordingSpan } from './tracing/sentryNonRecordingSpan'; import type { FetchBreadcrumbHint, HandlerDataFetch, Span, SpanAttributes, SpanOrigin } from './types-hoist'; import { SENTRY_BAGGAGE_KEY_PREFIX } from './utils-hoist/baggage'; -import { isInstanceOf } from './utils-hoist/is'; +import { isInstanceOf, isRequest } from './utils-hoist/is'; import { getSanitizedUrlStringFromUrlObject, isURLObjectRelative, parseStringToURLObject } from './utils-hoist/url'; import { hasSpansEnabled } from './utils/hasSpansEnabled'; import { getActiveSpan } from './utils/spanUtils'; @@ -227,10 +227,6 @@ function stripBaggageHeaderOfSentryBaggageValues(baggageHeader: string): string ); } -function isRequest(request: unknown): request is Request { - return typeof Request !== 'undefined' && isInstanceOf(request, Request); -} - function isHeaders(headers: unknown): headers is Headers { return typeof Headers !== 'undefined' && isInstanceOf(headers, Headers); } diff --git a/packages/core/src/types-hoist/instrument.ts b/packages/core/src/types-hoist/instrument.ts index 420482579dd9..5eba6066432a 100644 --- a/packages/core/src/types-hoist/instrument.ts +++ b/packages/core/src/types-hoist/instrument.ts @@ -61,6 +61,8 @@ export interface HandlerDataFetch { error?: unknown; // This is to be consumed by the HttpClient integration virtualError?: unknown; + /** Headers that the user passed to the fetch request. */ + headers?: WebFetchHeaders; } export interface HandlerDataDom { diff --git a/packages/core/src/utils-hoist/instrument/fetch.ts b/packages/core/src/utils-hoist/instrument/fetch.ts index 71c5148fae9c..7d6185d00639 100644 --- a/packages/core/src/utils-hoist/instrument/fetch.ts +++ b/packages/core/src/utils-hoist/instrument/fetch.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import type { HandlerDataFetch } from '../../types-hoist'; +import type { HandlerDataFetch, WebFetchHeaders } from '../../types-hoist'; -import { isError } from '../is'; +import { isError, isRequest } from '../is'; import { addNonEnumerableProperty, fill } from '../object'; import { supportsNativeFetch } from '../supports'; import { timestampInSeconds } from '../time'; @@ -67,6 +67,7 @@ function instrumentFetch(onFetchResolved?: (response: Response) => void, skipNat startTimestamp: timestampInSeconds() * 1000, // // Adding the error to be able to fingerprint the failed fetch event in HttpClient instrumentation virtualError, + headers: getHeadersFromFetchArgs(args), }; // if there is no callback, fetch is instrumented directly @@ -253,3 +254,26 @@ export function parseFetchArgs(fetchArgs: unknown[]): { method: string; url: str method: hasProp(arg, 'method') ? String(arg.method).toUpperCase() : 'GET', }; } + +function getHeadersFromFetchArgs(fetchArgs: unknown[]): WebFetchHeaders | undefined { + const [requestArgument, optionsArgument] = fetchArgs; + + try { + if ( + typeof optionsArgument === 'object' && + optionsArgument !== null && + 'headers' in optionsArgument && + optionsArgument.headers + ) { + return new Headers(optionsArgument.headers as any); + } + + if (isRequest(requestArgument)) { + return new Headers(requestArgument.headers); + } + } catch { + // noop + } + + return; +} diff --git a/packages/core/src/utils-hoist/is.ts b/packages/core/src/utils-hoist/is.ts index cfa9bc141e20..ab5e150e2394 100644 --- a/packages/core/src/utils-hoist/is.ts +++ b/packages/core/src/utils-hoist/is.ts @@ -201,3 +201,12 @@ export function isVueViewModel(wat: unknown): boolean { // Not using Object.prototype.toString because in Vue 3 it would read the instance's Symbol(Symbol.toStringTag) property. return !!(typeof wat === 'object' && wat !== null && ((wat as VueViewModel).__isVue || (wat as VueViewModel)._isVue)); } + +/** + * Checks whether the given parameter is a Standard Web API Request instance. + * + * Returns false if Request is not available in the current runtime. + */ +export function isRequest(request: unknown): request is Request { + return typeof Request !== 'undefined' && isInstanceOf(request, Request); +}