UNPKG

@zendesk/retrace

Version:

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

1,460 lines (1,317 loc) 56.7 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, type PerformanceEntryLike, } from './firstCPUIdle' import { getSpanKey } from './getSpanKey' import { type SpanMatcherFn, withAllConditions } from './matchSpan' import { createTraceRecording } from './recordingComputeUtils' import { requiredSpanWithErrorStatus } from './requiredSpanWithErrorStatus' import type { SpanAndAnnotation, SpanAnnotation, SpanAnnotationRecord, } from './spanAnnotationTypes' import type { ActiveTraceConfig, DraftTraceInput, Span } from './spanTypes' import type { TraceRecording } from './traceRecordingTypes' import type { CompleteTraceDefinition, DraftTraceContext, RelationSchemasBase, ReportErrorFn, TraceContext, TraceDefinitionModifications, TraceInterruptionReason, TraceInterruptionReasonForInvalidTraces, TraceManagerUtilities, TraceModifications, TransitionDraftOptions, } from './types' import { INVALID_TRACE_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' 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) interface OnEnterActive { transitionToState: 'active' transitionFromState: NonTerminalTraceStates } interface OnEnterInterrupted<RelationSchemasT> { transitionToState: 'interrupted' transitionFromState: NonTerminalTraceStates interruptionReason: TraceInterruptionReason lastRelevantSpanAndAnnotation: SpanAndAnnotation<RelationSchemasT> | undefined } interface OnEnterComplete<RelationSchemasT> { transitionToState: 'complete' transitionFromState: NonTerminalTraceStates interruptionReason?: TraceInterruptionReason cpuIdleSpanAndAnnotation: SpanAndAnnotation<RelationSchemasT> | undefined completeSpanAndAnnotation: SpanAndAnnotation<RelationSchemasT> | undefined lastRequiredSpanAndAnnotation: SpanAndAnnotation<RelationSchemasT> | undefined lastRelevantSpanAndAnnotation: SpanAndAnnotation<RelationSchemasT> | undefined } export type FinalTransition<RelationSchemasT> = | OnEnterInterrupted<RelationSchemasT> | OnEnterComplete<RelationSchemasT> interface OnEnterWaitingForInteractive { transitionToState: 'waiting-for-interactive' transitionFromState: NonTerminalTraceStates } interface OnEnterDebouncing { transitionToState: 'debouncing' transitionFromState: NonTerminalTraceStates } export type OnEnterStatePayload<RelationSchemasT> = | OnEnterActive | OnEnterInterrupted<RelationSchemasT> | OnEnterComplete<RelationSchemasT> | OnEnterDebouncing | OnEnterWaitingForInteractive export type Transition<RelationSchemasT> = DistributiveOmit< OnEnterStatePayload<RelationSchemasT>, 'transitionFromState' > export type States< SelectedRelationNameT extends keyof RelationSchemasT, RelationSchemasT, VariantsT extends string, > = TraceStateMachine< SelectedRelationNameT, RelationSchemasT, VariantsT >['states'] interface StateHandlersBase<RelationSchemasT> { [handler: string]: ( // eslint-disable-next-line @typescript-eslint/no-explicit-any payload: any, ) => | void | undefined | (Transition<RelationSchemasT> & { transitionFromState?: never }) } type StatesBase<RelationSchemasT> = Record< TraceStates, StateHandlersBase<RelationSchemasT> > interface TraceStateMachineSideEffectHandlers<RelationSchemasT> { readonly addSpanToRecording: ( spanAndAnnotation: SpanAndAnnotation<RelationSchemasT>, ) => void readonly prepareAndEmitRecording: ( transition: OnEnterStatePayload<RelationSchemasT>, ) => void } type EntryType<RelationSchemasT> = PerformanceEntryLike & { entry: SpanAndAnnotation<RelationSchemasT> } interface StateMachineContext< SelectedRelationNameT extends keyof RelationSchemasT, RelationSchemasT, VariantsT extends string, > extends DraftTraceContext< SelectedRelationNameT, RelationSchemasT, VariantsT > { sideEffectFns: TraceStateMachineSideEffectHandlers<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, 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 #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) 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 (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', interruptionReason: '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', interruptionReason: doesSpanMatch.requiredSpan ? 'matched-on-required-span-with-error' : 'matched-on-interrupt', lastRelevantSpanAndAnnotation: undefined, } } } } return undefined }, onInterrupt: (reason: TraceInterruptionReason) => ({ transitionToState: 'interrupted', interruptionReason: reason, lastRelevantSpanAndAnnotation: undefined, }), onDeadline: (deadlineType: DeadlineType) => { if (deadlineType === 'global') { return { transitionToState: 'interrupted', interruptionReason: 'timeout', lastRelevantSpanAndAnnotation: undefined, } } // other cases should never happen 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 (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', interruptionReason: '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) return { transitionToState: 'interrupted', interruptionReason: 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: (reason: TraceInterruptionReason) => ({ transitionToState: 'interrupted', interruptionReason: reason, lastRelevantSpanAndAnnotation: this.lastRelevant, }), onDeadline: (deadlineType: DeadlineType) => { if (deadlineType === 'global') { return { transitionToState: 'interrupted', interruptionReason: 'timeout', lastRelevantSpanAndAnnotation: this.lastRelevant, } } // other cases should never happen 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', interruptionReason: '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', interruptionReason: 'timeout', lastRelevantSpanAndAnnotation: this.lastRelevant, } } if (deadlineType === 'debounce') { 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 (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', interruptionReason: 'timeout', 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', interruptionReason: '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: (reason: TraceInterruptionReason) => ({ transitionToState: 'interrupted', interruptionReason: reason, lastRelevantSpanAndAnnotation: this.lastRelevant, }), }, 'waiting-for-interactive': { onEnterState: (_payload: OnEnterWaitingForInteractive) => { if (!this.lastRelevant) { // this should never happen return { transitionToState: 'interrupted', interruptionReason: 'invalid-state-transition', lastRelevantSpanAndAnnotation: this.lastRelevant, } } this.completeSpan = this.lastRelevant const interactiveConfig = this.#context.definition.captureInteractive if (!interactiveConfig) { // nothing to do in this state, move to 'complete' 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 : {}, ) // 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, // 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') { return { transitionToState: 'complete', interruptionReason: '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 { interruptionReason: '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) { // 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, because we have a complete trace // it's just missing the bonus data from when the browser became "interactive" return { transitionToState: 'complete', interruptionReason: 'timeout', lastRequiredSpanAndAnnotation: this.lastRequiredSpan, completeSpanAndAnnotation: this.completeSpan, lastRelevantSpanAndAnnotation: this.lastRelevant, cpuIdleSpanAndAnnotation: undefined, } } if (spanEndTimeEpoch > this.#interactiveDeadline) { // 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', interruptionReason: '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)) { return { transitionToState: 'complete', interruptionReason: doesSpanMatch.requiredSpan ? 'matched-on-required-span-with-error' : '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: (reason: TraceInterruptionReason) => // we captured a complete trace, however the interactive data is missing ({ transitionToState: 'complete', interruptionReason: reason, lastRequiredSpanAndAnnotation: this.lastRequiredSpan, completeSpanAndAnnotation: this.completeSpan, lastRelevantSpanAndAnnotation: this.lastRelevant, cpuIdleSpanAndAnnotation: 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.interruptionReason) ) { let span: SpanAndAnnotation<RelationSchemasT> | undefined // eslint-disable-next-line no-cond-assign while ((span = this.#draftBuffer.shift())) { this.sideEffectFns.addSpanToRecording(span) } } // terminal state this.clearDeadline() if (transition.interruptionReason === 'definition-changed') { // do not report if the definition changed // this is a special case where the instance is being recreated return } this.sideEffectFns.prepareAndEmitRecording(transition) }, }, complete: { onEnterState: (transition: OnEnterComplete<RelationSchemasT>) => { // terminal state this.clearDeadline() 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 } this.sideEffectFns.prepareAndEmitRecording(transition) }, }, } 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], ): 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, } // Emit state transition event for debugging this.#context.eventSubjects['state-transition'].next({ traceContext: this.#context, stateTransition: onEnterStateEvent, }) return this.emit('onEnterState', onEnterStateEvent) ?? onEnterStateEvent } 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 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 } input: DraftTraceInput<RelationSchemasT[SelectedRelationNameT], VariantsT> readonly traceUtilities: TraceManagerUtilities<RelationSchemasT> get isDraft() { return this.stateMachine.currentState === INITIAL_STATE } recordedItems: Set<SpanAndAnnotation<RelationSchemasT>> = new Set() occurrenceCounters = new Map<string, number>() processedPerformanceEntries: WeakMap< PerformanceEntry, SpanAndAnnotation<RelationSchemasT> > = new WeakMap() persistedDefinitionModifications: Set< TraceDefinitionModifications< SelectedRelationNameT, RelationSchemasT, VariantsT > > = new Set() readonly recordedItemsByLabel: { [label: string]: SpanAndAnnotation<RelationSchemasT>[] } stateMachine: TraceStateMachine< SelectedRelationNameT, RelationSchemasT, VariantsT > // debugging observables eventSubjects = { 'state-transition': new Subject< StateTransitionEvent<SelectedRelationNameT, RelationSchemasT, VariantsT> >(), 'required-span-seen': new Subject< RequiredSpanSeenEvent<SelectedRelationNameT, RelationSchemasT, VariantsT> >(), 'add-span-to-recording': new Subject< AddSpanToRecordingEvent< SelectedRelationNameT, RelationSchemasT, VariantsT > >(), 'definition-modified': new Subject< DefinitionModifiedEvent< SelectedRelationNameT, RelationSchemasT, VariantsT > >(), } when( event: 'state-transition', ): Observable< StateTransitionEvent<SelectedRelationNameT, RelationSchemasT, VariantsT> > when( event: 'required-span-seen', ): Observable< RequiredSpanSeenEvent<SelectedRelationNameT, RelationSchemasT, VariantsT> > when( event: 'add-span-to-recording', ): Observable< AddSpanToRecordingEvent<SelectedRelationNameT, RelationSchemasT, VariantsT> > when( event: 'definition-modified', ): Observable< DefinitionModifiedEvent<SelectedRelationNameT, RelationSchemasT, VariantsT> > when( event: | 'required-span-seen' | 'state-transition' | 'add-span-to-recording' | 'definition-modified', ): | Observable< StateTransitionEvent<SelectedRelationNameT, RelationSchemasT, VariantsT> > | Observable< RequiredSpanSeenEvent< SelectedRelationNameT, RelationSchemasT, VariantsT > > | Observable< AddSpanToRecordingEvent< SelectedRelationNameT, RelationSchemasT, VariantsT > > | Observable< DefinitionModifiedEvent< SelectedRelationNameT, RelationSchemasT, VariantsT > > { return this.eventSubjects[event].asObservable() } constructor( data: | { definition: CompleteTraceDefinition< SelectedRelationNameT, RelationSchemasT, VariantsT > input: DraftTraceInput< RelationSchemasT[SelectedRelationNameT], VariantsT > definitionModifications?: TraceDefinitionModifications< SelectedRelationNameT, RelationSchemasT, VariantsT > traceUtilities: TraceManagerUtilities<RelationSchemasT> } | { importFrom: Trace<SelectedRelationNameT, RelationSchemasT, VariantsT> definitionModifications: TraceDefinitionModifications< SelectedRelationNameT, RelationSchemasT, VariantsT > }, ) { 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.sourceDefinition = definition this.definition = { name: definition.name, type: definition.type, relationSchemaName: definition.relationSchemaName, relationSchema: definition.relationSchema, variants: definition.variants, labelMatching: definition.labelMatching, debounceWindow: definition.debounceWindow, promoteSpanAttributes: definition.promoteSpanAttributes, // below props are potentially mutable elements of the definition, let's make local copies: requiredSpans: [...definition.requiredSpans], computedSpanDefinitions: { ...definition.computedSpanDefinitions }, computedValueDefinitions: { ...definition.computedValueDefinitions }, interruptOnSpans: definition.interruptOnSpans ? [...definition.interruptOnSpans] : undefined, debounceOnSpans: definition.debounceOnSpans ? [...definition.debounceOnSpans] : undefined, captureInteractive: definition.captureInteractive ? typeof definition.captureInteractive === 'boolean' ? definition.captureInteractive : { ...definition.captureInteractive } : undefined, suppressErrorStatusPropagationOnSpans: definition.suppressErrorStatusPropagationOnSpans ? [...definition.suppressErrorStatusPropagationOnSpans] : undefined, } // all requiredSpans implicitly interrupt the trace if they error, unless explicitly ignored // creates interruptOnSpans for the source definition of requiredSpans const interruptOnRequiredErrored = this.mapRequiredSpanMatchersToInterruptOnMatchers( this.definition.requiredSpans, ) // Verify that the variant value is valid const variant = definition.variants[input.variant] if (variant) { this.applyDefinitionModifications(variant, false) } else { traceUtilities.reportErrorFn( new Error( `Invalid variant value: ${ input.variant }. Must be one of: ${Object.keys(definition.variants).join(', ')}`, ), // eslint-disable-next-line @typescript-eslint/no-explicit-any this as Trace<any, RelationSchemasT, any>, ) } this.input = { ...input, startTime: ensureTimestamp(input.startTime), } this.traceUtilities = traceUtilities this.definition.interruptOnSpans = [ ...(this.definition.interruptOnSpans ?? []), ...interruptOnRequiredErrored, ] as typeof definition.interruptOnSpans if ('importFrom' in data) { for (const mod of data.importFrom.persistedDefinitionModifications) { // re-apply any previously done modifications (in case this isn't the first time we're importing) this.applyDefinitionModifications(mod) } } if (definitionModifications) { this.applyDefinitionModifications(definitionModifications) } this.recordedItemsByLabel = Object.fromEntries( Object.keys(this.definition.labelMatching ?? {}).map((label) => [ label, [] as SpanAndAnnotation<RelationSchemasT>[], ]), ) // definition is now set, we can initialize the state machine this.stateMachine = new TraceStateMachine(this) if ('importFrom' in data) { if (data.importFrom.wasActivated) { this.transitionDraftToActive({ relatedTo: data.importFrom.activeInput.relatedTo, }) } // replay the recorded items from the imported trace and copy over cache state this.replayItems(data.importFrom.recordedItems) this.occurrenceCounters = data.importFrom.occurrenceCounters this.processedPerformanceEntries = data.importFrom.processedPerformanceEntries } } /** * all requiredSpans implicitly interrupt the trace if they error, unless explicitly ignored */ mapRequiredSpanMatchersToInterruptOnMatchers( requiredSpans: readonly SpanMatcherFn< SelectedRelationNameT, RelationSchemasT, VariantsT >[], ): readonly SpanMatcherFn< SelectedRelationNameT, RelationSchemasT, VariantsT >[] { return requiredSpans.flatMap((matcher) => matcher.continueWithErrorStatus ? [] : withAllConditions<SelectedRelationNameT, RelationSchemasT, VariantsT>( matcher, requiredSpanWithErrorStatus< SelectedRelationNameT, RelationSchemasT, VariantsT >(), ), ) } sideEffectFns: TraceStateMachineSideEffectHandlers<RelationSchemasT> = { addSpanToRecording: (spanAndAnnotation) => { if (!this.recordedItems.has(spanAndAnnotation)) { this.recordedItems.add(spanAndAnnotation) for (const label of spanAndAnnotation.annotation.labels) { this.recordedItemsByLabel[label]?.push(spanAndAnnotation) } this.eventSubjects['add-span-to-recording'].next({ spanAndAnnotation, traceContext: this, }) } }, prepareAndEmitRecording: (transition) => { if ( transition.transitionToState === 'interrupted' || transition.transitionToState === 'complete' ) { const traceRecording = createTraceRecording( // we don't want to pass 'this' but select the relevant properties // to avoid circular references { definition: this.definition, recordedItems: this.recordedItems, input: this.input, recordedItemsByLabel: this.recordedItemsByLabel, }, transition, ) this.onEnd(traceRecording) // memory clean-up in case something retains the Trace instance this.recordedItems = new Set() this.occurrenceCounters = new Map() this.processedPerformanceEntries = new WeakMap() // @ts-expect-error memory cleanup force override the otherwise readonly property this.recordedItemsByLabel = {} this.traceUtilities.performanceEntryDeduplicationStrategy?.reset() } }, } onEnd( traceRecording: TraceRecording<SelectedRelationNameT, RelationSchemasT>, ): void { this.traceUtilities.onEndTrace(this) this.traceUtilities.reportFn(traceRecording, this) } // this is public API only and should not be called internally interrupt(reason: TraceInterruptionReason) { this.stateMachine.emit('onInterrupt', reason) } transitionDraftToActive( inputAndDefinitionModifications: TraceModifications< SelectedRelationNameT, RelationSchemasT, VariantsT >, { previouslyActivatedBehavior = 'warn-and-continue', invalidRelatedToBehavior = 'warn-and-continue', }: TransitionDraftOptions = {}, ): void { const { isDraft } = this let reportPreviouslyActivated: ReportErrorFn<RelationSchemasT> let overwriteDraft = true switch (previouslyActivatedBehavior) { case 'error': reportPreviouslyActivated = this.traceUtilities.reportErrorFn overwriteDraft = false break case 'error-and-continue': reportPreviouslyActivated = this.traceUtilities.reportErrorFn break default: reportPreviouslyActivated = this.traceUtilities.reportWarningFn break } // this is an already initialized active trace, do nothing: if (!isDraft) { reportPreviouslyActivated( new Error( `You are trying to activate a trace that has already been activated before (${this.definition.name}).`, ), // eslint-disable-next-line @typescript-eslint/no-explicit-any this as Trace<any, RelationSchemasT, any>, ) if (!overwriteDraft) { return } } let reportValidationError: ReportErrorFn<RelationSchemasT> let useInvalidRelatedTo = true switch (invalidRelatedToBehavior) { case 'error': reportValidationError = this.traceUtilities.reportErrorFn useInvalidRelatedTo = false break case 'error-and-continue': reportValidationError = this.traceUtilities.reportErrorFn break default: reportValidationError = this.traceUtilities.reportWarningFn break } const { attributes } = this.input const { relatedTo, errors } = validateAndCoerceRelatedToAgainstSchema( inputAndDefinitionModifications.relatedTo, this.definition.relationSchema, ) if (errors.length > 0) { reportValidationError( new Error( `Invalid relatedTo value: ${JSON.stringify( inputAndDefinitionModifications.relatedTo, )}. ${errors.join(', ')}`, ), // eslint-disable-next-line @typescript-eslint/no-explicit-any this as Trace<any, RelationSchemasT, any>, ) if (!useInvalidRelatedTo) { return } } this.activeInput = { ...this.input, relatedTo, attributes: { ...this.input.attributes, ...attributes, }, } this.applyDefinitionModifications(inputAndDefinitionModifications) this.wasActivated = true if (isDraft) { // we might already be active in which case we would have issue