Skip to content

Commit 170d2bc

Browse files
committed
fix(browser): Apply Http timing attributes to streamed http.client spans (#19643)
This PR fixes a temporary bug in span streaming where we didn't add Http timing attributes (see #19613). We can fix this by following OTels approach: - delay the ending of `http.client` spans until either 300ms pass by or we receive the PerformanceResourceTiming entry with the respective timing information. Of course we end the span with the original timestamp then. - Unfortunately, we can only do this for streamed span because transaction-based spans cannot stay open longer than their parent (e.g. a pageload or navigation). Otherwise they'd get dropped. So we have to differentiate between the two modes here (RIP bundle size 😢) - To ensure we don't flush unnecessarily often, we also now delay flushing the span buffer for 500ms after a segment span ends. This slightly changed test semantics in a few integration tests because manually consecutively segments are now also sent in one envelope. This is completely fine (actually preferred) because we flush less often (i.e. fewer requests). closes #19613
1 parent 9e2f8eb commit 170d2bc

File tree

7 files changed

+70
-36
lines changed

7 files changed

+70
-36
lines changed

.size-limit.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,7 @@ module.exports = [
241241
path: createCDNPath('bundle.tracing.min.js'),
242242
gzip: false,
243243
brotli: false,
244-
limit: '129 KB',
244+
limit: '130 KB',
245245
},
246246
{
247247
name: 'CDN Bundle (incl. Logs, Metrics) - uncompressed',

dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/http-timings-streamed/test.ts

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,8 @@ import { sentryTest } from '../../../../utils/fixtures';
33
import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers';
44
import { getSpanOp, waitForStreamedSpans } from '../../../../utils/spanUtils';
55

6-
/**
7-
* This test details a limitation of span streaming in comparison to transaction-based tracing:
8-
* We can no longer attach http PerformanceResourceTiming attributes to http.client spans in
9-
* span streaming mode. The reason is that we track `http.client` spans in real time but only
10-
* get the detailed timing information after the span already ended.
11-
* We can probably fix this (somehat at least) but will do so in a follow-up PR.
12-
* @see https://github.com/getsentry/sentry-javascript/issues/19613
13-
*/
146
sentryTest(
15-
"[limitation] doesn't add http timing to http.client spans in span streaming mode",
7+
'adds http timing to http.client spans in span streaming mode',
168
async ({ browserName, getLocalTestUrl, page }) => {
179
const supportedBrowsers = ['chromium', 'firefox'];
1810

@@ -49,7 +41,7 @@ sentryTest(
4941
end_timestamp: expect.any(Number),
5042
trace_id: pageloadSpan?.trace_id,
5143
status: 'ok',
52-
attributes: expect.not.objectContaining({
44+
attributes: expect.objectContaining({
5345
'http.request.redirect_start': expect.any(Object),
5446
'http.request.redirect_end': expect.any(Object),
5547
'http.request.worker_start': expect.any(Object),

dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested-parentAlwaysRoot/test.ts

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,27 +11,22 @@ sentryTest(
1111
const checkoutSpansPromise = waitForStreamedSpans(page, spans =>
1212
spans.some(s => s.name === 'checkout-flow' && s.is_segment),
1313
);
14-
const postCheckoutSpansPromise = waitForStreamedSpans(page, spans =>
15-
spans.some(s => s.name === 'post-checkout' && s.is_segment),
16-
);
1714

1815
const url = await getLocalTestUrl({ testDir: __dirname });
1916
await page.goto(url);
2017

2118
const checkoutSpans = await checkoutSpansPromise;
22-
const postCheckoutSpans = await postCheckoutSpansPromise;
2319

2420
const checkoutSpan = checkoutSpans.find(s => s.name === 'checkout-flow');
25-
const postCheckoutSpan = postCheckoutSpans.find(s => s.name === 'post-checkout');
21+
const postCheckoutSpan = checkoutSpans.find(s => s.name === 'post-checkout');
2622

2723
const checkoutSpanId = checkoutSpan?.span_id;
2824
const postCheckoutSpanId = postCheckoutSpan?.span_id;
2925

3026
expect(checkoutSpanId).toMatch(/[a-f\d]{16}/);
3127
expect(postCheckoutSpanId).toMatch(/[a-f\d]{16}/);
3228

33-
expect(checkoutSpans.filter(s => !s.is_segment)).toHaveLength(4);
34-
expect(postCheckoutSpans.filter(s => !s.is_segment)).toHaveLength(1);
29+
expect(checkoutSpans.filter(s => !s.is_segment)).toHaveLength(5);
3530

3631
const checkoutStep1 = checkoutSpans.find(s => s.name === 'checkout-step-1');
3732
const checkoutStep2 = checkoutSpans.find(s => s.name === 'checkout-step-2');
@@ -56,7 +51,7 @@ sentryTest(
5651

5752
// post-checkout trace is started as a new trace because ending checkoutSpan removes the active
5853
// span on the scope
59-
const postCheckoutStep1 = postCheckoutSpans.find(s => s.name === 'post-checkout-1');
54+
const postCheckoutStep1 = checkoutSpans.find(s => s.name === 'post-checkout-1');
6055
expect(postCheckoutStep1).toBeDefined();
6156
expect(postCheckoutStep1?.parent_span_id).toBe(postCheckoutSpanId);
6257
},

dev-packages/browser-integration-tests/suites/tracing/setSpanActive-streamed/nested/test.ts

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,27 +11,22 @@ sentryTest(
1111
const checkoutSpansPromise = waitForStreamedSpans(page, spans =>
1212
spans.some(s => s.name === 'checkout-flow' && s.is_segment),
1313
);
14-
const postCheckoutSpansPromise = waitForStreamedSpans(page, spans =>
15-
spans.some(s => s.name === 'post-checkout' && s.is_segment),
16-
);
1714

1815
const url = await getLocalTestUrl({ testDir: __dirname });
1916
await page.goto(url);
2017

2118
const checkoutSpans = await checkoutSpansPromise;
22-
const postCheckoutSpans = await postCheckoutSpansPromise;
2319

2420
const checkoutSpan = checkoutSpans.find(s => s.name === 'checkout-flow');
25-
const postCheckoutSpan = postCheckoutSpans.find(s => s.name === 'post-checkout');
21+
const postCheckoutSpan = checkoutSpans.find(s => s.name === 'post-checkout');
2622

2723
const checkoutSpanId = checkoutSpan?.span_id;
2824
const postCheckoutSpanId = postCheckoutSpan?.span_id;
2925

3026
expect(checkoutSpanId).toMatch(/[a-f\d]{16}/);
3127
expect(postCheckoutSpanId).toMatch(/[a-f\d]{16}/);
3228

33-
expect(checkoutSpans.filter(s => !s.is_segment)).toHaveLength(4);
34-
expect(postCheckoutSpans.filter(s => !s.is_segment)).toHaveLength(1);
29+
expect(checkoutSpans.filter(s => !s.is_segment)).toHaveLength(5);
3530

3631
const checkoutStep1 = checkoutSpans.find(s => s.name === 'checkout-step-1');
3732
const checkoutStep2 = checkoutSpans.find(s => s.name === 'checkout-step-2');
@@ -51,7 +46,7 @@ sentryTest(
5146
// root span due to this being default behaviour in browser environments
5247
expect(checkoutStep21?.parent_span_id).toBe(checkoutSpanId);
5348

54-
const postCheckoutStep1 = postCheckoutSpans.find(s => s.name === 'post-checkout-1');
49+
const postCheckoutStep1 = checkoutSpans.find(s => s.name === 'post-checkout-1');
5550
expect(postCheckoutStep1).toBeDefined();
5651
expect(postCheckoutStep1?.parent_span_id).toBe(postCheckoutSpanId);
5752
},

packages/browser/src/integrations/spanstreaming.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,12 @@ export const spanStreamingIntegration = defineIntegration(() => {
5858
// In addition to capturing the span, we also flush the trace when the segment
5959
// span ends to ensure things are sent timely. We never know when the browser
6060
// is closed, users navigate away, etc.
61-
client.on('afterSegmentSpanEnd', segmentSpan => buffer.flush(segmentSpan.spanContext().traceId));
61+
client.on('afterSegmentSpanEnd', segmentSpan => {
62+
const traceId = segmentSpan.spanContext().traceId;
63+
setTimeout(() => {
64+
buffer.flush(traceId);
65+
}, 500);
66+
});
6267
},
6368
};
6469
}) satisfies IntegrationFn;

packages/browser/src/tracing/request.ts

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
/* eslint-disable max-lines */
12
import type {
23
Client,
34
HandlerDataXhr,
45
RequestHookInfo,
56
ResponseHookInfo,
67
SentryWrappedXMLHttpRequest,
78
Span,
9+
SpanTimeInput,
810
} from '@sentry/core';
911
import {
1012
addFetchEndInstrumentationHandler,
@@ -14,6 +16,7 @@ import {
1416
getLocationHref,
1517
getTraceData,
1618
hasSpansEnabled,
19+
hasSpanStreamingEnabled,
1720
instrumentFetchRequest,
1821
parseUrl,
1922
SEMANTIC_ATTRIBUTE_SENTRY_OP,
@@ -25,6 +28,7 @@ import {
2528
stringMatchesSomePattern,
2629
stripDataUrlContent,
2730
stripUrlQueryAndFragment,
31+
timestampInSeconds,
2832
} from '@sentry/core';
2933
import type { XhrHint } from '@sentry-internal/browser-utils';
3034
import {
@@ -205,7 +209,7 @@ export function instrumentOutgoingRequests(client: Client, _options?: Partial<Re
205209
});
206210

207211
if (enableHTTPTimings) {
208-
addHTTPTimings(createdSpan);
212+
addHTTPTimings(createdSpan, client);
209213
}
210214

211215
onRequestSpanStart?.(createdSpan, { headers: handlerData.headers });
@@ -226,7 +230,7 @@ export function instrumentOutgoingRequests(client: Client, _options?: Partial<Re
226230

227231
if (createdSpan) {
228232
if (enableHTTPTimings) {
229-
addHTTPTimings(createdSpan);
233+
addHTTPTimings(createdSpan, client);
230234
}
231235

232236
onRequestSpanStart?.(createdSpan, {
@@ -237,26 +241,64 @@ export function instrumentOutgoingRequests(client: Client, _options?: Partial<Re
237241
}
238242
}
239243

244+
/**
245+
* The maximum time (ms) to wait for PerformanceResourceTiming data before ending the span.
246+
* Same approach is used by OTel's browser fetch instrumentation:
247+
* See {@link https://github.com/open-telemetry/opentelemetry-js/blob/30f94fe99339287b1e4d3c8bb90172c2523f06f4/experimental/packages/opentelemetry-instrumentation-fetch/src/fetch.ts#L352-L372}
248+
*/
249+
const HTTP_TIMING_WAIT_MS = 300;
250+
240251
/**
241252
* Creates a temporary observer to listen to the next fetch/xhr resourcing timings,
242253
* so that when timings hit their per-browser limit they don't need to be removed.
243254
*
244255
* @param span A span that has yet to be finished, must contain `url` on data.
245256
*/
246-
function addHTTPTimings(span: Span): void {
257+
function addHTTPTimings(span: Span, client: Client): void {
247258
const { url } = spanToJSON(span).data;
248259

249260
if (!url || typeof url !== 'string') {
250261
return;
251262
}
252263

253-
const cleanup = addPerformanceInstrumentationHandler('resource', ({ entries }) => {
264+
// Clean up the performance observer and other resources
265+
// We have to wait here because otherwise this cleans itself up before it is fully done.
266+
// Default (non-streaming): just deregister the observer.
267+
let onEntryFound = (): void => void setTimeout(unsubscribePerformanceObsever);
268+
269+
// For streamed spans, we have to artificially delay the ending of the span until we
270+
// either receive the timing data, or HTTP_TIMING_WAIT_MS elapses.
271+
if (hasSpanStreamingEnabled(client)) {
272+
const originalEnd = span.end.bind(span);
273+
274+
span.end = (endTimestamp?: SpanTimeInput) => {
275+
const capturedEndTimestamp = endTimestamp ?? timestampInSeconds();
276+
let isEnded = false;
277+
278+
const endSpanAndCleanup = (): void => {
279+
if (isEnded) {
280+
return;
281+
}
282+
isEnded = true;
283+
setTimeout(unsubscribePerformanceObsever);
284+
originalEnd(capturedEndTimestamp);
285+
clearTimeout(fallbackTimeout);
286+
};
287+
288+
onEntryFound = endSpanAndCleanup;
289+
290+
// Fallback: always end the span after HTTP_TIMING_WAIT_MS even if no
291+
// PerformanceResourceTiming entry arrives (e.g. cross-origin without
292+
// Timing-Allow-Origin, or the browser didn't fire the observer in time).
293+
const fallbackTimeout = setTimeout(endSpanAndCleanup, HTTP_TIMING_WAIT_MS);
294+
};
295+
}
296+
297+
const unsubscribePerformanceObsever = addPerformanceInstrumentationHandler('resource', ({ entries }) => {
254298
entries.forEach(entry => {
255299
if (isPerformanceResourceTiming(entry) && entry.name.endsWith(url)) {
256300
span.setAttributes(resourceTimingToSpanAttributes(entry));
257-
// In the next tick, clean this handler up
258-
// We have to wait here because otherwise this cleans itself up before it is fully done
259-
setTimeout(cleanup);
301+
onEntryFound();
260302
}
261303
});
262304
});

packages/browser/test/integrations/spanstreaming.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,8 @@ describe('spanStreamingIntegration', () => {
171171
expect(mockSpanBufferInstance.flush).not.toHaveBeenCalled();
172172
});
173173

174-
it('flushes the trace when the segment span ends', () => {
174+
it('flushes the trace when the segment span ends after a delay for close to finished child spans', () => {
175+
vi.useFakeTimers();
175176
const client = new BrowserClient({
176177
...getDefaultBrowserClientOptions(),
177178
dsn: 'https://username@domain/123',
@@ -185,6 +186,10 @@ describe('spanStreamingIntegration', () => {
185186
const span = new SentryCore.SentrySpan({ name: 'test' });
186187
client.emit('afterSegmentSpanEnd', span);
187188

189+
vi.advanceTimersByTime(500);
190+
188191
expect(mockSpanBufferInstance.flush).toHaveBeenCalledWith(span.spanContext().traceId);
192+
193+
vi.useRealTimers();
189194
});
190195
});

0 commit comments

Comments
 (0)