Skip to content

Commit ccb98ff

Browse files
authored
feat(browser): Improve idle span handling (#12065)
Closes #12051 This PR does two things: 1. Ensures we add helpful attributes to idle spans more consistently. We now add the `sentry.idle_span_finish_reason` attribute always for idle spans, not only when op == `ui.action.click`. This should also make it easier to debug stuff etc, and I see no reason to not always set this. Additionally, we also keep the number of spans we discarded (if any) for easier debugging too (as `sentry.idle_span_discarded_spans`). 2. We ensure that idle spans cannot exceed the configured `finalTimeout`. Previously, due to the order of things, it was possible that we ended a span very late, if it had a child span with a very late end timestamp (as we took the last child span timestamp). Possibly this could lead to overly long transactions. I think 2, combined with the fact that later we _do_ filter out child spans that ended after idle span, lead to the incorrect-length spans.
1 parent 89377da commit ccb98ff

File tree

2 files changed

+183
-10
lines changed

2 files changed

+183
-10
lines changed

packages/core/src/tracing/idleSpan.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -129,15 +129,17 @@ export function startIdleSpan(startSpanOptions: StartSpanOptions, options: Parti
129129
const latestSpanEndTimestamp = childEndTimestamps.length ? Math.max(...childEndTimestamps) : undefined;
130130

131131
const spanEndTimestamp = spanTimeInputToSeconds(timestamp);
132+
// In reality this should always exist here, but type-wise it may be undefined...
132133
const spanStartTimestamp = spanToJSON(span).start_timestamp;
133134

134135
// The final endTimestamp should:
135136
// * Never be before the span start timestamp
136137
// * Be the latestSpanEndTimestamp, if there is one, and it is smaller than the passed span end timestamp
137138
// * Otherwise be the passed end timestamp
138-
const endTimestamp = Math.max(
139-
spanStartTimestamp || -Infinity,
140-
Math.min(spanEndTimestamp, latestSpanEndTimestamp || Infinity),
139+
// Final timestamp can never be after finalTimeout
140+
const endTimestamp = Math.min(
141+
spanStartTimestamp ? spanStartTimestamp + finalTimeout / 1000 : Infinity,
142+
Math.max(spanStartTimestamp || -Infinity, Math.min(spanEndTimestamp, latestSpanEndTimestamp || Infinity)),
141143
);
142144

143145
span.end(endTimestamp);
@@ -240,14 +242,15 @@ export function startIdleSpan(startSpanOptions: StartSpanOptions, options: Parti
240242
}
241243

242244
const attributes: SpanAttributes = spanJSON.data || {};
243-
if (spanJSON.op === 'ui.action.click' && !attributes[SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON]) {
245+
if (!attributes[SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON]) {
244246
span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON, _finishReason);
245247
}
246248

247249
logger.log(`[Tracing] Idle span "${spanJSON.op}" finished`);
248250

249251
const childSpans = getSpanDescendants(span).filter(child => child !== span);
250252

253+
let discardedSpans = 0;
251254
childSpans.forEach(childSpan => {
252255
// We cancel all pending spans with status "cancelled" to indicate the idle span was finished early
253256
if (childSpan.isRecording()) {
@@ -277,8 +280,13 @@ export function startIdleSpan(startSpanOptions: StartSpanOptions, options: Parti
277280

278281
if (!spanEndedBeforeFinalTimeout || !spanStartedBeforeIdleSpanEnd) {
279282
removeChildSpanFromSpan(span, childSpan);
283+
discardedSpans++;
280284
}
281285
});
286+
287+
if (discardedSpans > 0) {
288+
span.setAttribute('sentry.idle_span_discarded_spans', discardedSpans);
289+
}
282290
}
283291

284292
client.on('spanStart', startedSpan => {

packages/core/test/lib/tracing/idleSpan.test.ts

Lines changed: 171 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,81 @@ describe('startIdleSpan', () => {
230230
);
231231
});
232232

233+
it('Ensures idle span cannot exceed finalTimeout', () => {
234+
const transactions: Event[] = [];
235+
const beforeSendTransaction = jest.fn(event => {
236+
transactions.push(event);
237+
return null;
238+
});
239+
const options = getDefaultTestClientOptions({
240+
dsn,
241+
tracesSampleRate: 1,
242+
beforeSendTransaction,
243+
});
244+
const client = new TestClient(options);
245+
setCurrentClient(client);
246+
client.init();
247+
248+
// We want to accomodate a bit of drift there, so we ensure this starts earlier...
249+
const finalTimeout = 99_999;
250+
const baseTimeInSeconds = Math.floor(Date.now() / 1000) - 9999;
251+
252+
const idleSpan = startIdleSpan({ name: 'idle span', startTime: baseTimeInSeconds }, { finalTimeout: finalTimeout });
253+
expect(idleSpan).toBeDefined();
254+
255+
// regular child - should be kept
256+
const regularSpan = startInactiveSpan({
257+
name: 'regular span',
258+
startTime: baseTimeInSeconds + 2,
259+
});
260+
regularSpan.end(baseTimeInSeconds + 4);
261+
262+
// very late ending span
263+
const discardedSpan = startInactiveSpan({ name: 'discarded span', startTime: baseTimeInSeconds + 99 });
264+
discardedSpan.end(baseTimeInSeconds + finalTimeout + 100);
265+
266+
// Should be cancelled - will not finish
267+
const cancelledSpan = startInactiveSpan({
268+
name: 'cancelled span',
269+
startTime: baseTimeInSeconds + 4,
270+
});
271+
272+
jest.runOnlyPendingTimers();
273+
274+
expect(regularSpan.isRecording()).toBe(false);
275+
expect(idleSpan.isRecording()).toBe(false);
276+
expect(discardedSpan.isRecording()).toBe(false);
277+
expect(cancelledSpan.isRecording()).toBe(false);
278+
279+
expect(beforeSendTransaction).toHaveBeenCalledTimes(1);
280+
const transaction = transactions[0];
281+
282+
// End time is based on idle time etc.
283+
const idleSpanEndTime = transaction.timestamp!;
284+
expect(idleSpanEndTime).toEqual(baseTimeInSeconds + finalTimeout / 1000);
285+
286+
expect(transaction.spans).toHaveLength(2);
287+
expect(transaction.spans).toEqual(
288+
expect.arrayContaining([
289+
expect.objectContaining({
290+
description: 'regular span',
291+
timestamp: baseTimeInSeconds + 4,
292+
start_timestamp: baseTimeInSeconds + 2,
293+
}),
294+
]),
295+
);
296+
expect(transaction.spans).toEqual(
297+
expect.arrayContaining([
298+
expect.objectContaining({
299+
description: 'cancelled span',
300+
timestamp: idleSpanEndTime,
301+
start_timestamp: baseTimeInSeconds + 4,
302+
status: 'cancelled',
303+
}),
304+
]),
305+
);
306+
});
307+
233308
it('emits span hooks', () => {
234309
const client = getClient()!;
235310

@@ -274,6 +349,27 @@ describe('startIdleSpan', () => {
274349
expect(recordDroppedEventSpy).toHaveBeenCalledWith('sample_rate', 'transaction');
275350
});
276351

352+
it('sets finish reason when span is ended manually', () => {
353+
let transaction: Event | undefined;
354+
const beforeSendTransaction = jest.fn(event => {
355+
transaction = event;
356+
return null;
357+
});
358+
const options = getDefaultTestClientOptions({ dsn, tracesSampleRate: 1, beforeSendTransaction });
359+
const client = new TestClient(options);
360+
setCurrentClient(client);
361+
client.init();
362+
363+
const span = startIdleSpan({ name: 'foo' });
364+
span.end();
365+
jest.runOnlyPendingTimers();
366+
367+
expect(beforeSendTransaction).toHaveBeenCalledTimes(1);
368+
expect(transaction?.contexts?.trace?.data?.[SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON]).toEqual(
369+
'externalFinish',
370+
);
371+
});
372+
277373
it('sets finish reason when span ends', () => {
278374
let transaction: Event | undefined;
279375
const beforeSendTransaction = jest.fn(event => {
@@ -285,8 +381,7 @@ describe('startIdleSpan', () => {
285381
setCurrentClient(client);
286382
client.init();
287383

288-
// This is only set when op === 'ui.action.click'
289-
startIdleSpan({ name: 'foo', op: 'ui.action.click' });
384+
startIdleSpan({ name: 'foo' });
290385
startSpan({ name: 'inner' }, () => {});
291386
jest.runOnlyPendingTimers();
292387

@@ -296,6 +391,57 @@ describe('startIdleSpan', () => {
296391
);
297392
});
298393

394+
it('sets finish reason when span ends via expired heartbeat timeout', () => {
395+
let transaction: Event | undefined;
396+
const beforeSendTransaction = jest.fn(event => {
397+
transaction = event;
398+
return null;
399+
});
400+
const options = getDefaultTestClientOptions({ dsn, tracesSampleRate: 1, beforeSendTransaction });
401+
const client = new TestClient(options);
402+
setCurrentClient(client);
403+
client.init();
404+
405+
startIdleSpan({ name: 'foo' });
406+
startSpanManual({ name: 'inner' }, () => {});
407+
jest.runOnlyPendingTimers();
408+
409+
expect(beforeSendTransaction).toHaveBeenCalledTimes(1);
410+
expect(transaction?.contexts?.trace?.data?.[SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON]).toEqual(
411+
'heartbeatFailed',
412+
);
413+
});
414+
415+
it('sets finish reason when span ends via final timeout', () => {
416+
let transaction: Event | undefined;
417+
const beforeSendTransaction = jest.fn(event => {
418+
transaction = event;
419+
return null;
420+
});
421+
const options = getDefaultTestClientOptions({ dsn, tracesSampleRate: 1, beforeSendTransaction });
422+
const client = new TestClient(options);
423+
setCurrentClient(client);
424+
client.init();
425+
426+
startIdleSpan({ name: 'foo' }, { finalTimeout: TRACING_DEFAULTS.childSpanTimeout * 2 });
427+
428+
const span1 = startInactiveSpan({ name: 'inner' });
429+
jest.advanceTimersByTime(TRACING_DEFAULTS.childSpanTimeout - 1);
430+
span1.end();
431+
432+
const span2 = startInactiveSpan({ name: 'inner2' });
433+
jest.advanceTimersByTime(TRACING_DEFAULTS.childSpanTimeout - 1);
434+
span2.end();
435+
436+
startInactiveSpan({ name: 'inner3' });
437+
jest.runOnlyPendingTimers();
438+
439+
expect(beforeSendTransaction).toHaveBeenCalledTimes(1);
440+
expect(transaction?.contexts?.trace?.data?.[SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON]).toEqual(
441+
'finalTimeout',
442+
);
443+
});
444+
299445
it('uses finish reason set outside when span ends', () => {
300446
let transaction: Event | undefined;
301447
const beforeSendTransaction = jest.fn(event => {
@@ -307,8 +453,7 @@ describe('startIdleSpan', () => {
307453
setCurrentClient(client);
308454
client.init();
309455

310-
// This is only set when op === 'ui.action.click'
311-
const span = startIdleSpan({ name: 'foo', op: 'ui.action.click' });
456+
const span = startIdleSpan({ name: 'foo' });
312457
span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON, 'custom reason');
313458
startSpan({ name: 'inner' }, () => {});
314459
jest.runOnlyPendingTimers();
@@ -496,7 +641,7 @@ describe('startIdleSpan', () => {
496641

497642
describe('trim end timestamp', () => {
498643
it('trims end to highest child span end', () => {
499-
const idleSpan = startIdleSpan({ name: 'foo', startTime: 1000 });
644+
const idleSpan = startIdleSpan({ name: 'foo', startTime: 1000 }, { finalTimeout: 99_999_999 });
500645
expect(idleSpan).toBeDefined();
501646

502647
const span1 = startInactiveSpan({ name: 'span1', startTime: 1001 });
@@ -515,8 +660,28 @@ describe('startIdleSpan', () => {
515660
expect(spanToJSON(idleSpan!).timestamp).toBe(1100);
516661
});
517662

663+
it('trims end to final timeout', () => {
664+
const idleSpan = startIdleSpan({ name: 'foo', startTime: 1000 }, { finalTimeout: 30_000 });
665+
expect(idleSpan).toBeDefined();
666+
667+
const span1 = startInactiveSpan({ name: 'span1', startTime: 1001 });
668+
span1?.end(1005);
669+
670+
const span2 = startInactiveSpan({ name: 'span2', startTime: 1002 });
671+
span2?.end(1100);
672+
673+
const span3 = startInactiveSpan({ name: 'span1', startTime: 1050 });
674+
span3?.end(1060);
675+
676+
expect(getActiveSpan()).toBe(idleSpan);
677+
678+
jest.runAllTimers();
679+
680+
expect(spanToJSON(idleSpan!).timestamp).toBe(1030);
681+
});
682+
518683
it('keeps lower span endTime than highest child span end', () => {
519-
const idleSpan = startIdleSpan({ name: 'foo', startTime: 1000 });
684+
const idleSpan = startIdleSpan({ name: 'foo', startTime: 1000 }, { finalTimeout: 99_999_999 });
520685
expect(idleSpan).toBeDefined();
521686

522687
const span1 = startInactiveSpan({ name: 'span1', startTime: 999_999_999 });

0 commit comments

Comments
 (0)