@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
text/typescript
/* 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