diff --git a/.size-limit.js b/.size-limit.js index 32d5d19e1495..18826dd4a495 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -215,7 +215,7 @@ module.exports = [ import: createImport('init'), ignore: ['$app/stores'], gzip: true, - limit: '41 KB', + limit: '42 KB', }, // Node-Core SDK (ESM) { diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-end/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-end/init.js new file mode 100644 index 000000000000..9627bfc003e7 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-end/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, + onRequestSpanEnd(span, { headers }) { + if (headers) { + span.setAttribute('hook.called.response-type', headers.get('x-response-type')); + } + }, + }), + ], + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-end/subject.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-end/subject.js new file mode 100644 index 000000000000..8a1ec65972f2 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-end/subject.js @@ -0,0 +1,11 @@ +fetch('http://sentry-test.io/fetch', { + headers: { + foo: 'fetch', + }, +}); + +const xhr = new XMLHttpRequest(); + +xhr.open('GET', 'http://sentry-test.io/xhr'); +xhr.setRequestHeader('foo', 'xhr'); +xhr.send(); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-end/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-end/test.ts new file mode 100644 index 000000000000..03bfc13814af --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-end/test.ts @@ -0,0 +1,61 @@ +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 onRequestSpanEnd hook', async ({ browserName, getLocalTestUrl, page }) => { + const supportedBrowsers = ['chromium', 'firefox']; + + if (shouldSkipTracingTest() || !supportedBrowsers.includes(browserName)) { + sentryTest.skip(); + } + + await page.route('http://sentry-test.io/fetch', async route => { + await route.fulfill({ + status: 200, + headers: { + 'Content-Type': 'application/json', + 'X-Response-Type': 'fetch', + 'access-control-expose-headers': '*', + }, + body: '', + }); + }); + await page.route('http://sentry-test.io/xhr', async route => { + await route.fulfill({ + status: 200, + headers: { + 'Content-Type': 'application/json', + 'X-Response-Type': 'xhr', + 'access-control-expose-headers': '*', + }, + 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({ + type: 'xhr', + 'hook.called.response-type': 'xhr', + }), + }), + ); + + expect(tracingEvent.spans).toContainEqual( + expect.objectContaining({ + op: 'http.client', + data: expect.objectContaining({ + type: 'fetch', + 'hook.called.response-type': 'fetch', + }), + }), + ); +}); diff --git a/packages/browser-utils/src/index.ts b/packages/browser-utils/src/index.ts index accf3cb3a278..3f8080e623fe 100644 --- a/packages/browser-utils/src/index.ts +++ b/packages/browser-utils/src/index.ts @@ -28,7 +28,14 @@ export { fetch, setTimeout, clearCachedImplementation, getNativeImplementation } export { addXhrInstrumentationHandler, SENTRY_XHR_DATA_KEY } from './instrument/xhr'; -export { getBodyString, getFetchRequestArgBody, serializeFormData } from './networkUtils'; +export { + getBodyString, + getFetchRequestArgBody, + serializeFormData, + parseXhrResponseHeaders, + getFetchResponseHeaders, + filterAllowedHeaders, +} from './networkUtils'; export { resourceTimingToSpanAttributes } from './metrics/resourceTiming'; diff --git a/packages/browser-utils/src/networkUtils.ts b/packages/browser-utils/src/networkUtils.ts index 607434251872..05353f87ca9f 100644 --- a/packages/browser-utils/src/networkUtils.ts +++ b/packages/browser-utils/src/networkUtils.ts @@ -54,3 +54,64 @@ export function getFetchRequestArgBody(fetchArgs: unknown[] = []): RequestInit[' return (fetchArgs[1] as RequestInit).body; } + +/** + * Parses XMLHttpRequest response headers into a Record. + * Extracted from replay internals to be reusable. + */ +export function parseXhrResponseHeaders(xhr: XMLHttpRequest): Record { + let headers: string | undefined; + try { + headers = xhr.getAllResponseHeaders(); + } catch (error) { + DEBUG_BUILD && debug.error(error, 'Failed to get xhr response headers', xhr); + return {}; + } + + if (!headers) { + return {}; + } + + return headers.split('\r\n').reduce((acc: Record, line: string) => { + const [key, value] = line.split(': ') as [string, string | undefined]; + if (value) { + acc[key.toLowerCase()] = value; + } + return acc; + }, {}); +} + +/** + * Gets specific headers from a Headers object (Fetch API). + * Extracted from replay internals to be reusable. + */ +export function getFetchResponseHeaders(headers: Headers, allowedHeaders: string[]): Record { + const allHeaders: Record = {}; + + allowedHeaders.forEach(header => { + const value = headers.get(header); + if (value) { + allHeaders[header.toLowerCase()] = value; + } + }); + + return allHeaders; +} + +/** + * Filters headers based on an allowed list. + * Extracted from replay internals to be reusable. + */ +export function filterAllowedHeaders( + headers: Record, + allowedHeaders: string[], +): Record { + return Object.entries(headers).reduce((filteredHeaders: Record, [key, value]) => { + const normalizedKey = key.toLowerCase(); + // Avoid putting empty strings into the headers + if (allowedHeaders.includes(normalizedKey) && value) { + filteredHeaders[normalizedKey] = value; + } + return filteredHeaders; + }, {}); +} diff --git a/packages/browser/src/tracing/browserTracingIntegration.ts b/packages/browser/src/tracing/browserTracingIntegration.ts index a79f629855d7..2e3eebe86845 100644 --- a/packages/browser/src/tracing/browserTracingIntegration.ts +++ b/packages/browser/src/tracing/browserTracingIntegration.ts @@ -1,5 +1,13 @@ /* eslint-disable max-lines */ -import type { Client, IntegrationFn, Span, StartSpanOptions, TransactionSource, WebFetchHeaders } from '@sentry/core'; +import type { + Client, + IntegrationFn, + RequestHookInfo, + ResponseHookInfo, + Span, + StartSpanOptions, + TransactionSource, +} from '@sentry/core'; import { addNonEnumerableProperty, browserPerformanceTimeOrigin, @@ -297,7 +305,12 @@ export interface BrowserTracingOptions { * 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; + onRequestSpanStart?(span: Span, requestInformation: RequestHookInfo): void; + + /** + * Is called when spans end for outgoing requests, providing access to response headers. + */ + onRequestSpanEnd?(span: Span, responseInformation: ResponseHookInfo): void; } const DEFAULT_BROWSER_TRACING_OPTIONS: BrowserTracingOptions = { @@ -365,6 +378,7 @@ export const browserTracingIntegration = ((options: Partial(); @@ -125,6 +138,7 @@ export function instrumentOutgoingRequests(client: Client, _options?: Partial { const createdSpan = instrumentFetchRequest(handlerData, shouldCreateSpan, shouldAttachHeadersWithTargets, spans, { propagateTraceparent, + onRequestSpanEnd, }); if (handlerData.response && handlerData.fetchData.__span) { @@ -205,6 +220,7 @@ export function instrumentOutgoingRequests(client: Client, _options?: Partial boolean, spans: Record, propagateTraceparent?: boolean, + onRequestSpanEnd?: RequestInstrumentationOptions['onRequestSpanEnd'], ): Span | undefined { const xhr = handlerData.xhr; const sentryXhrData = xhr?.[SENTRY_XHR_DATA_KEY]; @@ -337,6 +341,10 @@ function xhrCallback( setHttpStatus(span, sentryXhrData.status_code); span.end(); + onRequestSpanEnd?.(span, { + headers: createHeadersSafely(parseXhrResponseHeaders(xhr as XMLHttpRequest & SentryWrappedXMLHttpRequest)), + }); + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete delete spans[spanId]; } @@ -438,18 +446,3 @@ function setHeaderOnXhr( // Error: InvalidStateError: Failed to execute 'setRequestHeader' on 'XMLHttpRequest': The object's state must be OPENED. } } - -function baggageHeaderHasSentryValues(baggageHeader: string): boolean { - return baggageHeader.split(',').some(value => value.trim().startsWith('sentry-')); -} - -function getFullURL(url: string): string | undefined { - try { - // By adding a base URL to new URL(), this will also work for relative urls - // If `url` is a full URL, the base URL is ignored anyhow - const parsed = new URL(url, WINDOW.location.origin); - return parsed.href; - } catch { - return undefined; - } -} diff --git a/packages/browser/src/tracing/utils.ts b/packages/browser/src/tracing/utils.ts new file mode 100644 index 000000000000..c422e3438fd9 --- /dev/null +++ b/packages/browser/src/tracing/utils.ts @@ -0,0 +1,46 @@ +import { WINDOW } from '../helpers'; + +/** + * Checks if the baggage header has Sentry values. + */ +export function baggageHeaderHasSentryValues(baggageHeader: string): boolean { + return baggageHeader.split(',').some(value => value.trim().startsWith('sentry-')); +} + +/** + * Gets the full URL from a given URL string. + */ +export function getFullURL(url: string): string | undefined { + try { + // By adding a base URL to new URL(), this will also work for relative urls + // If `url` is a full URL, the base URL is ignored anyhow + const parsed = new URL(url, WINDOW.location.origin); + return parsed.href; + } catch { + return undefined; + } +} + +/** + * Checks if the entry is a PerformanceResourceTiming. + */ +export function isPerformanceResourceTiming(entry: PerformanceEntry): entry is PerformanceResourceTiming { + return ( + entry.entryType === 'resource' && + 'initiatorType' in entry && + typeof (entry as PerformanceResourceTiming).nextHopProtocol === 'string' && + (entry.initiatorType === 'fetch' || entry.initiatorType === 'xmlhttprequest') + ); +} + +/** + * Creates a Headers object from a record of string key-value pairs, and returns undefined if it fails. + */ +export function createHeadersSafely(headers: Record | undefined): Headers | undefined { + try { + return new Headers(headers); + } catch { + // noop + return undefined; + } +} diff --git a/packages/core/src/fetch.ts b/packages/core/src/fetch.ts index 9ab62ec732da..4759651f252a 100644 --- a/packages/core/src/fetch.ts +++ b/packages/core/src/fetch.ts @@ -4,6 +4,7 @@ import { setHttpStatus, SPAN_STATUS_ERROR, startInactiveSpan } from './tracing'; import { SentryNonRecordingSpan } from './tracing/sentryNonRecordingSpan'; import type { FetchBreadcrumbHint } from './types-hoist/breadcrumb'; import type { HandlerDataFetch } from './types-hoist/instrument'; +import type { ResponseHookInfo } from './types-hoist/request'; import type { Span, SpanAttributes, SpanOrigin } from './types-hoist/span'; import { SENTRY_BAGGAGE_KEY_PREFIX } from './utils/baggage'; import { hasSpansEnabled } from './utils/hasSpansEnabled'; @@ -24,6 +25,7 @@ type PolymorphicRequestHeaders = interface InstrumentFetchRequestOptions { spanOrigin?: SpanOrigin; propagateTraceparent?: boolean; + onRequestSpanEnd?: (span: Span, responseInformation: ResponseHookInfo) => void; } /** @@ -82,6 +84,8 @@ export function instrumentFetchRequest( if (span) { endSpan(span, handlerData); + _callOnRequestSpanEnd(span, handlerData, spanOriginOrOptions); + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete delete spans[spanId]; } @@ -141,6 +145,24 @@ export function instrumentFetchRequest( return span; } +/** + * Calls the onRequestSpanEnd callback if it is defined. + */ +export function _callOnRequestSpanEnd( + span: Span, + handlerData: HandlerDataFetch, + spanOriginOrOptions?: SpanOrigin | InstrumentFetchRequestOptions, +): void { + const onRequestSpanEnd = + typeof spanOriginOrOptions === 'object' && spanOriginOrOptions !== null + ? spanOriginOrOptions.onRequestSpanEnd + : undefined; + + onRequestSpanEnd?.(span, { + headers: handlerData.response?.headers, + }); +} + /** * Adds sentry-trace and baggage headers to the various forms of fetch headers. * exported only for testing purposes diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e0daefd54d76..0c871c01b4f1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -390,7 +390,13 @@ export type { SendFeedbackParams, UserFeedback, } from './types-hoist/feedback'; -export type { QueryParams, RequestEventData, SanitizedRequestData } from './types-hoist/request'; +export type { + QueryParams, + RequestEventData, + RequestHookInfo, + ResponseHookInfo, + SanitizedRequestData, +} from './types-hoist/request'; export type { Runtime } from './types-hoist/runtime'; export type { SdkInfo } from './types-hoist/sdkinfo'; export type { SdkMetadata } from './types-hoist/sdkmetadata'; diff --git a/packages/core/src/types-hoist/request.ts b/packages/core/src/types-hoist/request.ts index 834249cdd24e..028acbe9f77e 100644 --- a/packages/core/src/types-hoist/request.ts +++ b/packages/core/src/types-hoist/request.ts @@ -1,3 +1,5 @@ +import type { WebFetchHeaders } from './webfetchapi'; + /** * Request data included in an event as sent to Sentry. */ @@ -24,3 +26,19 @@ export type SanitizedRequestData = { 'http.fragment'?: string; 'http.query'?: string; }; + +export interface RequestHookInfo { + headers?: WebFetchHeaders; +} + +export interface ResponseHookInfo { + /** + * Headers from the response. + */ + headers?: WebFetchHeaders; + + /** + * Error that may have occurred during the request. + */ + error?: unknown; +} diff --git a/packages/replay-internal/src/coreHandlers/util/fetchUtils.ts b/packages/replay-internal/src/coreHandlers/util/fetchUtils.ts index 4de9d08805b3..3ed550e65d9e 100644 --- a/packages/replay-internal/src/coreHandlers/util/fetchUtils.ts +++ b/packages/replay-internal/src/coreHandlers/util/fetchUtils.ts @@ -1,6 +1,11 @@ import type { Breadcrumb, FetchBreadcrumbData } from '@sentry/core'; import type { FetchHint, NetworkMetaWarning } from '@sentry-internal/browser-utils'; -import { getBodyString, getFetchRequestArgBody, setTimeout } from '@sentry-internal/browser-utils'; +import { + getBodyString, + getFetchRequestArgBody, + getFetchResponseHeaders, + setTimeout, +} from '@sentry-internal/browser-utils'; import { DEBUG_BUILD } from '../../debug-build'; import type { ReplayContainer, @@ -139,7 +144,7 @@ export async function _getResponseInfo( return buildSkippedNetworkRequestOrResponse(responseBodySize); } - const headers = response ? getAllHeaders(response.headers, networkResponseHeaders) : {}; + const headers = response ? getFetchResponseHeaders(response.headers, networkResponseHeaders) : {}; if (!response || (!networkCaptureBodies && responseBodySize !== undefined)) { return buildNetworkRequestOrResponse(headers, responseBodySize, undefined); @@ -215,18 +220,6 @@ async function _parseFetchResponseBody(response: Response): Promise<[string | un } } -function getAllHeaders(headers: Headers, allowedHeaders: string[]): Record { - const allHeaders: Record = {}; - - allowedHeaders.forEach(header => { - if (headers.get(header)) { - allHeaders[header] = headers.get(header) as string; - } - }); - - return allHeaders; -} - function getRequestHeaders(fetchArgs: unknown[], allowedHeaders: string[]): Record { if (fetchArgs.length === 1 && typeof fetchArgs[0] !== 'string') { return getHeadersFromOptions(fetchArgs[0] as Request | RequestInit, allowedHeaders); @@ -254,7 +247,7 @@ function getHeadersFromOptions( } if (headers instanceof Headers) { - return getAllHeaders(headers, allowedHeaders); + return getFetchResponseHeaders(headers, allowedHeaders); } // We do not support this, as it is not really documented (anymore?) diff --git a/packages/replay-internal/src/coreHandlers/util/xhrUtils.ts b/packages/replay-internal/src/coreHandlers/util/xhrUtils.ts index bb7c631eddef..be3c205d60d9 100644 --- a/packages/replay-internal/src/coreHandlers/util/xhrUtils.ts +++ b/packages/replay-internal/src/coreHandlers/util/xhrUtils.ts @@ -1,6 +1,6 @@ import type { Breadcrumb, XhrBreadcrumbData } from '@sentry/core'; import type { NetworkMetaWarning, XhrHint } from '@sentry-internal/browser-utils'; -import { getBodyString, SENTRY_XHR_DATA_KEY } from '@sentry-internal/browser-utils'; +import { getBodyString, parseXhrResponseHeaders, SENTRY_XHR_DATA_KEY } from '@sentry-internal/browser-utils'; import { DEBUG_BUILD } from '../../debug-build'; import type { ReplayContainer, ReplayNetworkOptions, ReplayNetworkRequestData } from '../../types'; import { debug } from '../../util/logger'; @@ -104,7 +104,7 @@ function _prepareXhrData( const networkRequestHeaders = xhrInfo ? getAllowedHeaders(xhrInfo.request_headers, options.networkRequestHeaders) : {}; - const networkResponseHeaders = getAllowedHeaders(getResponseHeaders(xhr), options.networkResponseHeaders); + const networkResponseHeaders = getAllowedHeaders(parseXhrResponseHeaders(xhr), options.networkResponseHeaders); const [requestBody, requestWarning] = options.networkCaptureBodies ? getBodyString(input, debug) : [undefined]; const [responseBody, responseWarning] = options.networkCaptureBodies ? _getXhrResponseBody(xhr) : [undefined]; @@ -123,22 +123,6 @@ function _prepareXhrData( }; } -function getResponseHeaders(xhr: XMLHttpRequest): Record { - const headers = xhr.getAllResponseHeaders(); - - if (!headers) { - return {}; - } - - return headers.split('\r\n').reduce((acc: Record, line: string) => { - const [key, value] = line.split(': ') as [string, string | undefined]; - if (value) { - acc[key.toLowerCase()] = value; - } - return acc; - }, {}); -} - function _getXhrResponseBody(xhr: XMLHttpRequest): [string | undefined, NetworkMetaWarning?] { // We collect errors that happen, but only log them if we can't get any response body const errors: unknown[] = [];