Skip to content

Commit dbb0ea4

Browse files
committed
feat(core): Add startIdleSpan()
1 parent 2b2a467 commit dbb0ea4

File tree

6 files changed

+793
-0
lines changed

6 files changed

+793
-0
lines changed

packages/core/src/baseclient.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,9 @@ export abstract class BaseClient<O extends ClientOptions> implements Client<O> {
429429
/** @inheritdoc */
430430
public on(hook: 'spanEnd', callback: (span: Span) => void): void;
431431

432+
/** @inheritdoc */
433+
public on(hook: 'idleSpanEnableAutoFinish', callback: (span: Span) => void): void;
434+
432435
/** @inheritdoc */
433436
public on(hook: 'beforeEnvelope', callback: (envelope: Envelope) => void): void;
434437

@@ -485,6 +488,9 @@ export abstract class BaseClient<O extends ClientOptions> implements Client<O> {
485488
/** @inheritdoc */
486489
public emit(hook: 'spanEnd', span: Span): void;
487490

491+
/** @inheritdoc */
492+
public emit(hook: 'idleSpanEnableAutoFinish', span: Span): void;
493+
488494
/** @inheritdoc */
489495
public emit(hook: 'beforeEnvelope', envelope: Envelope): void;
490496

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, getSpanTree, 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 = getSpanTree(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 = getSpanTree(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';

packages/core/src/tracing/utils.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,13 @@ export function addChildSpanToSpan(span: SpanWithPotentialChildren, childSpan: S
4848
}
4949
}
5050

51+
/** This is only used internally by Idle Spans. */
52+
export function removeChildSpanFromSpan(span: SpanWithPotentialChildren, childSpan: Span): void {
53+
if (span[CHILD_SPANS_FIELD]) {
54+
span[CHILD_SPANS_FIELD].delete(childSpan);
55+
}
56+
}
57+
5158
/**
5259
* Obtains the entire span tree, meaning a span + all of its descendants for a particular span.
5360
*/

0 commit comments

Comments
 (0)