@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
JavaScript
"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