|
| 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 | +} |
0 commit comments