UNPKG

@zendesk/retrace

Version:

define and capture Product Operation Traces along with computed metrics with an optional friendly React beacon API

949 lines 72.3 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.Trace = exports.TraceStateMachine = exports.shouldPropagateChildInterruptToParent = exports.isEnteringTerminalState = exports.isTerminalState = exports.TERMINAL_STATES = void 0; const rxjs_1 = require("rxjs"); const constants_1 = require("./constants"); const ensureMatcherFn_1 = require("./ensureMatcherFn"); const ensureTimestamp_1 = require("./ensureTimestamp"); const firstCPUIdle_1 = require("./firstCPUIdle"); const getSpanKey_1 = require("./getSpanKey"); const matchSpan_1 = require("./matchSpan"); const recordingComputeUtils_1 = require("./recordingComputeUtils"); const spanTypes_1 = require("./spanTypes"); const types_1 = require("./types"); const validateRelatedTo_1 = require("./validateRelatedTo"); const isInvalidTraceInterruptionReason = (reason) => types_1.INVALID_TRACE_INTERRUPTION_REASONS.includes(reason); const INITIAL_STATE = 'draft'; exports.TERMINAL_STATES = ['interrupted', 'complete']; const isTerminalState = (state) => exports.TERMINAL_STATES.includes(state); exports.isTerminalState = isTerminalState; const isEnteringTerminalState = (onEnterState) => (0, exports.isTerminalState)(onEnterState.transitionToState); exports.isEnteringTerminalState = isEnteringTerminalState; const shouldPropagateChildInterruptToParent = (childTraceInterruptionReason) => !types_1.TRACE_REPLACE_INTERRUPTION_REASONS.includes(childTraceInterruptionReason); exports.shouldPropagateChildInterruptToParent = shouldPropagateChildInterruptToParent; class TraceStateMachine { constructor(context) { this.#context = context; this.emit('onEnterState', undefined); } successfullyMatchedRequiredSpanMatchers = new Set(); #context; get sideEffectFns() { return this.#context.sideEffectFns; } currentState = INITIAL_STATE; /** the span that ended at the furthest point in time */ lastRelevant; lastRequiredSpan; /** it is set once the LRS value is established */ completeSpan; cpuIdleLongTaskProcessor; #lastLongTaskEndTime; #debounceDeadline = Number.POSITIVE_INFINITY; #interactiveDeadline = Number.POSITIVE_INFINITY; #timeoutDeadline = Number.POSITIVE_INFINITY; nextDeadlineRef; setDeadline(deadlineType, deadlineEpoch) { if (deadlineType === 'debounce') { this.#debounceDeadline = deadlineEpoch; } else if (deadlineType === 'interactive') { this.#interactiveDeadline = deadlineEpoch; } // which type of deadline is the closest and what kind is it? const closestDeadline = deadlineEpoch > this.#timeoutDeadline ? 'global' : deadlineType === 'next-quiet-window' && deadlineEpoch > this.#interactiveDeadline ? 'interactive' : deadlineType; const rightNowEpoch = Date.now(); const timeToDeadlinePlusBuffer = deadlineEpoch - rightNowEpoch + constants_1.DEADLINE_BUFFER; if (this.nextDeadlineRef) { clearTimeout(this.nextDeadlineRef); } this.nextDeadlineRef = setTimeout(() => { this.emit('onDeadline', closestDeadline); }, Math.max(timeToDeadlinePlusBuffer, 0)); } setGlobalDeadline(deadline) { this.#timeoutDeadline = deadline; const rightNowEpoch = Date.now(); const timeToDeadlinePlusBuffer = deadline - rightNowEpoch + constants_1.DEADLINE_BUFFER; if (!this.nextDeadlineRef) { // this should never happen this.nextDeadlineRef = setTimeout(() => { this.emit('onDeadline', 'global'); }, Math.max(timeToDeadlinePlusBuffer, 0)); } } clearDeadline() { if (this.nextDeadlineRef) { clearTimeout(this.nextDeadlineRef); this.nextDeadlineRef = undefined; } } /** * while debouncing, we need to buffer any spans that come in so they can be re-processed * once we transition to the 'waiting-for-interactive' state * otherwise we might miss out on spans that are relevant to calculating the interactive * * if we have long tasks before FMP, we want to use them as a potential grouping post FMP. */ debouncingSpanBuffer = []; #draftBuffer = []; // eslint-disable-next-line consistent-return #processDraftBuffer() { // process items in the buffer (stick the relatedTo in the entries) (if its empty, well we can skip this!) let span; // eslint-disable-next-line no-cond-assign while ((span = this.#draftBuffer.shift())) { const transition = this.emit('onProcessSpan', span, true); if (transition) return transition; } } states = { draft: { onEnterState: () => { this.setGlobalDeadline(this.#context.input.startTime.epoch + this.#context.definition.variants[this.#context.input.variant] .timeout); }, onMakeActive: () => ({ transitionToState: 'active', }), onProcessSpan: (spanAndAnnotation) => { const spanEndTimeEpoch = spanAndAnnotation.span.startTime.epoch + spanAndAnnotation.span.duration; if ((0, firstCPUIdle_1.isLongTask)(spanAndAnnotation.span.performanceEntry) && spanEndTimeEpoch > (this.#lastLongTaskEndTime ?? 0)) { this.#lastLongTaskEndTime = spanEndTimeEpoch; } if (spanEndTimeEpoch > this.#timeoutDeadline) { // we consider this interrupted, because of the clamping of the total duration of the operation // as potential other events could have happened and prolonged the operation // we can be a little picky, because we expect to record many operations // it's best to compare like-to-like return { transitionToState: 'interrupted', interruption: { reason: 'timeout' }, lastRelevantSpanAndAnnotation: undefined, }; } // add into span buffer this.#draftBuffer.push(spanAndAnnotation); // if the entry matches any of the interruptOnSpans criteria, // transition to interrupted state with the correct interruptionReason if (this.#context.definition.interruptOnSpans) { for (const doesSpanMatch of this.#context.definition .interruptOnSpans) { if (doesSpanMatch(spanAndAnnotation, this.#context)) { return { transitionToState: 'interrupted', interruption: { reason: doesSpanMatch.requiredSpan ? 'matched-on-required-span-with-error' : 'matched-on-interrupt', }, lastRelevantSpanAndAnnotation: undefined, }; } } } return undefined; }, onInterrupt: (reasonPayload) => ({ transitionToState: 'interrupted', interruption: reasonPayload, lastRelevantSpanAndAnnotation: undefined, }), onDeadline: (deadlineType) => { if (deadlineType === 'global') { return { transitionToState: 'interrupted', interruption: { reason: 'timeout' }, lastRelevantSpanAndAnnotation: undefined, }; } // other cases should never happen return undefined; }, onChildEnd: (event) => { // Check if child was interrupted and handle accordingly if (event.terminalState === 'interrupted' && event.interruption) { if (!(0, exports.shouldPropagateChildInterruptToParent)(event.interruption.reason)) { // no transition - ignore child interruption return undefined; } // Interrupt parent based on child interruption const parentInterruptionReason = event.interruption.reason === 'timeout' ? 'child-timeout' : 'child-interrupted'; return { transitionToState: 'interrupted', interruption: { reason: parentInterruptionReason }, lastRelevantSpanAndAnnotation: undefined, }; } return undefined; }, }, active: { onEnterState: (_transition) => { const nextTransition = this.#processDraftBuffer(); if (nextTransition) return nextTransition; return undefined; }, onProcessSpan: (spanAndAnnotation) => { const spanEndTimeEpoch = spanAndAnnotation.span.startTime.epoch + spanAndAnnotation.span.duration; if ((0, firstCPUIdle_1.isLongTask)(spanAndAnnotation.span.performanceEntry) && spanEndTimeEpoch > (this.#lastLongTaskEndTime ?? 0)) { this.#lastLongTaskEndTime = spanEndTimeEpoch; } if (spanEndTimeEpoch > this.#timeoutDeadline) { // we consider this interrupted, because of the clamping of the total duration of the operation // as potential other events could have happened and prolonged the operation // we can be a little picky, because we expect to record many operations // it's best to compare like-to-like return { transitionToState: 'interrupted', interruption: { reason: 'timeout' }, lastRelevantSpanAndAnnotation: this.lastRelevant, }; } // does span satisfy any of the "interruptOnSpans" definitions if (this.#context.definition.interruptOnSpans) { for (const doesSpanMatch of this.#context.definition .interruptOnSpans) { if (doesSpanMatch(spanAndAnnotation, this.#context)) { // still record the span that interrupted the trace this.sideEffectFns.addSpanToRecording(spanAndAnnotation); // relevant because it caused the interruption this.lastRelevant = spanAndAnnotation; return { transitionToState: 'interrupted', interruption: { reason: doesSpanMatch.requiredSpan ? 'matched-on-required-span-with-error' : 'matched-on-interrupt', }, lastRelevantSpanAndAnnotation: this.lastRelevant, }; } } } for (const doesSpanMatch of this.#context.definition.requiredSpans) { if (this.successfullyMatchedRequiredSpanMatchers.has(doesSpanMatch)) { // we previously successfully matched using this matcher // eslint-disable-next-line no-continue continue; } if (doesSpanMatch(spanAndAnnotation, this.#context)) { // now that we've seen it, we add it to the list this.successfullyMatchedRequiredSpanMatchers.add(doesSpanMatch); // Emit required span seen event for debugging this.#context.eventSubjects['required-span-seen'].next({ traceContext: this.#context, spanAndAnnotation, matcher: doesSpanMatch, }); // Sometimes spans are processed out of order, we update the lastRelevant if this span ends later if (!this.lastRelevant || spanAndAnnotation.annotation.operationRelativeEndTime > (this.lastRelevant?.annotation.operationRelativeEndTime ?? 0)) { this.lastRelevant = spanAndAnnotation; } } } this.sideEffectFns.addSpanToRecording(spanAndAnnotation); if (this.successfullyMatchedRequiredSpanMatchers.size === this.#context.definition.requiredSpans.length) { return { transitionToState: 'debouncing' }; } return undefined; }, onInterrupt: (reasonPayload) => ({ transitionToState: 'interrupted', interruption: reasonPayload, lastRelevantSpanAndAnnotation: this.lastRelevant, }), onDeadline: (deadlineType) => { if (deadlineType === 'global') { return { transitionToState: 'interrupted', interruption: { reason: 'timeout' }, lastRelevantSpanAndAnnotation: this.lastRelevant, }; } // other cases should never happen return undefined; }, onChildEnd: (event) => { // Check if child was interrupted and handle accordingly if (event.terminalState === 'interrupted' && event.interruption) { if (!(0, exports.shouldPropagateChildInterruptToParent)(event.interruption.reason)) { // no transition - ignore child interruption return undefined; } // Interrupt parent based on child interruption const parentInterruptionReason = event.interruption.reason === 'timeout' ? 'child-timeout' : 'child-interrupted'; return { transitionToState: 'interrupted', interruption: { reason: parentInterruptionReason }, lastRelevantSpanAndAnnotation: this.lastRelevant, }; } return undefined; }, }, // we enter the debouncing state once all requiredSpans entries have been seen // it is necessary due to the nature of React rendering, // as even once we reach the visually complete state of a component, // the component might continue to re-render // and change the final visual output of the component // we want to ensure the end of the operation captures // the final, settled state of the component debouncing: { onEnterState: (_payload) => { if (!this.lastRelevant) { // this should never happen return { transitionToState: 'interrupted', interruption: { reason: 'invalid-state-transition' }, lastRelevantSpanAndAnnotation: this.lastRelevant, }; } this.lastRequiredSpan = this.lastRelevant; this.lastRequiredSpan.annotation.markedRequirementsMet = true; if (!this.#context.definition.debounceOnSpans) { return { transitionToState: 'waiting-for-interactive' }; } // set the first debounce deadline this.setDeadline('debounce', this.lastRelevant.span.startTime.epoch + this.lastRelevant.span.duration + (this.#context.definition.debounceWindow ?? constants_1.DEFAULT_DEBOUNCE_DURATION)); return undefined; }, onDeadline: (deadlineType) => { if (deadlineType === 'global') { return { transitionToState: 'interrupted', interruption: { reason: 'timeout' }, lastRelevantSpanAndAnnotation: this.lastRelevant, }; } if (deadlineType === 'debounce') { // Check if we have children before transitioning to complete if (this.#context.children.size > 0) { return { transitionToState: 'waiting-for-children', }; } return { transitionToState: 'waiting-for-interactive', }; } // other cases should never happen return undefined; }, onProcessSpan: (spanAndAnnotation) => { const spanEndTimeEpoch = spanAndAnnotation.span.startTime.epoch + spanAndAnnotation.span.duration; if ((0, firstCPUIdle_1.isLongTask)(spanAndAnnotation.span.performanceEntry) && spanEndTimeEpoch > (this.#lastLongTaskEndTime ?? 0)) { this.#lastLongTaskEndTime = spanEndTimeEpoch; } if (spanEndTimeEpoch > this.#timeoutDeadline) { // we consider this interrupted, because of the clamping of the total duration of the operation // as potential other events could have happened and prolonged the operation // we can be a little picky, because we expect to record many operations // it's best to compare like-to-like return { transitionToState: 'interrupted', interruption: { reason: 'timeout' }, lastRelevantSpanAndAnnotation: this.lastRelevant, }; } // does span satisfy any of the "interruptOnSpans" definitions if (this.#context.definition.interruptOnSpans) { for (const doesSpanMatch of this.#context.definition .interruptOnSpans) { if (doesSpanMatch(spanAndAnnotation, this.#context)) { // still record the span that interrupted the trace this.sideEffectFns.addSpanToRecording(spanAndAnnotation); // relevant because it caused the interruption // this might be a little controversial since we don't know // if we would have seen a required span after // after all we're already debouncing... // but for simplicity the assumption is that if we see a span that matches the interruptOnSpans, // the trace should still be considered as interrupted this.lastRelevant = spanAndAnnotation; return { transitionToState: 'interrupted', interruption: { reason: doesSpanMatch.requiredSpan ? 'matched-on-required-span-with-error' : 'matched-on-interrupt', }, lastRelevantSpanAndAnnotation: this.lastRelevant, }; } } } // The debouncing buffer will be used to correctly group the spans into clusters when calculating the cpu idle in the waiting-for-interactive state // We record the spans here as well, so that they are included even if we never make it out of the debouncing state this.debouncingSpanBuffer.push(spanAndAnnotation); this.sideEffectFns.addSpanToRecording(spanAndAnnotation); if (spanEndTimeEpoch > this.#debounceDeadline) { // done debouncing return { transitionToState: 'waiting-for-interactive' }; } const { span } = spanAndAnnotation; // even though we satisfied all the requiredSpans conditions in the recording state, // if we see a previously required render span that was requested to be idle, but is no longer idle, // our trace is deemed invalid and should be interrupted const isSpanNonIdleRender = 'isIdle' in span && !span.isIdle; // we want to match on all the conditions except for the "isIdle: true" // for this reason we have to pretend to the matcher about "isIdle" or else our matcher condition would never evaluate to true const idleRegressionCheckSpan = isSpanNonIdleRender && { ...spanAndAnnotation, span: { ...span, isIdle: true }, }; if (idleRegressionCheckSpan) { for (const doesSpanMatch of this.#context.definition.requiredSpans) { if (doesSpanMatch(idleRegressionCheckSpan, this.#context) && doesSpanMatch.idleCheck) { // Sometimes spans are processed out of order, we update the lastRelevant if this span ends later if (spanAndAnnotation.annotation.operationRelativeEndTime > (this.lastRelevant?.annotation.operationRelativeEndTime ?? 0)) { this.lastRelevant = spanAndAnnotation; } // check if we regressed on "isIdle", and if so, transition to interrupted with reason return { transitionToState: 'interrupted', interruption: { reason: 'idle-component-no-longer-idle' }, lastRelevantSpanAndAnnotation: this.lastRelevant, }; } } } // does span satisfy any of the "debouncedOn" and if so, restart our debounce timer if (this.#context.definition.debounceOnSpans) { for (const doesSpanMatch of this.#context.definition .debounceOnSpans) { if (doesSpanMatch(spanAndAnnotation, this.#context)) { // Sometimes spans are processed out of order, we update the lastRelevant if this span ends later if (spanAndAnnotation.annotation.operationRelativeEndTime > (this.lastRelevant?.annotation.operationRelativeEndTime ?? 0)) { this.lastRelevant = spanAndAnnotation; // update the debounce timer relative from the time of the span end // (not from the time of processing of the event, because it may be asynchronous) this.setDeadline('debounce', this.lastRelevant.span.startTime.epoch + this.lastRelevant.span.duration + (this.#context.definition.debounceWindow ?? constants_1.DEFAULT_DEBOUNCE_DURATION)); } return undefined; } } } return undefined; }, onInterrupt: (reasonPayload) => ({ transitionToState: 'interrupted', interruption: reasonPayload, lastRelevantSpanAndAnnotation: this.lastRelevant, }), onChildEnd: (event) => { // Check if child was interrupted and handle accordingly if (event.terminalState === 'interrupted' && event.interruption) { if (!(0, exports.shouldPropagateChildInterruptToParent)(event.interruption.reason)) { // no transition - ignore child interruption return undefined; } // Interrupt parent based on child interruption const parentInterruptionReason = event.interruption.reason === 'timeout' ? 'child-timeout' : 'child-interrupted'; return { transitionToState: 'interrupted', interruption: { reason: parentInterruptionReason }, lastRelevantSpanAndAnnotation: this.lastRelevant, }; } return undefined; }, }, 'waiting-for-interactive': { onEnterState: (_payload) => { if (!this.lastRelevant) { // this should never happen return { transitionToState: 'interrupted', interruption: { reason: 'invalid-state-transition' }, lastRelevantSpanAndAnnotation: this.lastRelevant, }; } this.completeSpan = this.lastRelevant; const interactiveConfig = this.#context.definition.captureInteractive; if (!interactiveConfig) { // nothing to do in this state, check if we have children if (this.#context.children.size > 0) { return { transitionToState: 'waiting-for-children', }; } return { transitionToState: 'complete', completeSpanAndAnnotation: this.completeSpan, cpuIdleSpanAndAnnotation: undefined, lastRequiredSpanAndAnnotation: this.lastRequiredSpan, lastRelevantSpanAndAnnotation: this.lastRelevant, }; } const interruptMillisecondsAfterLastRequiredSpan = (typeof interactiveConfig === 'object' && interactiveConfig.timeout) || constants_1.DEFAULT_INTERACTIVE_TIMEOUT_DURATION; const lastRequiredSpanEndTimeEpoch = this.completeSpan.span.startTime.epoch + this.completeSpan.span.duration; this.setDeadline('interactive', lastRequiredSpanEndTimeEpoch + interruptMillisecondsAfterLastRequiredSpan); this.cpuIdleLongTaskProcessor = (0, firstCPUIdle_1.createCPUIdleProcessor)({ entryType: this.completeSpan.span.type, startTime: this.completeSpan.span.startTime.now, duration: this.completeSpan.span.duration, entry: this.completeSpan, }, typeof interactiveConfig === 'object' ? interactiveConfig : {}, { lastLongTaskEndTime: this.#lastLongTaskEndTime }); // DECISION: sort the buffer before processing. sorted by end time (spans that end first should be processed first) this.debouncingSpanBuffer.sort((a, b) => a.span.startTime.now + a.span.duration - (b.span.startTime.now + b.span.duration)); // process any spans that were buffered during the debouncing phase while (this.debouncingSpanBuffer.length > 0) { const span = this.debouncingSpanBuffer.shift(); const transition = this.emit('onProcessSpan', span, true); if (transition) { return transition; } } return undefined; }, onDeadline: (deadlineType) => { if (deadlineType === 'global') { // a global timeout will interrupt any children traces return { transitionToState: 'complete', interruption: { reason: 'timeout' }, completeSpanAndAnnotation: this.completeSpan, lastRequiredSpanAndAnnotation: this.lastRequiredSpan, lastRelevantSpanAndAnnotation: this.lastRelevant, cpuIdleSpanAndAnnotation: undefined, }; } if (deadlineType === 'interactive' || deadlineType === 'next-quiet-window') { const quietWindowCheck = this.cpuIdleLongTaskProcessor.checkIfQuietWindowPassed(performance.now()); const cpuIdleMatch = 'firstCpuIdle' in quietWindowCheck && quietWindowCheck.firstCpuIdle; const cpuIdleTimestamp = cpuIdleMatch && cpuIdleMatch.entry.span.startTime.epoch + cpuIdleMatch.entry.span.duration; if (cpuIdleTimestamp && cpuIdleTimestamp <= this.#timeoutDeadline) { // if we match the interactive criteria, transition to complete // reference https://docs.google.com/document/d/1GGiI9-7KeY3TPqS3YT271upUVimo-XiL5mwWorDUD4c/edit return { transitionToState: 'complete', lastRequiredSpanAndAnnotation: this.lastRequiredSpan, completeSpanAndAnnotation: this.completeSpan, cpuIdleSpanAndAnnotation: cpuIdleMatch.entry, lastRelevantSpanAndAnnotation: this.lastRelevant, }; } if (deadlineType === 'interactive') { // we consider this complete, because we have a complete trace // it's just missing the bonus data from when the browser became "interactive" return { interruption: { reason: 'timeout' }, transitionToState: 'complete', lastRequiredSpanAndAnnotation: this.lastRequiredSpan, completeSpanAndAnnotation: this.completeSpan, lastRelevantSpanAndAnnotation: this.lastRelevant, cpuIdleSpanAndAnnotation: undefined, }; } if ('nextCheck' in quietWindowCheck) { // check in the next quiet window const nextCheckIn = quietWindowCheck.nextCheck - performance.now(); this.setDeadline('next-quiet-window', Date.now() + nextCheckIn); } } // other cases should never happen return undefined; }, onProcessSpan: (spanAndAnnotation) => { this.sideEffectFns.addSpanToRecording(spanAndAnnotation); const quietWindowCheck = this.cpuIdleLongTaskProcessor.processPerformanceEntry({ entryType: spanAndAnnotation.span.type, startTime: spanAndAnnotation.span.startTime.now, duration: spanAndAnnotation.span.duration, entry: spanAndAnnotation, }); const cpuIdleMatch = 'firstCpuIdle' in quietWindowCheck && quietWindowCheck.firstCpuIdle; const cpuIdleTimestamp = cpuIdleMatch && cpuIdleMatch.entry.span.startTime.epoch + cpuIdleMatch.entry.span.duration; if (cpuIdleTimestamp && cpuIdleTimestamp <= this.#timeoutDeadline) { // check if we have children if (this.#context.children.size > 0) { return { transitionToState: 'waiting-for-children', }; } // if we match the interactive criteria, transition to complete // reference https://docs.google.com/document/d/1GGiI9-7KeY3TPqS3YT271upUVimo-XiL5mwWorDUD4c/edit return { transitionToState: 'complete', lastRequiredSpanAndAnnotation: this.lastRequiredSpan, completeSpanAndAnnotation: this.completeSpan, cpuIdleSpanAndAnnotation: cpuIdleMatch.entry, lastRelevantSpanAndAnnotation: this.lastRelevant, }; } const spanEndTimeEpoch = spanAndAnnotation.span.startTime.epoch + spanAndAnnotation.span.duration; if (spanEndTimeEpoch > this.#timeoutDeadline) { // we consider this complete, but check if we have children if (this.#context.children.size > 0) { return { transitionToState: 'waiting-for-children', interruptionReason: { reason: 'timeout' }, }; } // we consider this complete, because we have a complete trace // it's just missing the bonus data from when the browser became "interactive" return { transitionToState: 'complete', interruption: { reason: 'timeout' }, lastRequiredSpanAndAnnotation: this.lastRequiredSpan, completeSpanAndAnnotation: this.completeSpan, lastRelevantSpanAndAnnotation: this.lastRelevant, cpuIdleSpanAndAnnotation: undefined, }; } if (spanEndTimeEpoch > this.#interactiveDeadline) { // check if we have children if (this.#context.children.size > 0) { return { transitionToState: 'waiting-for-children', interruptionReason: { reason: 'waiting-for-interactive-timeout' }, }; } // we consider this complete, because we have a complete trace // it's just missing the bonus data from when the browser became "interactive" return { transitionToState: 'complete', interruption: { reason: 'waiting-for-interactive-timeout' }, lastRequiredSpanAndAnnotation: this.lastRequiredSpan, completeSpanAndAnnotation: this.completeSpan, lastRelevantSpanAndAnnotation: this.lastRelevant, cpuIdleSpanAndAnnotation: undefined, }; } // if the entry matches any of the interruptOnSpans criteria, // transition to complete state with the 'matched-on-interrupt' interruptionReason if (this.#context.definition.interruptOnSpans) { for (const doesSpanMatch of this.#context.definition .interruptOnSpans) { if (doesSpanMatch(spanAndAnnotation, this.#context)) { // Check if we have children before transitioning to complete if (this.#context.children.size > 0) { return { transitionToState: 'waiting-for-children', interruptionReason: doesSpanMatch.requiredSpan ? { reason: 'matched-on-required-span-with-error' } : { reason: 'matched-on-interrupt' }, }; } return { transitionToState: 'complete', interruption: doesSpanMatch.requiredSpan ? { reason: 'matched-on-required-span-with-error' } : { reason: 'matched-on-interrupt' }, lastRequiredSpanAndAnnotation: this.lastRequiredSpan, completeSpanAndAnnotation: this.completeSpan, lastRelevantSpanAndAnnotation: this.lastRelevant, cpuIdleSpanAndAnnotation: undefined, }; } } } if ('nextCheck' in quietWindowCheck) { // check in the next quiet window const nextCheckIn = quietWindowCheck.nextCheck - performance.now(); this.setDeadline('next-quiet-window', Date.now() + nextCheckIn); } return undefined; }, onInterrupt: (reasonPayload) => // we captured a complete trace, however the interactive data is missing ({ transitionToState: 'complete', interruption: reasonPayload, lastRequiredSpanAndAnnotation: this.lastRequiredSpan, completeSpanAndAnnotation: this.completeSpan, lastRelevantSpanAndAnnotation: this.lastRelevant, cpuIdleSpanAndAnnotation: undefined, }), onChildEnd: (event) => { // Check if child was interrupted and handle accordingly if (event.terminalState === 'interrupted' && event.interruption) { if (!(0, exports.shouldPropagateChildInterruptToParent)(event.interruption.reason)) { // no transition - ignore child interruption return undefined; } // Interrupt parent based on child interruption const parentInterruptionReason = event.interruption.reason === 'timeout' ? 'child-timeout' : 'child-interrupted'; return { transitionToState: 'interrupted', interruption: { reason: parentInterruptionReason }, lastRelevantSpanAndAnnotation: this.lastRelevant, }; } return undefined; }, }, 'waiting-for-children': { onEnterState: (_payload) => { // If we have no children, transition to complete immediately if (this.#context.children.size === 0) { return { transitionToState: 'complete', completeSpanAndAnnotation: this.completeSpan, cpuIdleSpanAndAnnotation: undefined, lastRequiredSpanAndAnnotation: this.lastRequiredSpan, lastRelevantSpanAndAnnotation: this.lastRelevant, }; } // Otherwise, wait for children to complete return undefined; }, onChildEnd: (event) => { // Check if child was interrupted and handle accordingly if (event.terminalState === 'interrupted' && event.interruption) { if (!(0, exports.shouldPropagateChildInterruptToParent)(event.interruption.reason)) { // no transition - ignore child interruption return undefined; } // Interrupt parent based on child interruption const parentInterruptionReason = event.interruption.reason === 'timeout' ? 'child-timeout' : 'child-interrupted'; return { transitionToState: 'interrupted', interruption: { reason: parentInterruptionReason }, lastRelevantSpanAndAnnotation: this.lastRelevant, }; } // If all children are done, transition to complete if (this.#context.children.size === 0) { return { transitionToState: 'complete', completeSpanAndAnnotation: this.completeSpan, cpuIdleSpanAndAnnotation: undefined, lastRequiredSpanAndAnnotation: this.lastRequiredSpan, lastRelevantSpanAndAnnotation: this.lastRelevant, }; } return undefined; }, onProcessSpan: (spanAndAnnotation) => { this.sideEffectFns.addSpanToRecording(spanAndAnnotation); return undefined; }, onInterrupt: (reasonPayload) => ({ transitionToState: 'interrupted', interruption: reasonPayload, lastRelevantSpanAndAnnotation: this.lastRelevant, }), onDeadline: (deadlineType) => { if (deadlineType === 'global') { return { transitionToState: 'interrupted', interruption: { reason: 'timeout' }, lastRelevantSpanAndAnnotation: this.lastRelevant, }; } return undefined; }, }, // terminal states: interrupted: { onEnterState: (transition) => { // depending on the reason, if we're coming from draft, we want to flush the buffer: if (transition.transitionFromState === 'draft' && !isInvalidTraceInterruptionReason(transition.interruption.reason)) { let span; // eslint-disable-next-line no-cond-assign while ((span = this.#draftBuffer.shift())) { this.sideEffectFns.addSpanToRecording(span); } } }, }, complete: { onEnterState: (transition) => { const { completeSpanAndAnnotation, cpuIdleSpanAndAnnotation } = transition; // Tag the span annotations: if (completeSpanAndAnnotation) { // mutate the annotation to mark the span as complete completeSpanAndAnnotation.annotation.markedComplete = true; } if (cpuIdleSpanAndAnnotation) { // mutate the annotation to mark the span as interactive cpuIdleSpanAndAnnotation.annotation.markedPageInteractive = true; } }, }, }; /** * @returns the last OnEnterState event if a transition was made */ emit(event, payload, /** if called recursively inside of an event handler, it must be set to true to avoid double handling of terminal state */ internal = false) { const currentStateHandlers = this.states[this.currentState]; const transitionPayload = currentStateHandlers[event]?.(payload); if (transitionPayload) { const transitionFromState = this.currentState; this.currentState = transitionPayload.transitionToState; const onEnterStateEvent = { ...transitionPayload, transitionFromState, }; const settledTransition = this.emit('onEnterState', onEnterStateEvent, true) ?? onEnterStateEvent; // Emit state transition event this.#context.eventSubjects['state-transition'].next({ traceContext: this.#context, stateTransition: settledTransition === onEnterStateEvent ? onEnterStateEvent : { ...settledTransition, transitionFromState, }, }); // Complete all event observables when reaching a terminal state if (!internal && (0, exports.isEnteringTerminalState)(settledTransition)) { this.clearDeadline(); this.#context.sideEffectFns.onTerminalStateReached(settledTransition); } return settledTransition; } return undefined; } } exports.TraceStateMachine = TraceStateMachine; class Trace { sourceDefinition; /** the source-of-truth - local copy of a final, mutable definition of this specific trace */ definition; wasActivated = false; get activeInput() { if (!this.input.relatedTo) { this.traceUtilities.reportErrorFn(new Error("Tried to access trace's activeInput, but the trace was never provided a 'relatedTo' input value"), // eslint-disable-next-line @typescript-eslint/no-explicit-any this); } return this.input; } set activeInput(value) { this.input = value; } wasReplaced = false; input; traceUtilities; get isDraft() { return this.stateMachine.currentState === INITIAL_STATE; } recordedItems = new Map(); occurrenceCounters = new Map(); processedPerformanceEntries = new WeakMap(); persistedDefinitionModifications = new Set(); recordedItemsByLabel; stateMachine; // Child trace management children = new Set(); terminalStateChildren = new Set(); // Child trace management methods adoptChild(childTrace) { if (childTrace.wasReplaced) { // If the child trace was replaced, we should not adopt it return; } // Add child to the children set this.children.add(childTrace); // update the child trace's parent reference // eslint-disable-next-line no-param-reassign childTrace.traceUtilities.parentTraceRef = this; } onChildEnd(childTrace, stateTransition, traceRecording) { // Remove child from active children this.children.delete(childTrace); this.terminalStateChildren.add(childTrace); const terminalState = stateTransition.transitionToState; const interruptionReason = terminalState === 'interrupted' && 'interruption' in stateTransition ? stateTransition.interruption : undefined; if (typeof traceRecording?.duration === 'number' && traceRecording.status !== 'interrupted') { const { entries: _, ...childOperationSpan } = traceRecording; // TODO: should this child operation span be sent just this Trace (parent), or to TraceManager globally? // if to globally, then maybe instead of here, we should just emit this as a span after any trace ends? this.processSpan({ ...childOperationSpan, // these below just to satisfy TS, they're already in ...childRecording: duration: traceRecording.duration, status: traceRecording.status, relatedTo: { ...traceRecording.relatedTo }, getParentSpan: () => undefined, }); } // Notify the state machine about the child end this.stateMachine.emit('onChildEnd', { childTrace, terminalState, interruption: interruptionReason, }); } // Method to check if this trace can adopt a child with the given name canAdoptChild(childTraceName) { return this.definition.adoptAsChildren?.includes(childTraceName) ?? false; } // debugging observables eventSubjects = { 'state-transition': new rxjs_1.Subject(), 'required-span-seen': new rxjs_1.Subject(), 'add-span-to-recording': new rxjs_1.Subject(), 'definition-modified': new rxjs_1.Subject(), }; when(event) { return this.eventSubjects[event].asObservable(); } constructor(data) { const { input, traceUtilities, definition, definitionModifications } = 'importFrom' in data ? { input: data.importFrom.input, traceUtilities: data.importFrom.traceUtilities, // we use the sourceDefinition and we will re-apply all // subsequent modifications to it later in the constructor definition: data.importFrom.sourceDefinition, definitionModifications: data.definitionModifications, } : data; this.traceUtilities = traceUtilitie