@zendesk/react-measure-timing-hooks
Version:
react hooks for measuring time to interactive and time to render of components
372 lines (335 loc) • 13.4 kB
text/typescript
/* eslint-disable no-continue */
import type { SpanAndAnnotation } from './spanAnnotationTypes'
import type { ActiveTraceInput, DraftTraceInput } from './spanTypes'
import type { FinalState } from './Trace'
import type { TraceRecording } from './traceRecordingTypes'
import type { TraceContext } from './types'
import { findLast } from './utils'
/**
* ### Deriving SLIs and other metrics from a trace
*
* ℹ️ It is our recommendation that the primary way of creating duration metrics would be to derive them from data in the trace.
*
* Instead of the traditional approach of capturing isolated metrics imperatively in the code,
* the **trace** model allows us the flexibility to define and compute any number of metrics from the **trace recording**.
*
* We can distinguish the following types of metrics:
*
* 1. **Duration of a Computed Span** — the time between any two **spans** that appeared in the **trace**. For example:
* 1. _time between the user’s click on a ticket_ and _everything in the ticket page has fully rendered with content_ (duration of the entire operation)
* 2. _time between the user’s click on a ticket_ and _the moment the first piece of the ticket UI was displayed_ (duration of a segment of the operation)
*
* 2. **Computed Values** — any numerical value derived from the **spans** or their attributes. For example:
* 1. _The total number of times the log component re-rendered while loading the ticket_
* 2. _The total number of requests made while loading the ticket_
* 3. _The total number of iframe apps were initialized while loading the ticket_
*/
export function getComputedValues<
SelectedRelationNameT extends keyof RelationSchemasT,
RelationSchemasT,
const VariantsT extends string,
>(
context: TraceContext<SelectedRelationNameT, RelationSchemasT, VariantsT>,
): TraceRecording<SelectedRelationNameT, RelationSchemasT>['computedValues'] {
const computedValues: TraceRecording<
SelectedRelationNameT,
RelationSchemasT
>['computedValues'] = {}
for (const [name, computedValueDefinition] of Object.entries(
context.definition.computedValueDefinitions,
)) {
const { matches, computeValueFromMatches } = computedValueDefinition
// Initialize arrays to hold matches for each matcher
const matchingEntriesByMatcher: SpanAndAnnotation<RelationSchemasT>[][] =
Array.from({ length: matches.length }, () => [])
// Single pass through recordedItems
for (const item of context.recordedItems.values()) {
matches.forEach((doesSpanMatch, index) => {
if (doesSpanMatch(item, context)) {
matchingEntriesByMatcher[index]!.push(item)
}
})
}
const value = computeValueFromMatches(...matchingEntriesByMatcher)
if (value !== undefined) {
computedValues[name] = value
}
}
return computedValues
}
const markedComplete = <RelationSchemasT>(
spanAndAnnotation: SpanAndAnnotation<RelationSchemasT>,
) => spanAndAnnotation.annotation.markedComplete
const markedInteractive = <RelationSchemasT>(
spanAndAnnotation: SpanAndAnnotation<RelationSchemasT>,
) => spanAndAnnotation.annotation.markedPageInteractive
export function getComputedSpans<
SelectedRelationNameT extends keyof RelationSchemasT,
RelationSchemasT,
const VariantsT extends string,
>(
context: TraceContext<SelectedRelationNameT, RelationSchemasT, VariantsT>,
): TraceRecording<SelectedRelationNameT, RelationSchemasT>['computedSpans'] {
// loop through the computed span definitions, check for entries that match in recorded items, then calculate the startOffset and duration
const computedSpans: TraceRecording<
SelectedRelationNameT,
RelationSchemasT
>['computedSpans'] = {}
const recordedItemsArray = [...context.recordedItems.values()]
for (const [name, computedSpanDefinition] of Object.entries(
context.definition.computedSpanDefinitions,
)) {
const { startSpan: startSpanMatcher, endSpan } = computedSpanDefinition
const matchingStartEntry =
typeof startSpanMatcher === 'function'
? recordedItemsArray.find((spanAndAnnotation) =>
startSpanMatcher(spanAndAnnotation, context),
)
: startSpanMatcher
const matchingStartTime =
matchingStartEntry === 'operation-start'
? context.input.startTime.now
: matchingStartEntry?.span.startTime.now
const endSpanMatcher =
endSpan === 'operation-end'
? markedComplete
: endSpan === 'interactive'
? markedInteractive
: endSpan
const matchingEndEntry = findLast(recordedItemsArray, (spanAndAnnotation) =>
endSpanMatcher(spanAndAnnotation, context),
)
const matchingEndTime = matchingEndEntry
? matchingEndEntry.span.startTime.now + matchingEndEntry.span.duration
: undefined
if (
typeof matchingStartTime === 'number' &&
typeof matchingEndTime === 'number'
) {
const duration = matchingEndTime - matchingStartTime
computedSpans[name] = {
duration,
// DECISION: After considering which events happen first and which one is defined as the start
// the start offset is always going to be anchored to the start span.
// cases:
// -----S------E (computed val is positive)
// -----E------S (computed val is negative)
// this way the `endOffset` can be derived as follows:
// endOffset = computedSpan.startOffset + computedSpan.duration
startOffset: matchingStartTime - context.input.startTime.now,
}
}
}
return computedSpans
}
function getComputedRenderBeaconSpans<
SelectedRelationNameT extends keyof RelationSchemasT,
RelationSchemasT,
const VariantsT extends string,
>(
recordedItems: ReadonlySet<SpanAndAnnotation<RelationSchemasT>>,
input: ActiveTraceInput<RelationSchemasT[SelectedRelationNameT], VariantsT>,
): TraceRecording<
SelectedRelationNameT,
RelationSchemasT
>['computedRenderBeaconSpans'] {
const renderSpansByBeacon = new Map<
string,
{
firstStart: number
firstContentfulRenderEnd: number | undefined
firstLoadingEnd: number | undefined
firstContentStart: number | undefined
renderCount: number
sumOfDurations: number
lastRenderStartTime: number | undefined // Track the last render start time
}
>()
const relatedToKey = Object.keys(input.relatedTo)
// Group render spans by beacon and compute firstStart and lastEnd
for (const entry of recordedItems) {
if (
entry.span.type !== 'component-render' &&
entry.span.type !== 'component-render-start'
) {
continue
}
const {
name,
startTime,
duration,
relatedTo: r,
renderedOutput,
} = entry.span
const relatedTo = r as Record<string, unknown> | undefined
const inputRelatedTo: Record<string, unknown> = input.relatedTo
const relationMatch = relatedToKey.every(
(key) =>
relatedTo?.[key] === undefined ||
inputRelatedTo[key] === relatedTo[key],
)
if (!relationMatch) continue
const start = startTime.now
const contentfulRenderEnd =
entry.span.type === 'component-render' && renderedOutput === 'content'
? start + duration
: undefined
const spanTimes = renderSpansByBeacon.get(name)
if (!spanTimes) {
renderSpansByBeacon.set(name, {
firstStart: start,
firstContentfulRenderEnd: contentfulRenderEnd,
renderCount: entry.span.type === 'component-render' ? 1 : 0,
sumOfDurations: duration,
firstContentStart: renderedOutput === 'content' ? start : undefined,
firstLoadingEnd:
entry.span.type === 'component-render' && renderedOutput === 'loading'
? start + duration
: undefined,
lastRenderStartTime:
entry.span.type === 'component-render-start' ? start : undefined,
})
} else {
spanTimes.firstStart = Math.min(spanTimes.firstStart, start)
spanTimes.firstContentfulRenderEnd =
contentfulRenderEnd && spanTimes.firstContentfulRenderEnd
? Math.min(spanTimes.firstContentfulRenderEnd, contentfulRenderEnd)
: contentfulRenderEnd ?? spanTimes.firstContentfulRenderEnd
if (entry.span.type === 'component-render') {
spanTimes.renderCount += 1
// React's concurrent rendering might pause and discard a render,
// which would mean that an effect scheduled for that render does not execute because the render itself was not committed to the DOM.
// we want to extend the the render span backwards, to first time that rendering was scheduled as the start time of rendering
if (spanTimes.lastRenderStartTime !== undefined) {
spanTimes.sumOfDurations +=
start + duration - spanTimes.lastRenderStartTime
spanTimes.lastRenderStartTime = undefined
} else {
spanTimes.sumOfDurations += duration
}
} else if (entry.span.type === 'component-render-start') {
spanTimes.lastRenderStartTime = start
}
if (
spanTimes.firstContentStart === undefined &&
renderedOutput === 'content'
) {
spanTimes.firstContentStart = start
}
if (
spanTimes.firstLoadingEnd === undefined &&
entry.span.type === 'component-render' &&
renderedOutput === 'loading'
) {
spanTimes.firstLoadingEnd = start + duration
}
}
}
const computedRenderBeaconSpans: TraceRecording<
SelectedRelationNameT,
RelationSchemasT
>['computedRenderBeaconSpans'] = {}
// Calculate duration and startOffset for each beacon
for (const [beaconName, spanTimes] of renderSpansByBeacon) {
if (!spanTimes.firstContentfulRenderEnd) continue
computedRenderBeaconSpans[beaconName] = {
startOffset: spanTimes.firstStart - input.startTime.now,
firstRenderTillContent:
spanTimes.firstContentfulRenderEnd - spanTimes.firstStart,
firstRenderTillLoading: spanTimes.firstLoadingEnd
? spanTimes.firstLoadingEnd - spanTimes.firstStart
: 0,
firstRenderTillData: spanTimes.firstContentStart
? spanTimes.firstContentStart - spanTimes.firstStart
: 0,
renderCount: spanTimes.renderCount,
sumOfRenderDurations: spanTimes.sumOfDurations,
}
}
return computedRenderBeaconSpans
}
function isActiveTraceInput<
SelectedRelationNameT extends keyof RelationSchemasT,
RelationSchemasT,
const VariantsT extends string,
>(
input:
| DraftTraceInput<RelationSchemasT[SelectedRelationNameT], VariantsT>
| ActiveTraceInput<RelationSchemasT[SelectedRelationNameT], VariantsT>,
): input is ActiveTraceInput<
RelationSchemasT[SelectedRelationNameT],
VariantsT
> {
return Boolean(input.relatedTo)
}
export function createTraceRecording<
const SelectedRelationNameT extends keyof RelationSchemasT,
const RelationSchemasT,
const VariantsT extends string,
>(
context: TraceContext<SelectedRelationNameT, RelationSchemasT, VariantsT>,
{
transitionFromState,
interruptionReason,
cpuIdleSpanAndAnnotation,
completeSpanAndAnnotation,
lastRequiredSpanAndAnnotation,
}: FinalState<RelationSchemasT>,
): TraceRecording<SelectedRelationNameT, RelationSchemasT> {
const { definition, recordedItems, input } = context
const { id, relatedTo, variant } = input
const { name } = definition
// CODE CLEAN UP TODO: let's get this information (wasInterrupted) from up top (in FinalState)
const wasInterrupted =
interruptionReason && transitionFromState !== 'waiting-for-interactive'
const computedSpans = !wasInterrupted ? getComputedSpans(context) : {}
const computedValues = !wasInterrupted ? getComputedValues(context) : {}
const computedRenderBeaconSpans =
!wasInterrupted && isActiveTraceInput(input)
? getComputedRenderBeaconSpans(recordedItems, input)
: {}
const recordedItemsArray = [...recordedItems.values()]
const anyNonSuppressedErrors = recordedItemsArray.some(
(spanAndAnnotation) =>
spanAndAnnotation.span.status === 'error' &&
!definition.suppressErrorStatusPropagationOnSpans?.some((doesSpanMatch) =>
doesSpanMatch(spanAndAnnotation, context),
),
)
const duration =
completeSpanAndAnnotation?.annotation.operationRelativeEndTime ?? null
const startTillInteractive =
cpuIdleSpanAndAnnotation?.annotation.operationRelativeEndTime ?? null
const startTillRequirementsMet =
lastRequiredSpanAndAnnotation?.annotation.operationRelativeEndTime ?? null
return {
id,
name,
startTime: input.startTime,
relatedTo,
type: 'operation',
duration,
variant,
additionalDurations: {
startTillRequirementsMet,
startTillInteractive,
// last entry until the tti?
completeTillInteractive:
startTillInteractive && duration
? startTillInteractive - duration
: null,
},
// ?: If we have any error entries then should we mark the status as 'error'
status: wasInterrupted
? 'interrupted'
: anyNonSuppressedErrors
? 'error'
: 'ok',
computedSpans,
computedRenderBeaconSpans,
computedValues,
attributes: input.attributes ?? {},
interruptionReason,
entries: recordedItemsArray,
}
}