Skip to content

Commit 51f04a1

Browse files
lforstLms24
andauthored
feat(browser): Add onRequestSpanStart hook to browser tracing integration (#15979)
This PR adds a hook to the browser tracing integration that allows to hook into the creation of request spans (fetch & xhr) and set attributes based on headers (can be extended with additional info in the future). Primarily I was gonna use this to set an attribute/op on prefetch client requests in Next.js to show them as `http.client.prefetch`. --------- Co-authored-by: Lukas Stracke <[email protected]>
1 parent 26ff159 commit 51f04a1

File tree

10 files changed

+155
-18
lines changed

10 files changed

+155
-18
lines changed

Diff for: .size-limit.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ module.exports = [
139139
path: 'packages/vue/build/esm/index.js',
140140
import: createImport('init', 'browserTracingIntegration'),
141141
gzip: true,
142-
limit: '39.5 KB',
142+
limit: '40 KB',
143143
},
144144
// Svelte SDK (ESM)
145145
{
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://[email protected]/1337',
7+
integrations: [
8+
Sentry.browserTracingIntegration({
9+
idleTimeout: 1000,
10+
onRequestSpanStart(span, { headers }) {
11+
if (headers) {
12+
span.setAttribute('hook.called.headers', headers.get('foo'));
13+
}
14+
},
15+
}),
16+
],
17+
tracesSampleRate: 1,
18+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
fetch('http://sentry-test-site-fetch.example/', {
2+
headers: {
3+
foo: 'fetch',
4+
},
5+
});
6+
7+
const xhr = new XMLHttpRequest();
8+
9+
xhr.open('GET', 'http://sentry-test-site-xhr.example/');
10+
xhr.setRequestHeader('foo', 'xhr');
11+
xhr.send();
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { expect } from '@playwright/test';
2+
import type { Event } from '@sentry/core';
3+
4+
import { sentryTest } from '../../../../utils/fixtures';
5+
import { getMultipleSentryEnvelopeRequests, shouldSkipTracingTest } from '../../../../utils/helpers';
6+
7+
sentryTest('should call onRequestSpanStart hook', async ({ browserName, getLocalTestUrl, page }) => {
8+
const supportedBrowsers = ['chromium', 'firefox'];
9+
10+
if (shouldSkipTracingTest() || !supportedBrowsers.includes(browserName)) {
11+
sentryTest.skip();
12+
}
13+
14+
await page.route('http://sentry-test-site-fetch.example/', async route => {
15+
await route.fulfill({
16+
status: 200,
17+
contentType: 'application/json',
18+
body: '',
19+
});
20+
});
21+
await page.route('http://sentry-test-site-xhr.example/', async route => {
22+
await route.fulfill({
23+
status: 200,
24+
contentType: 'application/json',
25+
body: '',
26+
});
27+
});
28+
29+
const url = await getLocalTestUrl({ testDir: __dirname });
30+
31+
const envelopes = await getMultipleSentryEnvelopeRequests<Event>(page, 2, { url, timeout: 10000 });
32+
33+
const tracingEvent = envelopes[envelopes.length - 1]; // last envelope contains tracing data on all browsers
34+
35+
expect(tracingEvent.spans).toContainEqual(
36+
expect.objectContaining({
37+
op: 'http.client',
38+
data: expect.objectContaining({
39+
'hook.called.headers': 'xhr',
40+
}),
41+
}),
42+
);
43+
44+
expect(tracingEvent.spans).toContainEqual(
45+
expect.objectContaining({
46+
op: 'http.client',
47+
data: expect.objectContaining({
48+
'hook.called.headers': 'fetch',
49+
}),
50+
}),
51+
);
52+
});

Diff for: packages/browser/src/tracing/browserTracingIntegration.ts

+10-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
startTrackingLongTasks,
1010
startTrackingWebVitals,
1111
} from '@sentry-internal/browser-utils';
12-
import type { Client, IntegrationFn, Span, StartSpanOptions, TransactionSource } from '@sentry/core';
12+
import type { Client, IntegrationFn, Span, StartSpanOptions, TransactionSource, WebFetchHeaders } from '@sentry/core';
1313
import {
1414
GLOBAL_OBJ,
1515
SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON,
@@ -195,6 +195,13 @@ export interface BrowserTracingOptions {
195195
* Default: (url: string) => true
196196
*/
197197
shouldCreateSpanForRequest?(this: void, url: string): boolean;
198+
199+
/**
200+
* This callback is invoked directly after a span is started for an outgoing fetch or XHR request.
201+
* You can use it to annotate the span with additional data or attributes, for example by setting
202+
* attributes based on the passed request headers.
203+
*/
204+
onRequestSpanStart?(span: Span, requestInformation: { headers?: WebFetchHeaders }): void;
198205
}
199206

200207
const DEFAULT_BROWSER_TRACING_OPTIONS: BrowserTracingOptions = {
@@ -246,6 +253,7 @@ export const browserTracingIntegration = ((_options: Partial<BrowserTracingOptio
246253
instrumentPageLoad,
247254
instrumentNavigation,
248255
linkPreviousTrace,
256+
onRequestSpanStart,
249257
} = {
250258
...DEFAULT_BROWSER_TRACING_OPTIONS,
251259
..._options,
@@ -468,6 +476,7 @@ export const browserTracingIntegration = ((_options: Partial<BrowserTracingOptio
468476
tracePropagationTargets: client.getOptions().tracePropagationTargets,
469477
shouldCreateSpanForRequest,
470478
enableHTTPTimings,
479+
onRequestSpanStart,
471480
});
472481
},
473482
};

Diff for: packages/browser/src/tracing/request.ts

+25-9
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
extractNetworkProtocol,
66
} from '@sentry-internal/browser-utils';
77
import type { XhrHint } from '@sentry-internal/browser-utils';
8-
import type { Client, HandlerDataXhr, SentryWrappedXMLHttpRequest, Span } from '@sentry/core';
8+
import type { Client, HandlerDataXhr, SentryWrappedXMLHttpRequest, Span, WebFetchHeaders } from '@sentry/core';
99
import {
1010
SEMANTIC_ATTRIBUTE_SENTRY_OP,
1111
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
@@ -98,6 +98,11 @@ export interface RequestInstrumentationOptions {
9898
* Default: (url: string) => true
9999
*/
100100
shouldCreateSpanForRequest?(this: void, url: string): boolean;
101+
102+
/**
103+
* Is called when spans are started for outgoing requests.
104+
*/
105+
onRequestSpanStart?(span: Span, requestInformation: { headers?: WebFetchHeaders }): void;
101106
}
102107

103108
const responseToSpanId = new WeakMap<object, string>();
@@ -119,10 +124,9 @@ export function instrumentOutgoingRequests(client: Client, _options?: Partial<Re
119124
shouldCreateSpanForRequest,
120125
enableHTTPTimings,
121126
tracePropagationTargets,
127+
onRequestSpanStart,
122128
} = {
123-
traceFetch: defaultRequestInstrumentationOptions.traceFetch,
124-
traceXHR: defaultRequestInstrumentationOptions.traceXHR,
125-
trackFetchStreamPerformance: defaultRequestInstrumentationOptions.trackFetchStreamPerformance,
129+
...defaultRequestInstrumentationOptions,
126130
..._options,
127131
};
128132

@@ -179,19 +183,31 @@ export function instrumentOutgoingRequests(client: Client, _options?: Partial<Re
179183
'http.url': fullUrl,
180184
'server.address': host,
181185
});
182-
}
183186

184-
if (enableHTTPTimings && createdSpan) {
185-
addHTTPTimings(createdSpan);
187+
if (enableHTTPTimings) {
188+
addHTTPTimings(createdSpan);
189+
}
190+
191+
onRequestSpanStart?.(createdSpan, { headers: handlerData.headers });
186192
}
187193
});
188194
}
189195

190196
if (traceXHR) {
191197
addXhrInstrumentationHandler(handlerData => {
192198
const createdSpan = xhrCallback(handlerData, shouldCreateSpan, shouldAttachHeadersWithTargets, spans);
193-
if (enableHTTPTimings && createdSpan) {
194-
addHTTPTimings(createdSpan);
199+
if (createdSpan) {
200+
if (enableHTTPTimings) {
201+
addHTTPTimings(createdSpan);
202+
}
203+
204+
let headers;
205+
try {
206+
headers = new Headers(handlerData.xhr.__sentry_xhr_v3__?.request_headers);
207+
} catch {
208+
// noop
209+
}
210+
onRequestSpanStart?.(createdSpan, { headers });
195211
}
196212
});
197213
}

Diff for: packages/core/src/fetch.ts

+1-5
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { SPAN_STATUS_ERROR, setHttpStatus, startInactiveSpan } from './tracing';
44
import { SentryNonRecordingSpan } from './tracing/sentryNonRecordingSpan';
55
import type { FetchBreadcrumbHint, HandlerDataFetch, Span, SpanAttributes, SpanOrigin } from './types-hoist';
66
import { SENTRY_BAGGAGE_KEY_PREFIX } from './utils-hoist/baggage';
7-
import { isInstanceOf } from './utils-hoist/is';
7+
import { isInstanceOf, isRequest } from './utils-hoist/is';
88
import { getSanitizedUrlStringFromUrlObject, isURLObjectRelative, parseStringToURLObject } from './utils-hoist/url';
99
import { hasSpansEnabled } from './utils/hasSpansEnabled';
1010
import { getActiveSpan } from './utils/spanUtils';
@@ -227,10 +227,6 @@ function stripBaggageHeaderOfSentryBaggageValues(baggageHeader: string): string
227227
);
228228
}
229229

230-
function isRequest(request: unknown): request is Request {
231-
return typeof Request !== 'undefined' && isInstanceOf(request, Request);
232-
}
233-
234230
function isHeaders(headers: unknown): headers is Headers {
235231
return typeof Headers !== 'undefined' && isInstanceOf(headers, Headers);
236232
}

Diff for: packages/core/src/types-hoist/instrument.ts

+2
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ export interface HandlerDataFetch {
6161
error?: unknown;
6262
// This is to be consumed by the HttpClient integration
6363
virtualError?: unknown;
64+
/** Headers that the user passed to the fetch request. */
65+
headers?: WebFetchHeaders;
6466
}
6567

6668
export interface HandlerDataDom {

Diff for: packages/core/src/utils-hoist/instrument/fetch.ts

+26-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/* eslint-disable @typescript-eslint/no-explicit-any */
2-
import type { HandlerDataFetch } from '../../types-hoist';
2+
import type { HandlerDataFetch, WebFetchHeaders } from '../../types-hoist';
33

4-
import { isError } from '../is';
4+
import { isError, isRequest } from '../is';
55
import { addNonEnumerableProperty, fill } from '../object';
66
import { supportsNativeFetch } from '../supports';
77
import { timestampInSeconds } from '../time';
@@ -67,6 +67,7 @@ function instrumentFetch(onFetchResolved?: (response: Response) => void, skipNat
6767
startTimestamp: timestampInSeconds() * 1000,
6868
// // Adding the error to be able to fingerprint the failed fetch event in HttpClient instrumentation
6969
virtualError,
70+
headers: getHeadersFromFetchArgs(args),
7071
};
7172

7273
// if there is no callback, fetch is instrumented directly
@@ -253,3 +254,26 @@ export function parseFetchArgs(fetchArgs: unknown[]): { method: string; url: str
253254
method: hasProp(arg, 'method') ? String(arg.method).toUpperCase() : 'GET',
254255
};
255256
}
257+
258+
function getHeadersFromFetchArgs(fetchArgs: unknown[]): WebFetchHeaders | undefined {
259+
const [requestArgument, optionsArgument] = fetchArgs;
260+
261+
try {
262+
if (
263+
typeof optionsArgument === 'object' &&
264+
optionsArgument !== null &&
265+
'headers' in optionsArgument &&
266+
optionsArgument.headers
267+
) {
268+
return new Headers(optionsArgument.headers as any);
269+
}
270+
271+
if (isRequest(requestArgument)) {
272+
return new Headers(requestArgument.headers);
273+
}
274+
} catch {
275+
// noop
276+
}
277+
278+
return;
279+
}

Diff for: packages/core/src/utils-hoist/is.ts

+9
Original file line numberDiff line numberDiff line change
@@ -201,3 +201,12 @@ export function isVueViewModel(wat: unknown): boolean {
201201
// Not using Object.prototype.toString because in Vue 3 it would read the instance's Symbol(Symbol.toStringTag) property.
202202
return !!(typeof wat === 'object' && wat !== null && ((wat as VueViewModel).__isVue || (wat as VueViewModel)._isVue));
203203
}
204+
205+
/**
206+
* Checks whether the given parameter is a Standard Web API Request instance.
207+
*
208+
* Returns false if Request is not available in the current runtime.
209+
*/
210+
export function isRequest(request: unknown): request is Request {
211+
return typeof Request !== 'undefined' && isInstanceOf(request, Request);
212+
}

0 commit comments

Comments
 (0)