Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .size-limit.js
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ module.exports = [
path: createCDNPath('bundle.tracing.min.js'),
gzip: false,
brotli: false,
limit: '129 KB',
limit: '130 KB',
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Browser bundle size increase flagged per project rules

Low Severity

The CDN Bundle (incl. Tracing) uncompressed size limit increased from 129 KB to 130 KB (~0.8% increase). Per the project rules, large bundle size increases in browser packages need to be flagged even when unavoidable. The PR description acknowledges this trade-off for adding HTTP timing support to streamed spans.

Fix in Cursor Fix in Web

Triggered by project rule: PR Review Guidelines for Cursor Bot

},
{
name: 'CDN Bundle (incl. Logs, Metrics) - uncompressed',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,8 @@ import { sentryTest } from '../../../../utils/fixtures';
import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers';
import { getSpanOp, waitForStreamedSpans } from '../../../../utils/spanUtils';

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

Expand Down Expand Up @@ -49,7 +41,7 @@ sentryTest(
end_timestamp: expect.any(Number),
trace_id: pageloadSpan?.trace_id,
status: 'ok',
attributes: expect.not.objectContaining({
attributes: expect.objectContaining({
'http.request.redirect_start': expect.any(Object),
'http.request.redirect_end': expect.any(Object),
'http.request.worker_start': expect.any(Object),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,27 +11,22 @@ sentryTest(
const checkoutSpansPromise = waitForStreamedSpans(page, spans =>
spans.some(s => s.name === 'checkout-flow' && s.is_segment),
);
const postCheckoutSpansPromise = waitForStreamedSpans(page, spans =>
spans.some(s => s.name === 'post-checkout' && s.is_segment),
);

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

const checkoutSpans = await checkoutSpansPromise;
const postCheckoutSpans = await postCheckoutSpansPromise;

const checkoutSpan = checkoutSpans.find(s => s.name === 'checkout-flow');
const postCheckoutSpan = postCheckoutSpans.find(s => s.name === 'post-checkout');
const postCheckoutSpan = checkoutSpans.find(s => s.name === 'post-checkout');

const checkoutSpanId = checkoutSpan?.span_id;
const postCheckoutSpanId = postCheckoutSpan?.span_id;

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

expect(checkoutSpans.filter(s => !s.is_segment)).toHaveLength(4);
expect(postCheckoutSpans.filter(s => !s.is_segment)).toHaveLength(1);
expect(checkoutSpans.filter(s => !s.is_segment)).toHaveLength(5);

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

// post-checkout trace is started as a new trace because ending checkoutSpan removes the active
// span on the scope
const postCheckoutStep1 = postCheckoutSpans.find(s => s.name === 'post-checkout-1');
const postCheckoutStep1 = checkoutSpans.find(s => s.name === 'post-checkout-1');
expect(postCheckoutStep1).toBeDefined();
expect(postCheckoutStep1?.parent_span_id).toBe(postCheckoutSpanId);
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,27 +11,22 @@ sentryTest(
const checkoutSpansPromise = waitForStreamedSpans(page, spans =>
spans.some(s => s.name === 'checkout-flow' && s.is_segment),
);
const postCheckoutSpansPromise = waitForStreamedSpans(page, spans =>
spans.some(s => s.name === 'post-checkout' && s.is_segment),
);

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

const checkoutSpans = await checkoutSpansPromise;
const postCheckoutSpans = await postCheckoutSpansPromise;

const checkoutSpan = checkoutSpans.find(s => s.name === 'checkout-flow');
const postCheckoutSpan = postCheckoutSpans.find(s => s.name === 'post-checkout');
const postCheckoutSpan = checkoutSpans.find(s => s.name === 'post-checkout');

const checkoutSpanId = checkoutSpan?.span_id;
const postCheckoutSpanId = postCheckoutSpan?.span_id;

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

expect(checkoutSpans.filter(s => !s.is_segment)).toHaveLength(4);
expect(postCheckoutSpans.filter(s => !s.is_segment)).toHaveLength(1);
expect(checkoutSpans.filter(s => !s.is_segment)).toHaveLength(5);

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

const postCheckoutStep1 = postCheckoutSpans.find(s => s.name === 'post-checkout-1');
const postCheckoutStep1 = checkoutSpans.find(s => s.name === 'post-checkout-1');
expect(postCheckoutStep1).toBeDefined();
expect(postCheckoutStep1?.parent_span_id).toBe(postCheckoutSpanId);
},
Expand Down
7 changes: 6 additions & 1 deletion packages/browser/src/integrations/spanstreaming.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,12 @@ export const spanStreamingIntegration = defineIntegration(() => {
// In addition to capturing the span, we also flush the trace when the segment
// span ends to ensure things are sent timely. We never know when the browser
// is closed, users navigate away, etc.
client.on('afterSegmentSpanEnd', segmentSpan => buffer.flush(segmentSpan.spanContext().traceId));
client.on('afterSegmentSpanEnd', segmentSpan => {
const traceId = segmentSpan.spanContext().traceId;
setTimeout(() => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

q/l: Should we wrap this in our safeUnref function? Or is this guaranteed to run only on the browser?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's guaranteed to only run in the browser. There will be a dedicated server-side spanStreamingIntegration which doesn't need this logic

buffer.flush(traceId);
}, 500);
});
},
};
}) satisfies IntegrationFn;
56 changes: 49 additions & 7 deletions packages/browser/src/tracing/request.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
/* eslint-disable max-lines */
import type {
Client,
HandlerDataXhr,
RequestHookInfo,
ResponseHookInfo,
SentryWrappedXMLHttpRequest,
Span,
SpanTimeInput,
} from '@sentry/core';
import {
addFetchEndInstrumentationHandler,
Expand All @@ -14,6 +16,7 @@ import {
getLocationHref,
getTraceData,
hasSpansEnabled,
hasSpanStreamingEnabled,
instrumentFetchRequest,
parseUrl,
SEMANTIC_ATTRIBUTE_SENTRY_OP,
Expand All @@ -25,6 +28,7 @@ import {
stringMatchesSomePattern,
stripDataUrlContent,
stripUrlQueryAndFragment,
timestampInSeconds,
} from '@sentry/core';
import type { XhrHint } from '@sentry-internal/browser-utils';
import {
Expand Down Expand Up @@ -205,7 +209,7 @@ export function instrumentOutgoingRequests(client: Client, _options?: Partial<Re
});

if (enableHTTPTimings) {
addHTTPTimings(createdSpan);
addHTTPTimings(createdSpan, client);
}

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

if (createdSpan) {
if (enableHTTPTimings) {
addHTTPTimings(createdSpan);
addHTTPTimings(createdSpan, client);
}

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

/**
* The maximum time (ms) to wait for PerformanceResourceTiming data before ending the span.
* Same approach is used by OTel's browser fetch instrumentation:
* See {@link https://github.com/open-telemetry/opentelemetry-js/blob/30f94fe99339287b1e4d3c8bb90172c2523f06f4/experimental/packages/opentelemetry-instrumentation-fetch/src/fetch.ts#L352-L372}
*/
const HTTP_TIMING_WAIT_MS = 300;

/**
* Creates a temporary observer to listen to the next fetch/xhr resourcing timings,
* so that when timings hit their per-browser limit they don't need to be removed.
*
* @param span A span that has yet to be finished, must contain `url` on data.
*/
function addHTTPTimings(span: Span): void {
function addHTTPTimings(span: Span, client: Client): void {
const { url } = spanToJSON(span).data;

if (!url || typeof url !== 'string') {
return;
}

const cleanup = addPerformanceInstrumentationHandler('resource', ({ entries }) => {
// Clean up the performance observer and other resources
// We have to wait here because otherwise this cleans itself up before it is fully done.
// Default (non-streaming): just deregister the observer.
let onEntryFound = (): void => void setTimeout(unsubscribePerformanceObsever);

// For streamed spans, we have to artificially delay the ending of the span until we
// either receive the timing data, or HTTP_TIMING_WAIT_MS elapses.
if (hasSpanStreamingEnabled(client)) {
const originalEnd = span.end.bind(span);

span.end = (endTimestamp?: SpanTimeInput) => {
const capturedEndTimestamp = endTimestamp ?? timestampInSeconds();
let isEnded = false;

const endSpanAndCleanup = (): void => {
if (isEnded) {
return;
}
isEnded = true;
setTimeout(unsubscribePerformanceObsever);
originalEnd(capturedEndTimestamp);
clearTimeout(fallbackTimeout);
};

onEntryFound = endSpanAndCleanup;

// Fallback: always end the span after HTTP_TIMING_WAIT_MS even if no
// PerformanceResourceTiming entry arrives (e.g. cross-origin without
// Timing-Allow-Origin, or the browser didn't fire the observer in time).
const fallbackTimeout = setTimeout(endSpanAndCleanup, HTTP_TIMING_WAIT_MS);
};
}

const unsubscribePerformanceObsever = addPerformanceInstrumentationHandler('resource', ({ entries }) => {
entries.forEach(entry => {
if (isPerformanceResourceTiming(entry) && entry.name.endsWith(url)) {
span.setAttributes(resourceTimingToSpanAttributes(entry));
// In the next tick, clean this handler up
// We have to wait here because otherwise this cleans itself up before it is fully done
setTimeout(cleanup);
onEntryFound();
}
});
});
Expand Down
7 changes: 6 additions & 1 deletion packages/browser/test/integrations/spanstreaming.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,8 @@ describe('spanStreamingIntegration', () => {
expect(mockSpanBufferInstance.flush).not.toHaveBeenCalled();
});

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

vi.advanceTimersByTime(500);

expect(mockSpanBufferInstance.flush).toHaveBeenCalledWith(span.spanContext().traceId);

vi.useRealTimers();
});
});
Loading