UNPKG

@zendesk/retrace

Version:

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

1,411 lines (1,268 loc) 78.1 kB
/* eslint-disable @typescript-eslint/consistent-indexed-object-style */ /* eslint-disable max-classes-per-file */ import type { Observable } from 'rxjs' import { Subject } from 'rxjs' import { DEADLINE_BUFFER, DEFAULT_DEBOUNCE_DURATION, DEFAULT_INTERACTIVE_TIMEOUT_DURATION, } from './constants' import type { AddSpanToRecordingEvent, DefinitionModifiedEvent, RequiredSpanSeenEvent, StateTransitionEvent, } from './debugTypes' import { convertMatchersToFns } from './ensureMatcherFn' import { ensureTimestamp } from './ensureTimestamp' import { type CPUIdleLongTaskProcessor, createCPUIdleProcessor, isLongTask, type PerformanceEntryLike, } from './firstCPUIdle' import { getSpanKey } from './getSpanKey' import { requiredSpanWithErrorStatus, type SpanMatcherFn, withAllConditions, } from './matchSpan' import { createTraceRecording } from './recordingComputeUtils' import type { SpanAndAnnotation, SpanAnnotation, SpanAnnotationRecord, } from './spanAnnotationTypes' import { type ActiveTraceConfig, type DraftTraceInput, PARENT_SPAN, type Span, } from './spanTypes' import type { TraceRecording } from './traceRecordingTypes' import type { CompleteTraceDefinition, DraftTraceContext, InterruptionReasonPayload, RelationSchemasBase, ReportErrorFn, TraceContext, TraceDefinitionModifications, TraceInterruptionReason, TraceInterruptionReasonForInvalidTraces, TraceModifications, TraceUtilities, TransitionDraftOptions, } from './types' import { INVALID_TRACE_INTERRUPTION_REASONS, TRACE_REPLACE_INTERRUPTION_REASONS, } from './types' import type { DistributiveOmit, MergedStateHandlerMethods, StateHandlerPayloads, } from './typeUtils' import { validateAndCoerceRelatedToAgainstSchema } from './validateRelatedTo' const isInvalidTraceInterruptionReason = ( reason: TraceInterruptionReason, ): reason is TraceInterruptionReasonForInvalidTraces => ( INVALID_TRACE_INTERRUPTION_REASONS as readonly TraceInterruptionReason[] ).includes(reason) const INITIAL_STATE = 'draft' type InitialTraceState = typeof INITIAL_STATE export type NonTerminalTraceStates = | InitialTraceState | 'active' | 'debouncing' | 'waiting-for-interactive' | 'waiting-for-children' export const TERMINAL_STATES = ['interrupted', 'complete'] as const type TerminalTraceStates = (typeof TERMINAL_STATES)[number] export type TraceStates = NonTerminalTraceStates | TerminalTraceStates export const isTerminalState = ( state: TraceStates, ): state is TerminalTraceStates => (TERMINAL_STATES as readonly TraceStates[]).includes(state) export const isEnteringTerminalState = < RelationSchemasT extends RelationSchemasBase<RelationSchemasT>, >( onEnterState: OnEnterStatePayload<RelationSchemasT>, ): onEnterState is FinalTransition<RelationSchemasT> => isTerminalState(onEnterState.transitionToState) export const shouldPropagateChildInterruptToParent = ( childTraceInterruptionReason: TraceInterruptionReason, ) => !( TRACE_REPLACE_INTERRUPTION_REASONS as readonly TraceInterruptionReason[] ).includes(childTraceInterruptionReason) interface OnEnterActive { transitionToState: 'active' transitionFromState: NonTerminalTraceStates } interface OnEnterInterrupted< RelationSchemasT extends RelationSchemasBase<RelationSchemasT>, > { transitionToState: 'interrupted' transitionFromState: NonTerminalTraceStates interruption: InterruptionReasonPayload<RelationSchemasT> lastRelevantSpanAndAnnotation: SpanAndAnnotation<RelationSchemasT> | undefined } interface OnEnterComplete< RelationSchemasT extends RelationSchemasBase<RelationSchemasT>, > { transitionToState: 'complete' transitionFromState: NonTerminalTraceStates interruption?: InterruptionReasonPayload<RelationSchemasT> cpuIdleSpanAndAnnotation: SpanAndAnnotation<RelationSchemasT> | undefined completeSpanAndAnnotation: SpanAndAnnotation<RelationSchemasT> | undefined lastRequiredSpanAndAnnotation: SpanAndAnnotation<RelationSchemasT> | undefined lastRelevantSpanAndAnnotation: SpanAndAnnotation<RelationSchemasT> | undefined } export type FinalTransition< RelationSchemasT extends RelationSchemasBase<RelationSchemasT>, > = OnEnterInterrupted<RelationSchemasT> | OnEnterComplete<RelationSchemasT> interface OnEnterWaitingForInteractive { transitionToState: 'waiting-for-interactive' transitionFromState: NonTerminalTraceStates } interface OnEnterWaitingForChildren { transitionToState: 'waiting-for-children' transitionFromState: NonTerminalTraceStates } interface OnEnterDebouncing { transitionToState: 'debouncing' transitionFromState: NonTerminalTraceStates } export type OnEnterStatePayload< RelationSchemasT extends RelationSchemasBase<RelationSchemasT>, > = | OnEnterActive | OnEnterInterrupted<RelationSchemasT> | OnEnterComplete<RelationSchemasT> | OnEnterDebouncing | OnEnterWaitingForInteractive | OnEnterWaitingForChildren export type Transition< RelationSchemasT extends RelationSchemasBase<RelationSchemasT>, > = DistributiveOmit< OnEnterStatePayload<RelationSchemasT>, 'transitionFromState' > export type States< SelectedRelationNameT extends keyof RelationSchemasT, RelationSchemasT extends RelationSchemasBase<RelationSchemasT>, VariantsT extends string, > = TraceStateMachine< SelectedRelationNameT, RelationSchemasT, VariantsT >['states'] interface StateHandlersBase< RelationSchemasT extends RelationSchemasBase<RelationSchemasT>, > { [handler: string]: ( // eslint-disable-next-line @typescript-eslint/no-explicit-any payload: any, ) => | void | undefined | (Transition<RelationSchemasT> & { transitionFromState?: never }) } interface ChildEndEvent< RelationSchemasT extends RelationSchemasBase<RelationSchemasT>, > { childTrace: AllPossibleTraces<RelationSchemasT> terminalState: 'complete' | 'interrupted' interruption?: InterruptionReasonPayload<RelationSchemasT> } type StatesBase< RelationSchemasT extends RelationSchemasBase<RelationSchemasT>, > = Record<TraceStates, StateHandlersBase<RelationSchemasT>> interface TraceStateMachineSideEffectHandlers< RelationSchemasT extends RelationSchemasBase<RelationSchemasT>, > { readonly addSpanToRecording: ( spanAndAnnotation: SpanAndAnnotation<RelationSchemasT>, ) => void readonly onTerminalStateReached: ( transition: FinalTransition<RelationSchemasT>, ) => void readonly onError: (error: Error) => void } type EntryType<RelationSchemasT extends RelationSchemasBase<RelationSchemasT>> = PerformanceEntryLike & { entry: SpanAndAnnotation<RelationSchemasT> } interface StateMachineContext< SelectedRelationNameT extends keyof RelationSchemasT, RelationSchemasT extends RelationSchemasBase<RelationSchemasT>, VariantsT extends string, > extends DraftTraceContext< SelectedRelationNameT, RelationSchemasT, VariantsT > { sideEffectFns: TraceStateMachineSideEffectHandlers<RelationSchemasT> children: ReadonlySet<AllPossibleTraces<RelationSchemasT>> terminalStateChildren: ReadonlySet<AllPossibleTraces<RelationSchemasT>> eventSubjects: { 'state-transition': Subject< StateTransitionEvent<SelectedRelationNameT, RelationSchemasT, VariantsT> > 'required-span-seen': Subject< RequiredSpanSeenEvent<SelectedRelationNameT, RelationSchemasT, VariantsT> > 'add-span-to-recording': Subject< AddSpanToRecordingEvent< SelectedRelationNameT, RelationSchemasT, VariantsT > > 'definition-modified': Subject< DefinitionModifiedEvent< SelectedRelationNameT, RelationSchemasT, VariantsT > > } } type DeadlineType = 'global' | 'debounce' | 'interactive' | 'next-quiet-window' export class TraceStateMachine< SelectedRelationNameT extends keyof RelationSchemasT, RelationSchemasT extends RelationSchemasBase<RelationSchemasT>, const VariantsT extends string, > { constructor( context: StateMachineContext< SelectedRelationNameT, RelationSchemasT, VariantsT >, ) { this.#context = context this.emit('onEnterState', undefined) } readonly successfullyMatchedRequiredSpanMatchers = new Set< SpanMatcherFn<SelectedRelationNameT, RelationSchemasT, VariantsT> >() readonly #context: StateMachineContext< SelectedRelationNameT, RelationSchemasT, VariantsT > get sideEffectFns() { return this.#context.sideEffectFns } currentState: TraceStates = INITIAL_STATE /** the span that ended at the furthest point in time */ lastRelevant: SpanAndAnnotation<RelationSchemasT> | undefined lastRequiredSpan: SpanAndAnnotation<RelationSchemasT> | undefined /** it is set once the LRS value is established */ completeSpan: SpanAndAnnotation<RelationSchemasT> | undefined cpuIdleLongTaskProcessor: | CPUIdleLongTaskProcessor<EntryType<RelationSchemasT>> | undefined #lastLongTaskEndTime: number | undefined #debounceDeadline: number = Number.POSITIVE_INFINITY #interactiveDeadline: number = Number.POSITIVE_INFINITY #timeoutDeadline: number = Number.POSITIVE_INFINITY nextDeadlineRef: ReturnType<typeof setTimeout> | undefined setDeadline( deadlineType: Exclude<DeadlineType, 'global'>, deadlineEpoch: number, ) { 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 + DEADLINE_BUFFER if (this.nextDeadlineRef) { clearTimeout(this.nextDeadlineRef) } this.nextDeadlineRef = setTimeout(() => { this.emit('onDeadline', closestDeadline) }, Math.max(timeToDeadlinePlusBuffer, 0)) } setGlobalDeadline(deadline: number) { this.#timeoutDeadline = deadline const rightNowEpoch = Date.now() const timeToDeadlinePlusBuffer = deadline - rightNowEpoch + 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: SpanAndAnnotation<RelationSchemasT>[] = [] #draftBuffer: SpanAndAnnotation<RelationSchemasT>[] = [] // eslint-disable-next-line consistent-return #processDraftBuffer(): Transition<RelationSchemasT> | void { // process items in the buffer (stick the relatedTo in the entries) (if its empty, well we can skip this!) let span: SpanAndAnnotation<RelationSchemasT> | undefined // eslint-disable-next-line no-cond-assign while ((span = this.#draftBuffer.shift())) { const transition = this.emit('onProcessSpan', span, true) if (transition) return transition } } readonly states = { draft: { onEnterState: () => { this.setGlobalDeadline( this.#context.input.startTime.epoch + this.#context.definition.variants[this.#context.input.variant]! .timeout, ) }, onMakeActive: () => ({ transitionToState: 'active', }), onProcessSpan: ( spanAndAnnotation: SpanAndAnnotation<RelationSchemasT>, ) => { const spanEndTimeEpoch = spanAndAnnotation.span.startTime.epoch + spanAndAnnotation.span.duration if ( 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, } as const } // 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, } as const } } } return undefined }, onInterrupt: ( reasonPayload: InterruptionReasonPayload<RelationSchemasT>, ) => ({ transitionToState: 'interrupted', interruption: reasonPayload, lastRelevantSpanAndAnnotation: undefined, } as const), onDeadline: (deadlineType: DeadlineType) => { if (deadlineType === 'global') { return { transitionToState: 'interrupted', interruption: { reason: 'timeout' }, lastRelevantSpanAndAnnotation: undefined, } as const } // other cases should never happen return undefined }, onChildEnd: (event: ChildEndEvent<RelationSchemasT>) => { // Check if child was interrupted and handle accordingly if (event.terminalState === 'interrupted' && event.interruption) { if ( !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, } as const } return undefined }, }, active: { onEnterState: (_transition: OnEnterActive) => { const nextTransition = this.#processDraftBuffer() if (nextTransition) return nextTransition return undefined }, onProcessSpan: ( spanAndAnnotation: SpanAndAnnotation<RelationSchemasT>, ) => { const spanEndTimeEpoch = spanAndAnnotation.span.startTime.epoch + spanAndAnnotation.span.duration if ( 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: InterruptionReasonPayload<RelationSchemasT>, ) => ({ transitionToState: 'interrupted', interruption: reasonPayload, lastRelevantSpanAndAnnotation: this.lastRelevant, }), onDeadline: (deadlineType: DeadlineType) => { if (deadlineType === 'global') { return { transitionToState: 'interrupted', interruption: { reason: 'timeout' }, lastRelevantSpanAndAnnotation: this.lastRelevant, } } // other cases should never happen return undefined }, onChildEnd: (event: ChildEndEvent<RelationSchemasT>) => { // Check if child was interrupted and handle accordingly if (event.terminalState === 'interrupted' && event.interruption) { if ( !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: OnEnterDebouncing) => { 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 ?? DEFAULT_DEBOUNCE_DURATION), ) return undefined }, onDeadline: (deadlineType: 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: SpanAndAnnotation<RelationSchemasT>, ) => { const spanEndTimeEpoch = spanAndAnnotation.span.startTime.epoch + spanAndAnnotation.span.duration if ( 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 ?? DEFAULT_DEBOUNCE_DURATION), ) } return undefined } } } return undefined }, onInterrupt: ( reasonPayload: InterruptionReasonPayload<RelationSchemasT>, ) => ({ transitionToState: 'interrupted', interruption: reasonPayload, lastRelevantSpanAndAnnotation: this.lastRelevant, }), onChildEnd: (event: ChildEndEvent<RelationSchemasT>) => { // Check if child was interrupted and handle accordingly if (event.terminalState === 'interrupted' && event.interruption) { if ( !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: OnEnterWaitingForInteractive) => { 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) || DEFAULT_INTERACTIVE_TIMEOUT_DURATION const lastRequiredSpanEndTimeEpoch = this.completeSpan.span.startTime.epoch + this.completeSpan.span.duration this.setDeadline( 'interactive', lastRequiredSpanEndTimeEpoch + interruptMillisecondsAfterLastRequiredSpan, ) this.cpuIdleLongTaskProcessor = createCPUIdleProcessor< EntryType<RelationSchemasT> >( { 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, // below cast is necessary due to circular type reference ) as Transition<RelationSchemasT> | undefined if (transition) { return transition } } return undefined }, onDeadline: (deadlineType: 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: SpanAndAnnotation<RelationSchemasT>, ) => { 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' }, } as const } 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, } as const } } } 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: InterruptionReasonPayload<RelationSchemasT>, ) => // 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: ChildEndEvent<RelationSchemasT>) => { // Check if child was interrupted and handle accordingly if (event.terminalState === 'interrupted' && event.interruption) { if ( !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: OnEnterWaitingForChildren) => { // 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: ChildEndEvent<RelationSchemasT>) => { // Check if child was interrupted and handle accordingly if (event.terminalState === 'interrupted' && event.interruption) { if ( !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: SpanAndAnnotation<RelationSchemasT>, ) => { this.sideEffectFns.addSpanToRecording(spanAndAnnotation) return undefined }, onInterrupt: ( reasonPayload: InterruptionReasonPayload<RelationSchemasT>, ) => ({ transitionToState: 'interrupted', interruption: reasonPayload, lastRelevantSpanAndAnnotation: this.lastRelevant, }), onDeadline: (deadlineType: DeadlineType) => { if (deadlineType === 'global') { return { transitionToState: 'interrupted', interruption: { reason: 'timeout' }, lastRelevantSpanAndAnnotation: this.lastRelevant, } } return undefined }, }, // terminal states: interrupted: { onEnterState: (transition: OnEnterInterrupted<RelationSchemasT>) => { // 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: SpanAndAnnotation<RelationSchemasT> | undefined // eslint-disable-next-line no-cond-assign while ((span = this.#draftBuffer.shift())) { this.sideEffectFns.addSpanToRecording(span) } } }, }, complete: { onEnterState: (transition: OnEnterComplete<RelationSchemasT>) => { 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 } }, }, } satisfies StatesBase<RelationSchemasT> /** * @returns the last OnEnterState event if a transition was made */ emit< EventName extends keyof StateHandlerPayloads< SelectedRelationNameT, RelationSchemasT, VariantsT >, >( event: EventName, payload: StateHandlerPayloads< SelectedRelationNameT, RelationSchemasT, VariantsT >[EventName], /** if called recursively inside of an event handler, it must be set to true to avoid double handling of terminal state */ internal = false, ): OnEnterStatePayload<RelationSchemasT> | undefined { const currentStateHandlers = this.states[this.currentState] as Partial< MergedStateHandlerMethods< SelectedRelationNameT, RelationSchemasT, VariantsT > > const transitionPayload = currentStateHandlers[event]?.(payload) if (transitionPayload) { const transitionFromState = this.currentState as NonTerminalTraceStates this.currentState = transitionPayload.transitionToState const onEnterStateEvent: OnEnterStatePayload<RelationSchemasT> = { ...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 && isEnteringTerminalState(settledTransition)) { this.clearDeadline() this.#context.sideEffectFns.onTerminalStateReached(settledTransition) } return settledTransition } return undefined } } export class Trace< const SelectedRelationNameT extends keyof RelationSchemasT, const RelationSchemasT extends RelationSchemasBase<RelationSchemasT>, const VariantsT extends string, > implements TraceContext<SelectedRelationNameT, RelationSchemasT, VariantsT> { readonly sourceDefinition: CompleteTraceDefinition< SelectedRelationNameT, RelationSchemasT, VariantsT > /** the source-of-truth - local copy of a final, mutable definition of this specific trace */ definition: CompleteTraceDefinition< SelectedRelationNameT, RelationSchemasT, VariantsT > wasActivated = false get activeInput(): ActiveTraceConfig< SelectedRelationNameT, RelationSchemasT, VariantsT > { 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 as Trace<any, RelationSchemasT, any>, ) } return this.input as ActiveTraceConfig< SelectedRelationNameT, RelationSchemasT, VariantsT > } set activeInput( value: ActiveTraceConfig< SelectedRelationNameT, RelationSchemasT, VariantsT >, ) { this.input = value } wasReplaced = false input: DraftTraceInput<RelationSchemasT[SelectedRelationNameT], VariantsT> readonly traceUtilities: TraceUtilities<RelationSchemasT> get isDraft() { return this.stateMachine.currentState === INITIAL_STATE } recordedItems: Map<string, SpanAndAnnotation<RelationSchemasT>> = new Map() occurrenceCounters = new Map<string, number>() processedPerformanceEntries: WeakMap< PerformanceEntry, SpanAndAnnotation<RelationSchemasT> > = new WeakMap() persistedDefinitionModifications: Set< TraceDefinitionModifications< SelectedRelationNameT, RelationSchemasT, VariantsT > > = new Set() readonly recordedItemsByLabel: { [label: string]: Set<SpanAndAnnotation<RelationSchemasT>> } stateMachine: TraceStateMachine< Selecte