Skip to content

Commit 82c356d

Browse files
authored
feat(core): Implement startIdleSpan (#10934)
For now, this does not actually _use_ this anywhere. The idle span works _mostly_ like the idle transaction, but there are small differences: 1. I replaced the heartbeat with simply waiting for 15s (instead of 3x5s) after the last span was started. IMHO semantically this is good enough, and makes it easier to reason about this? LMK if I am missing something there. 2. This means that the idle span ends when either: a. No span was created for 1s b. The last time a span was created was 15s ago c. 30s passed (final timeout) 3. Instead of the `delayAutoFinishUntilSignal` we used before for e.g. Next.js streaming pages, we now have `disableAutoFinish`, together with `client.emit('idleSpanEnableAutoFinish', idleSpan)` 4. Idle spans are always on the scope (we never used it without this setting) 5. You can directly pass `beforeSpanEnd` (instead of `registerBeforeFinishCallback()`), which we can use for web vital collection etc.
1 parent c52b1dd commit 82c356d

File tree

11 files changed

+903
-1
lines changed

11 files changed

+903
-1
lines changed

packages/core/src/baseclient.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import type {
2222
Session,
2323
SessionAggregates,
2424
SeverityLevel,
25+
Span,
2526
StartSpanOptions,
2627
Transaction,
2728
TransactionEvent,
@@ -422,6 +423,15 @@ export abstract class BaseClient<O extends ClientOptions> implements Client<O> {
422423
/** @inheritdoc */
423424
public on(hook: 'finishTransaction', callback: (transaction: Transaction) => void): void;
424425

426+
/** @inheritdoc */
427+
public on(hook: 'spanStart', callback: (span: Span) => void): void;
428+
429+
/** @inheritdoc */
430+
public on(hook: 'spanEnd', callback: (span: Span) => void): void;
431+
432+
/** @inheritdoc */
433+
public on(hook: 'idleSpanEnableAutoFinish', callback: (span: Span) => void): void;
434+
425435
/** @inheritdoc */
426436
public on(hook: 'beforeEnvelope', callback: (envelope: Envelope) => void): void;
427437

@@ -472,6 +482,15 @@ export abstract class BaseClient<O extends ClientOptions> implements Client<O> {
472482
/** @inheritdoc */
473483
public emit(hook: 'finishTransaction', transaction: Transaction): void;
474484

485+
/** @inheritdoc */
486+
public emit(hook: 'spanStart', span: Span): void;
487+
488+
/** @inheritdoc */
489+
public emit(hook: 'spanEnd', span: Span): void;
490+
491+
/** @inheritdoc */
492+
public emit(hook: 'idleSpanEnableAutoFinish', span: Span): void;
493+
475494
/** @inheritdoc */
476495
public emit(hook: 'beforeEnvelope', envelope: Envelope): void;
477496

packages/core/src/tracing/hubextensions.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ function _startTransaction(
4848
}
4949
if (client) {
5050
client.emit('startTransaction', transaction);
51+
client.emit('spanStart', transaction);
5152
}
5253
return transaction;
5354
}
@@ -95,6 +96,7 @@ export function startIdleTransaction(
9596
}
9697
if (client) {
9798
client.emit('startTransaction', transaction);
99+
client.emit('spanStart', transaction);
98100
}
99101
return transaction;
100102
}

packages/core/src/tracing/idleSpan.ts

Lines changed: 333 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,333 @@
1+
import type { Span, StartSpanOptions } from '@sentry/types';
2+
import { logger, timestampInSeconds } from '@sentry/utils';
3+
import { getClient, getCurrentScope } from '../currentScopes';
4+
5+
import { DEBUG_BUILD } from '../debug-build';
6+
import { spanToJSON } from '../utils/spanUtils';
7+
import { SPAN_STATUS_ERROR } from './spanstatus';
8+
import { startInactiveSpan } from './trace';
9+
import { getActiveSpan, getSpanDescendants, removeChildSpanFromSpan } from './utils';
10+
11+
export const TRACING_DEFAULTS = {
12+
idleTimeout: 1_000,
13+
finalTimeout: 30_000,
14+
childSpanTimeout: 15_000,
15+
};
16+
17+
const FINISH_REASON_TAG = 'finishReason';
18+
19+
const FINISH_REASON_HEARTBEAT_FAILED = 'heartbeatFailed';
20+
const FINISH_REASON_IDLE_TIMEOUT = 'idleTimeout';
21+
const FINISH_REASON_FINAL_TIMEOUT = 'finalTimeout';
22+
const FINISH_REASON_EXTERNAL_FINISH = 'externalFinish';
23+
const FINISH_REASON_CANCELLED = 'cancelled';
24+
25+
// unused
26+
const FINISH_REASON_DOCUMENT_HIDDEN = 'documentHidden';
27+
28+
// unusued in this file, but used in BrowserTracing
29+
const FINISH_REASON_INTERRUPTED = 'interactionInterrupted';
30+
31+
type IdleSpanFinishReason =
32+
| typeof FINISH_REASON_CANCELLED
33+
| typeof FINISH_REASON_DOCUMENT_HIDDEN
34+
| typeof FINISH_REASON_EXTERNAL_FINISH
35+
| typeof FINISH_REASON_FINAL_TIMEOUT
36+
| typeof FINISH_REASON_HEARTBEAT_FAILED
37+
| typeof FINISH_REASON_IDLE_TIMEOUT
38+
| typeof FINISH_REASON_INTERRUPTED;
39+
40+
interface IdleSpanOptions {
41+
/**
42+
* The time that has to pass without any span being created.
43+
* If this time is exceeded, the idle span will finish.
44+
*/
45+
idleTimeout: number;
46+
/**
47+
* The max. time an idle span may run.
48+
* If this time is exceeded, the idle span will finish no matter what.
49+
*/
50+
finalTimeout: number;
51+
/**
52+
* The max. time a child span may run.
53+
* If the time since the last span was started exceeds this time, the idle span will finish.
54+
*/
55+
childSpanTimeout?: number;
56+
/**
57+
* When set to `true`, will disable the idle timeout and child timeout
58+
* until the `idleSpanEnableAutoFinish` hook is emitted for the idle span.
59+
* The final timeout mechanism will not be affected by this option,
60+
* meaning the idle span will definitely be finished when the final timeout is
61+
* reached, no matter what this option is configured to.
62+
*
63+
* Defaults to `false`.
64+
*/
65+
disableAutoFinish?: boolean;
66+
/** Allows to configure a hook that is called when the idle span is ended, before it is processed. */
67+
beforeSpanEnd?: (span: Span) => void;
68+
}
69+
70+
/**
71+
* An idle span is a span that automatically finishes. It does this by tracking child spans as activities.
72+
* An idle span is always the active span.
73+
*/
74+
export function startIdleSpan(
75+
startSpanOptions: StartSpanOptions,
76+
options: Partial<IdleSpanOptions> = {},
77+
): Span | undefined {
78+
// Activities store a list of active spans
79+
const activities = new Map<string, boolean>();
80+
81+
// We should not use heartbeat if we finished a span
82+
let _finished = false;
83+
84+
// Timer that tracks idleTimeout
85+
let _idleTimeoutID: ReturnType<typeof setTimeout> | undefined;
86+
87+
// Timer that tracks maxSpanTime for child spans
88+
let _childSpanTimeoutID: ReturnType<typeof setTimeout> | undefined;
89+
90+
// The reason why the span was finished
91+
let _finishReason: IdleSpanFinishReason = FINISH_REASON_EXTERNAL_FINISH;
92+
93+
let _autoFinishAllowed: boolean = !options.disableAutoFinish;
94+
95+
const {
96+
idleTimeout = TRACING_DEFAULTS.idleTimeout,
97+
finalTimeout = TRACING_DEFAULTS.finalTimeout,
98+
childSpanTimeout = TRACING_DEFAULTS.childSpanTimeout,
99+
beforeSpanEnd,
100+
} = options;
101+
102+
const client = getClient();
103+
104+
if (!client) {
105+
return;
106+
}
107+
108+
const scope = getCurrentScope();
109+
const previousActiveSpan = getActiveSpan();
110+
const _span = _startIdleSpan(startSpanOptions);
111+
112+
// Span _should_ always be defined here, but TS does not know that...
113+
if (!_span) {
114+
return;
115+
}
116+
117+
// For TS, so that we know everything below here has a span
118+
const span = _span;
119+
120+
/**
121+
* Cancels the existing idle timeout, if there is one.
122+
*/
123+
function _cancelIdleTimeout(): void {
124+
if (_idleTimeoutID) {
125+
clearTimeout(_idleTimeoutID);
126+
_idleTimeoutID = undefined;
127+
}
128+
}
129+
130+
/**
131+
* Cancels the existing child span timeout, if there is one.
132+
*/
133+
function _cancelChildSpanTimeout(): void {
134+
if (_childSpanTimeoutID) {
135+
clearTimeout(_childSpanTimeoutID);
136+
_childSpanTimeoutID = undefined;
137+
}
138+
}
139+
140+
/**
141+
* Restarts idle timeout, if there is no running idle timeout it will start one.
142+
*/
143+
function _restartIdleTimeout(endTimestamp?: number): void {
144+
_cancelIdleTimeout();
145+
_idleTimeoutID = setTimeout(() => {
146+
if (!_finished && activities.size === 0 && _autoFinishAllowed) {
147+
_finishReason = FINISH_REASON_IDLE_TIMEOUT;
148+
span.end(endTimestamp);
149+
}
150+
}, idleTimeout);
151+
}
152+
153+
/**
154+
* Restarts child span timeout, if there is none running it will start one.
155+
*/
156+
function _restartChildSpanTimeout(endTimestamp?: number): void {
157+
_cancelChildSpanTimeout();
158+
_idleTimeoutID = setTimeout(() => {
159+
if (!_finished && _autoFinishAllowed) {
160+
_finishReason = FINISH_REASON_HEARTBEAT_FAILED;
161+
span.end(endTimestamp);
162+
}
163+
}, childSpanTimeout);
164+
}
165+
166+
/**
167+
* Start tracking a specific activity.
168+
* @param spanId The span id that represents the activity
169+
*/
170+
function _pushActivity(spanId: string): void {
171+
_cancelIdleTimeout();
172+
activities.set(spanId, true);
173+
DEBUG_BUILD && logger.log(`[Tracing] pushActivity: ${spanId}`);
174+
DEBUG_BUILD && logger.log('[Tracing] new activities count', activities.size);
175+
176+
const endTimestamp = timestampInSeconds();
177+
// We need to add the timeout here to have the real endtimestamp of the idle span
178+
// Remember timestampInSeconds is in seconds, timeout is in ms
179+
_restartChildSpanTimeout(endTimestamp + childSpanTimeout / 1000);
180+
}
181+
182+
/**
183+
* Remove an activity from usage
184+
* @param spanId The span id that represents the activity
185+
*/
186+
function _popActivity(spanId: string): void {
187+
if (activities.has(spanId)) {
188+
DEBUG_BUILD && logger.log(`[Tracing] popActivity ${spanId}`);
189+
activities.delete(spanId);
190+
DEBUG_BUILD && logger.log('[Tracing] new activities count', activities.size);
191+
}
192+
193+
if (activities.size === 0) {
194+
const endTimestamp = timestampInSeconds();
195+
// We need to add the timeout here to have the real endtimestamp of the idle span
196+
// Remember timestampInSeconds is in seconds, timeout is in ms
197+
_restartIdleTimeout(endTimestamp + idleTimeout / 1000);
198+
_cancelChildSpanTimeout();
199+
}
200+
}
201+
202+
function endIdleSpan(): void {
203+
_finished = true;
204+
activities.clear();
205+
206+
if (beforeSpanEnd) {
207+
beforeSpanEnd(span);
208+
}
209+
210+
// eslint-disable-next-line deprecation/deprecation
211+
scope.setSpan(previousActiveSpan);
212+
213+
const spanJSON = spanToJSON(span);
214+
215+
const { timestamp: endTimestamp, start_timestamp: startTimestamp } = spanJSON;
216+
// This should never happen, but to make TS happy...
217+
if (!endTimestamp || !startTimestamp) {
218+
return;
219+
}
220+
221+
const attributes = spanJSON.data || {};
222+
if (spanJSON.op === 'ui.action.click' && !attributes[FINISH_REASON_TAG]) {
223+
span.setAttribute(FINISH_REASON_TAG, _finishReason);
224+
}
225+
226+
DEBUG_BUILD &&
227+
logger.log('[Tracing] finishing idle span', new Date(endTimestamp * 1000).toISOString(), spanJSON.op);
228+
229+
const childSpans = getSpanDescendants(span).filter(child => child !== span);
230+
231+
childSpans.forEach(childSpan => {
232+
// We cancel all pending spans with status "cancelled" to indicate the idle span was finished early
233+
if (childSpan.isRecording()) {
234+
childSpan.setStatus({ code: SPAN_STATUS_ERROR, message: 'cancelled' });
235+
childSpan.end(endTimestamp);
236+
DEBUG_BUILD &&
237+
logger.log('[Tracing] cancelling span since span ended early', JSON.stringify(childSpan, undefined, 2));
238+
}
239+
240+
const childSpanJSON = spanToJSON(childSpan);
241+
const { timestamp: childEndTimestamp = 0, start_timestamp: childStartTimestamp = 0 } = childSpanJSON;
242+
243+
const spanStartedBeforeIdleSpanEnd = childStartTimestamp <= endTimestamp;
244+
245+
// Add a delta with idle timeout so that we prevent false positives
246+
const timeoutWithMarginOfError = (finalTimeout + idleTimeout) / 1000;
247+
const spanEndedBeforeFinalTimeout = childEndTimestamp - childStartTimestamp < timeoutWithMarginOfError;
248+
249+
if (DEBUG_BUILD) {
250+
const stringifiedSpan = JSON.stringify(childSpan, undefined, 2);
251+
if (!spanStartedBeforeIdleSpanEnd) {
252+
logger.log('[Tracing] discarding Span since it happened after idle span was finished', stringifiedSpan);
253+
} else if (!spanEndedBeforeFinalTimeout) {
254+
logger.log('[Tracing] discarding Span since it finished after idle span final timeout', stringifiedSpan);
255+
}
256+
}
257+
258+
if (!spanEndedBeforeFinalTimeout || !spanStartedBeforeIdleSpanEnd) {
259+
removeChildSpanFromSpan(span, childSpan);
260+
}
261+
});
262+
263+
DEBUG_BUILD && logger.log('[Tracing] flushing idle span');
264+
}
265+
266+
client.on('spanStart', startedSpan => {
267+
// If we already finished the idle span,
268+
// or if this is the idle span itself being started,
269+
// or if the started span has already been closed,
270+
// we don't care about it for activity
271+
if (_finished || startedSpan === span || !!spanToJSON(startedSpan).timestamp) {
272+
return;
273+
}
274+
275+
const allSpans = getSpanDescendants(span);
276+
277+
// If the span that was just started is a child of the idle span, we should track it
278+
if (allSpans.includes(startedSpan)) {
279+
_pushActivity(startedSpan.spanContext().spanId);
280+
}
281+
});
282+
283+
client.on('spanEnd', endedSpan => {
284+
if (_finished) {
285+
return;
286+
}
287+
288+
_popActivity(endedSpan.spanContext().spanId);
289+
290+
if (endedSpan === span) {
291+
endIdleSpan();
292+
}
293+
});
294+
295+
client.on('idleSpanEnableAutoFinish', spanToAllowAutoFinish => {
296+
if (spanToAllowAutoFinish === span) {
297+
_autoFinishAllowed = true;
298+
_restartIdleTimeout();
299+
300+
if (activities.size) {
301+
_restartChildSpanTimeout();
302+
}
303+
}
304+
});
305+
306+
// We only start the initial idle timeout if we are not delaying the auto finish
307+
if (!options.disableAutoFinish) {
308+
_restartIdleTimeout();
309+
}
310+
311+
setTimeout(() => {
312+
if (!_finished) {
313+
span.setStatus({ code: SPAN_STATUS_ERROR, message: 'deadline_exceeded' });
314+
_finishReason = FINISH_REASON_FINAL_TIMEOUT;
315+
span.end();
316+
}
317+
}, finalTimeout);
318+
319+
return span;
320+
}
321+
322+
function _startIdleSpan(options: StartSpanOptions): Span | undefined {
323+
const span = startInactiveSpan(options);
324+
325+
// eslint-disable-next-line deprecation/deprecation
326+
getCurrentScope().setSpan(span);
327+
328+
if (span) {
329+
DEBUG_BUILD && logger.log(`Setting idle span on scope. Span ID: ${span.spanContext().spanId}`);
330+
}
331+
332+
return span;
333+
}

packages/core/src/tracing/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,5 @@ export {
1818
} from './trace';
1919
export { getDynamicSamplingContextFromClient, getDynamicSamplingContextFromSpan } from './dynamicSamplingContext';
2020
export { setMeasurement } from './measurement';
21+
22+
export { startIdleSpan } from './idleSpan';

0 commit comments

Comments
 (0)