Skip to content

Commit b3ee89e

Browse files
authored
feat(tracing): Add experiment to capture http timings (#8371)
This adds an experiment that will allow http timings to be captured. We currently capture timings on Sentry SaaS with some custom code and append them to the spans, which has been helpful to identify some performance problems that were previously hidden (http/1.1 stall time). Following this work we can add these to the waterfall to represent measurements as subtimings and will power an upcoming http/1.1 stall performance issue.
1 parent 88eb034 commit b3ee89e

File tree

5 files changed

+143
-8
lines changed

5 files changed

+143
-8
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import * as Sentry from '@sentry/browser';
2+
import { Integrations } from '@sentry/tracing';
3+
4+
window.Sentry = Sentry;
5+
6+
Sentry.init({
7+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
8+
integrations: [
9+
new Integrations.BrowserTracing({
10+
idleTimeout: 1000,
11+
_experiments: {
12+
enableHTTPTimings: true,
13+
},
14+
}),
15+
],
16+
tracesSampleRate: 1,
17+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
fetch('http://example.com/0').then(fetch('http://example.com/1').then(fetch('http://example.com/2')));
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { expect } from '@playwright/test';
2+
import type { Event } from '@sentry/types';
3+
4+
import { sentryTest } from '../../../../utils/fixtures';
5+
import { getMultipleSentryEnvelopeRequests, shouldSkipTracingTest } from '../../../../utils/helpers';
6+
7+
sentryTest('should create fetch spans with http timing', async ({ browserName, getLocalTestPath, page }) => {
8+
const supportedBrowsers = ['chromium', 'firefox'];
9+
10+
if (shouldSkipTracingTest() || !supportedBrowsers.includes(browserName)) {
11+
sentryTest.skip();
12+
}
13+
await page.route('http://example.com/*', async route => {
14+
const request = route.request();
15+
const postData = await request.postDataJSON();
16+
17+
await route.fulfill({
18+
status: 200,
19+
contentType: 'application/json',
20+
body: JSON.stringify(Object.assign({ id: 1 }, postData)),
21+
});
22+
});
23+
24+
const url = await getLocalTestPath({ testDir: __dirname });
25+
26+
const envelopes = await getMultipleSentryEnvelopeRequests<Event>(page, 2, { url, timeout: 10000 });
27+
const tracingEvent = envelopes[envelopes.length - 1]; // last envelope contains tracing data on all browsers
28+
29+
const requestSpans = tracingEvent.spans?.filter(({ op }) => op === 'http.client');
30+
31+
expect(requestSpans).toHaveLength(3);
32+
33+
await page.pause();
34+
requestSpans?.forEach((span, index) =>
35+
expect(span).toMatchObject({
36+
description: `GET http://example.com/${index}`,
37+
parent_span_id: tracingEvent.contexts?.trace?.span_id,
38+
span_id: expect.any(String),
39+
start_timestamp: expect.any(Number),
40+
timestamp: expect.any(Number),
41+
trace_id: tracingEvent.contexts?.trace?.trace_id,
42+
data: expect.objectContaining({
43+
'http.request.connect_start': expect.any(Number),
44+
'http.request.request_start': expect.any(Number),
45+
'http.request.response_start': expect.any(Number),
46+
'network.protocol.version': expect.any(String),
47+
}),
48+
}),
49+
);
50+
});

Diff for: packages/tracing-internal/src/browser/browsertracing.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ export interface BrowserTracingOptions extends RequestInstrumentationOptions {
111111
_experiments: Partial<{
112112
enableLongTask: boolean;
113113
enableInteractions: boolean;
114+
enableHTTPTimings: boolean;
114115
onStartRouteTransaction: (t: Transaction | undefined, ctx: TransactionContext, getCurrentHub: () => Hub) => void;
115116
}>;
116117

@@ -145,7 +146,6 @@ const DEFAULT_BROWSER_TRACING_OPTIONS: BrowserTracingOptions = {
145146
startTransactionOnLocationChange: true,
146147
startTransactionOnPageLoad: true,
147148
enableLongTask: true,
148-
_experiments: {},
149149
...defaultRequestInstrumentationOptions,
150150
};
151151

@@ -283,6 +283,9 @@ export class BrowserTracing implements Integration {
283283
traceXHR,
284284
tracePropagationTargets,
285285
shouldCreateSpanForRequest,
286+
_experiments: {
287+
enableHTTPTimings: _experiments.enableHTTPTimings,
288+
},
286289
});
287290
}
288291

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

+71-7
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { DynamicSamplingContext, Span } from '@sentry/types';
44
import {
55
addInstrumentationHandler,
66
BAGGAGE_HEADER_NAME,
7+
browserPerformanceTimeOrigin,
78
dynamicSamplingContextToSentryBaggageHeader,
89
isInstanceOf,
910
SENTRY_XHR_DATA_KEY,
@@ -14,6 +15,13 @@ export const DEFAULT_TRACE_PROPAGATION_TARGETS = ['localhost', /^\/(?!\/)/];
1415

1516
/** Options for Request Instrumentation */
1617
export interface RequestInstrumentationOptions {
18+
/**
19+
* Allow experiments for the request instrumentation.
20+
*/
21+
_experiments: Partial<{
22+
enableHTTPTimings: boolean;
23+
}>;
24+
1725
/**
1826
* @deprecated Will be removed in v8.
1927
* Use `shouldCreateSpanForRequest` to control span creation and `tracePropagationTargets` to control
@@ -108,12 +116,13 @@ export const defaultRequestInstrumentationOptions: RequestInstrumentationOptions
108116
// TODO (v8): Remove this property
109117
tracingOrigins: DEFAULT_TRACE_PROPAGATION_TARGETS,
110118
tracePropagationTargets: DEFAULT_TRACE_PROPAGATION_TARGETS,
119+
_experiments: {},
111120
};
112121

113122
/** Registers span creators for xhr and fetch requests */
114123
export function instrumentOutgoingRequests(_options?: Partial<RequestInstrumentationOptions>): void {
115124
// eslint-disable-next-line deprecation/deprecation
116-
const { traceFetch, traceXHR, tracePropagationTargets, tracingOrigins, shouldCreateSpanForRequest } = {
125+
const { traceFetch, traceXHR, tracePropagationTargets, tracingOrigins, shouldCreateSpanForRequest, _experiments } = {
117126
traceFetch: defaultRequestInstrumentationOptions.traceFetch,
118127
traceXHR: defaultRequestInstrumentationOptions.traceXHR,
119128
..._options,
@@ -132,15 +141,63 @@ export function instrumentOutgoingRequests(_options?: Partial<RequestInstrumenta
132141

133142
if (traceFetch) {
134143
addInstrumentationHandler('fetch', (handlerData: FetchData) => {
135-
fetchCallback(handlerData, shouldCreateSpan, shouldAttachHeadersWithTargets, spans);
144+
const createdSpan = fetchCallback(handlerData, shouldCreateSpan, shouldAttachHeadersWithTargets, spans);
145+
if (_experiments?.enableHTTPTimings && createdSpan) {
146+
addHTTPTimings(createdSpan);
147+
}
136148
});
137149
}
138150

139151
if (traceXHR) {
140152
addInstrumentationHandler('xhr', (handlerData: XHRData) => {
141-
xhrCallback(handlerData, shouldCreateSpan, shouldAttachHeadersWithTargets, spans);
153+
const createdSpan = xhrCallback(handlerData, shouldCreateSpan, shouldAttachHeadersWithTargets, spans);
154+
if (_experiments?.enableHTTPTimings && createdSpan) {
155+
addHTTPTimings(createdSpan);
156+
}
157+
});
158+
}
159+
}
160+
161+
/**
162+
* Creates a temporary observer to listen to the next fetch/xhr resourcing timings,
163+
* so that when timings hit their per-browser limit they don't need to be removed.
164+
*
165+
* @param span A span that has yet to be finished, must contain `url` on data.
166+
*/
167+
function addHTTPTimings(span: Span): void {
168+
const url = span.data.url;
169+
const observer = new PerformanceObserver(list => {
170+
const entries = list.getEntries() as PerformanceResourceTiming[];
171+
entries.forEach(entry => {
172+
if ((entry.initiatorType === 'fetch' || entry.initiatorType === 'xmlhttprequest') && entry.name.endsWith(url)) {
173+
const spanData = resourceTimingEntryToSpanData(entry);
174+
spanData.forEach(data => span.setData(...data));
175+
observer.disconnect();
176+
}
142177
});
178+
});
179+
observer.observe({
180+
entryTypes: ['resource'],
181+
});
182+
}
183+
184+
function resourceTimingEntryToSpanData(resourceTiming: PerformanceResourceTiming): [string, string | number][] {
185+
const version = resourceTiming.nextHopProtocol.split('/')[1] || 'none';
186+
187+
const timingSpanData: [string, string | number][] = [];
188+
if (version) {
189+
timingSpanData.push(['network.protocol.version', version]);
190+
}
191+
192+
if (!browserPerformanceTimeOrigin) {
193+
return timingSpanData;
143194
}
195+
return [
196+
...timingSpanData,
197+
['http.request.connect_start', (browserPerformanceTimeOrigin + resourceTiming.connectStart) / 1000],
198+
['http.request.request_start', (browserPerformanceTimeOrigin + resourceTiming.requestStart) / 1000],
199+
['http.request.response_start', (browserPerformanceTimeOrigin + resourceTiming.responseStart) / 1000],
200+
];
144201
}
145202

146203
/**
@@ -154,13 +211,15 @@ export function shouldAttachHeaders(url: string, tracePropagationTargets: (strin
154211

155212
/**
156213
* Create and track fetch request spans
214+
*
215+
* @returns Span if a span was created, otherwise void.
157216
*/
158-
export function fetchCallback(
217+
function fetchCallback(
159218
handlerData: FetchData,
160219
shouldCreateSpan: (url: string) => boolean,
161220
shouldAttachHeaders: (url: string) => boolean,
162221
spans: Record<string, Span>,
163-
): void {
222+
): Span | void {
164223
if (!hasTracingEnabled() || !(handlerData.fetchData && shouldCreateSpan(handlerData.fetchData.url))) {
165224
return;
166225
}
@@ -229,6 +288,7 @@ export function fetchCallback(
229288
options,
230289
);
231290
}
291+
return span;
232292
}
233293
}
234294

@@ -301,13 +361,15 @@ export function addTracingHeadersToFetchRequest(
301361

302362
/**
303363
* Create and track xhr request spans
364+
*
365+
* @returns Span if a span was created, otherwise void.
304366
*/
305-
export function xhrCallback(
367+
function xhrCallback(
306368
handlerData: XHRData,
307369
shouldCreateSpan: (url: string) => boolean,
308370
shouldAttachHeaders: (url: string) => boolean,
309371
spans: Record<string, Span>,
310-
): void {
372+
): Span | void {
311373
const xhr = handlerData.xhr;
312374
const sentryXhrData = xhr && xhr[SENTRY_XHR_DATA_KEY];
313375

@@ -370,5 +432,7 @@ export function xhrCallback(
370432
// Error: InvalidStateError: Failed to execute 'setRequestHeader' on 'XMLHttpRequest': The object's state must be OPENED.
371433
}
372434
}
435+
436+
return span;
373437
}
374438
}

0 commit comments

Comments
 (0)