Skip to content

Commit a680e0c

Browse files
authored
feat(browser): Add onRequestSpanEnd hook to browser tracing integration (#17884)
This PR aims to address #9643 partially by introducing a `onRequestSpanEnd` hook to the browser integration. These changes make it easier for users to enrich tracing spans with response header data. #### Example ```ts import * as Sentry from '@sentry/browser'; Sentry.init({ // ... integrations: [ Sentry.browserTracingIntegration({ onRequestSpanEnd(span, responseInformation) { span.setAttributes({ response_type: 'JSON', }); }, }), ], }); ``` #### Tracing Integration and API Improvements * Added `onRequestSpanEnd` callback to `BrowserTracingOptions` and `RequestInstrumentationOptions`, allowing users to access response headers when a request span ends. This enables custom span annotation based on response data. * Updated internal request instrumentation logic to call `onRequestSpanEnd` for both Fetch and XHR requests, passing parsed response headers to the callback. #### Utility and Refactoring * Centralized header parsing and filtering utilities (`parseXhrResponseHeaders`, `getFetchResponseHeaders`, `filterAllowedHeaders`) in `networkUtils.ts`, and exported them for reuse across packages. * Moved helper functions for baggage header checking, URL resolution, performance timing checks, and safe header creation to a new `utils.ts` file to avoid failing the file size limit lint rule. I was hesitant to hoist up those replay utils initially but a few of them were needed to expose them on the hook callback. #### Type and API Consistency * Introduced new types `RequestHookInfo` and `ResponseHookInfo` to standardize the information passed to request span hooks, and exported them from the core package for use in integrations. I also added the necessary tests to test out the new hook.
1 parent 6230aed commit a680e0c

File tree

13 files changed

+259
-57
lines changed

13 files changed

+259
-57
lines changed

.size-limit.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@ module.exports = [
206206
import: createImport('init'),
207207
ignore: ['next/router', 'next/constants'],
208208
gzip: true,
209-
limit: '45 KB',
209+
limit: '46 KB',
210210
},
211211
// SvelteKit SDK (ESM)
212212
{
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import * as Sentry from '@sentry/browser';
2+
3+
window.Sentry = Sentry;
4+
5+
Sentry.init({
6+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
7+
integrations: [
8+
Sentry.browserTracingIntegration({
9+
idleTimeout: 1000,
10+
onRequestSpanEnd(span, { headers }) {
11+
if (headers) {
12+
span.setAttribute('hook.called.response-type', headers.get('x-response-type'));
13+
}
14+
},
15+
}),
16+
],
17+
tracesSampleRate: 1,
18+
});
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
fetch('http://sentry-test.io/fetch', {
2+
headers: {
3+
foo: 'fetch',
4+
},
5+
});
6+
7+
const xhr = new XMLHttpRequest();
8+
9+
xhr.open('GET', 'http://sentry-test.io/xhr');
10+
xhr.setRequestHeader('foo', 'xhr');
11+
xhr.send();
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { expect } from '@playwright/test';
2+
import type { Event } from '@sentry/core';
3+
import { sentryTest } from '../../../../utils/fixtures';
4+
import { getMultipleSentryEnvelopeRequests, shouldSkipTracingTest } from '../../../../utils/helpers';
5+
6+
sentryTest('should call onRequestSpanEnd hook', async ({ browserName, getLocalTestUrl, page }) => {
7+
const supportedBrowsers = ['chromium', 'firefox'];
8+
9+
if (shouldSkipTracingTest() || !supportedBrowsers.includes(browserName)) {
10+
sentryTest.skip();
11+
}
12+
13+
await page.route('http://sentry-test.io/fetch', async route => {
14+
await route.fulfill({
15+
status: 200,
16+
headers: {
17+
'Content-Type': 'application/json',
18+
'X-Response-Type': 'fetch',
19+
'access-control-expose-headers': '*',
20+
},
21+
body: '',
22+
});
23+
});
24+
await page.route('http://sentry-test.io/xhr', async route => {
25+
await route.fulfill({
26+
status: 200,
27+
headers: {
28+
'Content-Type': 'application/json',
29+
'X-Response-Type': 'xhr',
30+
'access-control-expose-headers': '*',
31+
},
32+
body: '',
33+
});
34+
});
35+
36+
const url = await getLocalTestUrl({ testDir: __dirname });
37+
38+
const envelopes = await getMultipleSentryEnvelopeRequests<Event>(page, 2, { url, timeout: 10000 });
39+
40+
const tracingEvent = envelopes[envelopes.length - 1]; // last envelope contains tracing data on all browsers
41+
42+
expect(tracingEvent.spans).toContainEqual(
43+
expect.objectContaining({
44+
op: 'http.client',
45+
data: expect.objectContaining({
46+
type: 'xhr',
47+
'hook.called.response-type': 'xhr',
48+
}),
49+
}),
50+
);
51+
52+
expect(tracingEvent.spans).toContainEqual(
53+
expect.objectContaining({
54+
op: 'http.client',
55+
data: expect.objectContaining({
56+
type: 'fetch',
57+
'hook.called.response-type': 'fetch',
58+
}),
59+
}),
60+
);
61+
});

packages/browser-utils/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export { fetch, setTimeout, clearCachedImplementation, getNativeImplementation }
2828

2929
export { addXhrInstrumentationHandler, SENTRY_XHR_DATA_KEY } from './instrument/xhr';
3030

31-
export { getBodyString, getFetchRequestArgBody, serializeFormData } from './networkUtils';
31+
export { getBodyString, getFetchRequestArgBody, serializeFormData, parseXhrResponseHeaders } from './networkUtils';
3232

3333
export { resourceTimingToSpanAttributes } from './metrics/resourceTiming';
3434

packages/browser-utils/src/networkUtils.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,29 @@ export function getFetchRequestArgBody(fetchArgs: unknown[] = []): RequestInit['
5454

5555
return (fetchArgs[1] as RequestInit).body;
5656
}
57+
58+
/**
59+
* Parses XMLHttpRequest response headers into a Record.
60+
* Extracted from replay internals to be reusable.
61+
*/
62+
export function parseXhrResponseHeaders(xhr: XMLHttpRequest): Record<string, string> {
63+
let headers: string | undefined;
64+
try {
65+
headers = xhr.getAllResponseHeaders();
66+
} catch (error) {
67+
DEBUG_BUILD && debug.error(error, 'Failed to get xhr response headers', xhr);
68+
return {};
69+
}
70+
71+
if (!headers) {
72+
return {};
73+
}
74+
75+
return headers.split('\r\n').reduce((acc: Record<string, string>, line: string) => {
76+
const [key, value] = line.split(': ') as [string, string | undefined];
77+
if (value) {
78+
acc[key.toLowerCase()] = value;
79+
}
80+
return acc;
81+
}, {});
82+
}

packages/browser/src/tracing/browserTracingIntegration.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
/* eslint-disable max-lines */
2-
import type { Client, IntegrationFn, Span, StartSpanOptions, TransactionSource, WebFetchHeaders } from '@sentry/core';
2+
import type {
3+
Client,
4+
IntegrationFn,
5+
RequestHookInfo,
6+
ResponseHookInfo,
7+
Span,
8+
StartSpanOptions,
9+
TransactionSource,
10+
} from '@sentry/core';
311
import {
412
addNonEnumerableProperty,
513
browserPerformanceTimeOrigin,
@@ -297,7 +305,12 @@ export interface BrowserTracingOptions {
297305
* You can use it to annotate the span with additional data or attributes, for example by setting
298306
* attributes based on the passed request headers.
299307
*/
300-
onRequestSpanStart?(span: Span, requestInformation: { headers?: WebFetchHeaders }): void;
308+
onRequestSpanStart?(span: Span, requestInformation: RequestHookInfo): void;
309+
310+
/**
311+
* Is called when spans end for outgoing requests, providing access to response headers.
312+
*/
313+
onRequestSpanEnd?(span: Span, responseInformation: ResponseHookInfo): void;
301314
}
302315

303316
const DEFAULT_BROWSER_TRACING_OPTIONS: BrowserTracingOptions = {
@@ -365,6 +378,7 @@ export const browserTracingIntegration = ((options: Partial<BrowserTracingOption
365378
consistentTraceSampling,
366379
enableReportPageLoaded,
367380
onRequestSpanStart,
381+
onRequestSpanEnd,
368382
} = {
369383
...DEFAULT_BROWSER_TRACING_OPTIONS,
370384
...options,
@@ -692,6 +706,7 @@ export const browserTracingIntegration = ((options: Partial<BrowserTracingOption
692706
shouldCreateSpanForRequest,
693707
enableHTTPTimings,
694708
onRequestSpanStart,
709+
onRequestSpanEnd,
695710
});
696711
},
697712
};

packages/browser/src/tracing/request.ts

Lines changed: 28 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
import type { Client, HandlerDataXhr, SentryWrappedXMLHttpRequest, Span, WebFetchHeaders } from '@sentry/core';
1+
import type {
2+
Client,
3+
HandlerDataXhr,
4+
RequestHookInfo,
5+
ResponseHookInfo,
6+
SentryWrappedXMLHttpRequest,
7+
Span,
8+
} from '@sentry/core';
29
import {
310
addFetchEndInstrumentationHandler,
411
addFetchInstrumentationHandler,
@@ -22,11 +29,12 @@ import type { XhrHint } from '@sentry-internal/browser-utils';
2229
import {
2330
addPerformanceInstrumentationHandler,
2431
addXhrInstrumentationHandler,
32+
parseXhrResponseHeaders,
2533
resourceTimingToSpanAttributes,
2634
SENTRY_XHR_DATA_KEY,
2735
} from '@sentry-internal/browser-utils';
2836
import type { BrowserClient } from '../client';
29-
import { WINDOW } from '../helpers';
37+
import { baggageHeaderHasSentryValues, createHeadersSafely, getFullURL, isPerformanceResourceTiming } from './utils';
3038

3139
/** Options for Request Instrumentation */
3240
export interface RequestInstrumentationOptions {
@@ -102,7 +110,12 @@ export interface RequestInstrumentationOptions {
102110
/**
103111
* Is called when spans are started for outgoing requests.
104112
*/
105-
onRequestSpanStart?(span: Span, requestInformation: { headers?: WebFetchHeaders }): void;
113+
onRequestSpanStart?(span: Span, requestInformation: RequestHookInfo): void;
114+
115+
/**
116+
* Is called when spans end for outgoing requests, providing access to response headers.
117+
*/
118+
onRequestSpanEnd?(span: Span, responseInformation: ResponseHookInfo): void;
106119
}
107120

108121
const responseToSpanId = new WeakMap<object, string>();
@@ -125,6 +138,7 @@ export function instrumentOutgoingRequests(client: Client, _options?: Partial<Re
125138
enableHTTPTimings,
126139
tracePropagationTargets,
127140
onRequestSpanStart,
141+
onRequestSpanEnd,
128142
} = {
129143
...defaultRequestInstrumentationOptions,
130144
..._options,
@@ -171,6 +185,7 @@ export function instrumentOutgoingRequests(client: Client, _options?: Partial<Re
171185
addFetchInstrumentationHandler(handlerData => {
172186
const createdSpan = instrumentFetchRequest(handlerData, shouldCreateSpan, shouldAttachHeadersWithTargets, spans, {
173187
propagateTraceparent,
188+
onRequestSpanEnd,
174189
});
175190

176191
if (handlerData.response && handlerData.fetchData.__span) {
@@ -205,34 +220,22 @@ export function instrumentOutgoingRequests(client: Client, _options?: Partial<Re
205220
shouldAttachHeadersWithTargets,
206221
spans,
207222
propagateTraceparent,
223+
onRequestSpanEnd,
208224
);
209225

210226
if (createdSpan) {
211227
if (enableHTTPTimings) {
212228
addHTTPTimings(createdSpan);
213229
}
214230

215-
let headers;
216-
try {
217-
headers = new Headers(handlerData.xhr.__sentry_xhr_v3__?.request_headers);
218-
} catch {
219-
// noop
220-
}
221-
onRequestSpanStart?.(createdSpan, { headers });
231+
onRequestSpanStart?.(createdSpan, {
232+
headers: createHeadersSafely(handlerData.xhr.__sentry_xhr_v3__?.request_headers),
233+
});
222234
}
223235
});
224236
}
225237
}
226238

227-
function isPerformanceResourceTiming(entry: PerformanceEntry): entry is PerformanceResourceTiming {
228-
return (
229-
entry.entryType === 'resource' &&
230-
'initiatorType' in entry &&
231-
typeof (entry as PerformanceResourceTiming).nextHopProtocol === 'string' &&
232-
(entry.initiatorType === 'fetch' || entry.initiatorType === 'xmlhttprequest')
233-
);
234-
}
235-
236239
/**
237240
* Creates a temporary observer to listen to the next fetch/xhr resourcing timings,
238241
* so that when timings hit their per-browser limit they don't need to be removed.
@@ -315,6 +318,7 @@ function xhrCallback(
315318
shouldAttachHeaders: (url: string) => boolean,
316319
spans: Record<string, Span>,
317320
propagateTraceparent?: boolean,
321+
onRequestSpanEnd?: RequestInstrumentationOptions['onRequestSpanEnd'],
318322
): Span | undefined {
319323
const xhr = handlerData.xhr;
320324
const sentryXhrData = xhr?.[SENTRY_XHR_DATA_KEY];
@@ -337,6 +341,11 @@ function xhrCallback(
337341
setHttpStatus(span, sentryXhrData.status_code);
338342
span.end();
339343

344+
onRequestSpanEnd?.(span, {
345+
headers: createHeadersSafely(parseXhrResponseHeaders(xhr as XMLHttpRequest & SentryWrappedXMLHttpRequest)),
346+
error: handlerData.error,
347+
});
348+
340349
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
341350
delete spans[spanId];
342351
}
@@ -438,18 +447,3 @@ function setHeaderOnXhr(
438447
// Error: InvalidStateError: Failed to execute 'setRequestHeader' on 'XMLHttpRequest': The object's state must be OPENED.
439448
}
440449
}
441-
442-
function baggageHeaderHasSentryValues(baggageHeader: string): boolean {
443-
return baggageHeader.split(',').some(value => value.trim().startsWith('sentry-'));
444-
}
445-
446-
function getFullURL(url: string): string | undefined {
447-
try {
448-
// By adding a base URL to new URL(), this will also work for relative urls
449-
// If `url` is a full URL, the base URL is ignored anyhow
450-
const parsed = new URL(url, WINDOW.location.origin);
451-
return parsed.href;
452-
} catch {
453-
return undefined;
454-
}
455-
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { WINDOW } from '../helpers';
2+
3+
/**
4+
* Checks if the baggage header has Sentry values.
5+
*/
6+
export function baggageHeaderHasSentryValues(baggageHeader: string): boolean {
7+
return baggageHeader.split(',').some(value => value.trim().startsWith('sentry-'));
8+
}
9+
10+
/**
11+
* Gets the full URL from a given URL string.
12+
*/
13+
export function getFullURL(url: string): string | undefined {
14+
try {
15+
// By adding a base URL to new URL(), this will also work for relative urls
16+
// If `url` is a full URL, the base URL is ignored anyhow
17+
const parsed = new URL(url, WINDOW.location.origin);
18+
return parsed.href;
19+
} catch {
20+
return undefined;
21+
}
22+
}
23+
24+
/**
25+
* Checks if the entry is a PerformanceResourceTiming.
26+
*/
27+
export function isPerformanceResourceTiming(entry: PerformanceEntry): entry is PerformanceResourceTiming {
28+
return (
29+
entry.entryType === 'resource' &&
30+
'initiatorType' in entry &&
31+
typeof (entry as PerformanceResourceTiming).nextHopProtocol === 'string' &&
32+
(entry.initiatorType === 'fetch' || entry.initiatorType === 'xmlhttprequest')
33+
);
34+
}
35+
36+
/**
37+
* Creates a Headers object from a record of string key-value pairs, and returns undefined if it fails.
38+
*/
39+
export function createHeadersSafely(headers: Record<string, string> | undefined): Headers | undefined {
40+
try {
41+
return new Headers(headers);
42+
} catch {
43+
// noop
44+
return undefined;
45+
}
46+
}

0 commit comments

Comments
 (0)