@zendesk/react-measure-timing-hooks
Version:
react hooks for measuring time to interactive and time to render of components
360 lines (333 loc) • 11.2 kB
text/typescript
/**
* Copyright Zendesk, Inc.
*
* Use of this source code is governed under the Apache License, Version 2.0
* found at http://www.apache.org/licenses/LICENSE-2.0.
*/
import {
ACTION_TYPE,
DEFAULT_LOADING_STAGES,
ERROR_STAGES,
INFORMATIVE_STAGES,
MARKER,
OBSERVER_SOURCE,
} from './constants'
import type {
ActionWithStateMetadata,
ReportArguments,
StageDescription,
TimingSpan,
} from './types'
import { getCurrentBrowserSupportForNonResponsiveStateDetection } from './utilities'
export interface Report {
id: string
ttr: number | null
/** TTI will not be present in browsers that do not support tracking long tasks */
tti: number | null
timeSpent: Record<string, number>
counts: Record<string, number>
durations: Record<string, number[]>
isFirstLoad: boolean
lastStage: string
includedStages: string[]
hadError: boolean
handled: boolean
spans: TimingSpan[]
loadingStagesDuration: number
flushReason: string
}
export function generateReport<CustomMetadata extends Record<string, unknown>>({
actions,
timingId,
isFirstLoad = true,
immediateSendReportStages = [],
loadingStages = DEFAULT_LOADING_STAGES,
flushReason = 'auto',
measures,
}: ReportArguments<CustomMetadata>): Report {
const lastStart: Record<string, number> = {}
let lastRenderEnd: number | null = null
const timeSpent: Record<string, number> = {}
let startTime: number | null = null
let endTime: number | null = null
let lastDependencyChange: number | null = null
let dependencyChanges = 0
const counts: Record<string, number> = {}
let previousStageEndTime: number | null = null
let previousStage: string = INFORMATIVE_STAGES.INITIAL
const stageDescriptions: StageDescription[] = []
const durations: Record<string, number[]> = {}
const hasObserverSupport =
getCurrentBrowserSupportForNonResponsiveStateDetection()
const allImmediateSendReportStages = [
...immediateSendReportStages,
INFORMATIVE_STAGES.TIMEOUT,
]
const lastAction = [...actions].reverse()[0]
const includedStages = new Set<string>()
const spans: TimingSpan[] = []
const markStage = ({
stage,
action,
}: {
stage: string
action: ActionWithStateMetadata
}) => {
// guard for the case where the initial stage is customized by the initial render
if (action.timestamp !== startTime) {
includedStages.add(previousStage)
const lastStageTime = previousStageEndTime ?? startTime
const timeToStage = action.timestamp - lastStageTime!
const lastStageDescription =
stageDescriptions[stageDescriptions.length - 1]
if (stage === previousStage && lastStageDescription) {
// since we're still in the same stage (possibly set by a different source this time),
// we just update previous stage description:
lastStageDescription.timeToStage = timeToStage
lastStageDescription.timestamp = action.timestamp - startTime!
lastStageDescription.metadata = Object.assign(
lastStageDescription.metadata ?? {},
action.metadata,
)
lastStageDescription.mountedPlacements = action.mountedPlacements
lastStageDescription.timingId = action.timingId
lastStageDescription.dependencyChanges = dependencyChanges
} else if (stage !== previousStage) {
stageDescriptions.push({
type: action.type,
source: action.source,
previousStage,
stage,
timeToStage,
previousStageTimestamp: (lastStageTime ?? 0) - startTime!,
timestamp: action.timestamp - startTime!,
...(action.metadata
? {
metadata: action.metadata,
}
: {}),
mountedPlacements: action.mountedPlacements,
timingId: action.timingId,
dependencyChanges,
})
}
}
if (stage !== previousStage) {
// update for next time
previousStageEndTime = action.timestamp
dependencyChanges = 0
}
includedStages.add(stage)
previousStage = stage
}
actions.forEach((action, index) => {
if (index === 0) {
startTime = action.timestamp
previousStageEndTime = action.timestamp
lastDependencyChange = action.timestamp
} else {
endTime = action.timestamp
}
// eslint-disable-next-line default-case
switch (action.marker) {
case MARKER.START: {
// action's start time should never be before overall start time
lastStart[action.source] = Math.max(action.timestamp, startTime ?? 0)
break
}
case MARKER.END: {
if (action.source !== OBSERVER_SOURCE) lastRenderEnd = action.timestamp
counts[action.source] = (counts[action.source] ?? 0) + 1
const sourceDurations = durations[action.source] ?? []
let { duration } = action.entry
const actionStartTime = action.timestamp - duration
if (actionStartTime < startTime!) {
// correct for the special case where the observer is initialized before the first action
duration -= startTime! - actionStartTime
}
sourceDurations.push(duration)
durations[action.source] = sourceDurations
timeSpent[action.source] = (timeSpent[action.source] ?? 0) + duration
spans.push({
type: action.type,
description:
action.type === ACTION_TYPE.UNRESPONSIVE
? 'unresponsive'
: `<${action.source}> (${sourceDurations.length})`,
startTime: action.timestamp - duration,
endTime: action.timestamp,
relativeEndTime: action.timestamp - (startTime ?? 0),
entry: action.entry,
data: {
mountedPlacements: action.mountedPlacements,
timingId: action.timingId,
source: action.source,
metadata: action.metadata ?? {},
stage: previousStage,
},
})
break
}
case MARKER.POINT: {
if (action.type === ACTION_TYPE.DEPENDENCY_CHANGE) {
dependencyChanges++
spans.push({
type: action.type,
description: 'dependency change',
startTime: lastDependencyChange!,
endTime: action.timestamp,
relativeEndTime: action.timestamp - (startTime ?? 0),
entry: action.entry,
data: {
mountedPlacements: action.mountedPlacements,
timingId: action.timingId,
source: action.source,
metadata: action.metadata ?? {},
stage: previousStage,
},
})
lastDependencyChange = action.timestamp
} else {
markStage({ stage: action.stage, action })
}
break
}
}
})
if (!lastRenderEnd) lastRenderEnd = 0
const lastTimedEvent = Math.max(lastRenderEnd, previousStageEndTime ?? 0)
const isInCompleteState = Boolean(
lastAction && lastAction.type !== ACTION_TYPE.STAGE_CHANGE,
)
const didImmediateSend = allImmediateSendReportStages.includes(previousStage)
spans.push(
...stageDescriptions.map(
({
type,
previousStage: pStage,
stage,
previousStageTimestamp,
timestamp,
timeToStage,
...data
}): TimingSpan => ({
type,
description: `${pStage} to ${stage}`,
startTime: startTime! + previousStageTimestamp,
endTime: startTime! + timestamp,
relativeEndTime: timestamp,
data: {
stage,
previousStage: pStage,
timeToStage,
mountedPlacements: data.mountedPlacements,
timingId: data.timingId,
source: data.source,
metadata: data.metadata ?? {},
dependencyChanges: data.dependencyChanges,
},
}),
),
)
const tti =
startTime !== null &&
endTime !== null &&
hasObserverSupport &&
isInCompleteState &&
!didImmediateSend
? endTime - startTime
: null
const ttr =
startTime !== null && previousStage !== INFORMATIVE_STAGES.TIMEOUT
? lastTimedEvent - startTime
: null
if (
lastAction &&
endTime !== null &&
previousStageEndTime !== null &&
previousStage !== INFORMATIVE_STAGES.TIMEOUT
) {
const lastStageToLastRender = lastRenderEnd - previousStageEndTime
const lastStageToEnd = endTime - previousStageEndTime
spans.push({
type: 'ttr',
description: 'render',
startTime: startTime as unknown as number,
endTime: lastRenderEnd,
relativeEndTime: lastRenderEnd - (startTime ?? 0),
entry: measures.ttr,
data: {
mountedPlacements: lastAction.mountedPlacements,
timingId: lastAction.timingId,
stage: isInCompleteState
? INFORMATIVE_STAGES.RENDERED
: INFORMATIVE_STAGES.INCOMPLETE_RENDER,
previousStage,
timeToStage: lastStageToLastRender,
dependencyChanges,
},
})
if (hasObserverSupport && isInCompleteState && !didImmediateSend) {
const lastRenderToEndTime = endTime - lastRenderEnd
spans.push({
type: 'tti',
description: 'interactive',
startTime: startTime as unknown as number,
endTime: endTime as unknown as number,
relativeEndTime: (endTime as unknown as number) - (startTime ?? 0),
entry: measures.tti,
data: {
stage: INFORMATIVE_STAGES.INTERACTIVE,
previousStage: INFORMATIVE_STAGES.RENDERED,
timeToStage: lastRenderToEndTime,
mountedPlacements: lastAction.mountedPlacements,
timingId: lastAction.timingId,
dependencyChanges: 0,
},
})
} else if (lastStageToEnd > lastStageToLastRender) {
const difference = lastStageToEnd - lastStageToLastRender
spans.push({
type: 'render',
description: 'incomplete render',
startTime: lastRenderEnd,
endTime: lastRenderEnd + difference,
relativeEndTime: lastRenderEnd + difference,
data: {
stage: INFORMATIVE_STAGES.INCOMPLETE_RENDER,
previousStage: isInCompleteState
? INFORMATIVE_STAGES.RENDERED
: INFORMATIVE_STAGES.INCOMPLETE_RENDER,
timeToStage: difference,
mountedPlacements: lastAction.mountedPlacements,
timingId: lastAction.timingId,
dependencyChanges: 0,
},
})
}
}
const loadingStagesSpans = Object.values(spans).filter(
({ data: { previousStage: pStage } }) =>
pStage && loadingStages.includes(pStage),
)
const loadingStagesDuration = loadingStagesSpans.reduce(
(total, { data: { timeToStage = 0 } }) => total + timeToStage,
0,
)
return {
id: timingId ?? lastAction?.timingId ?? 'unknown',
tti,
ttr,
isFirstLoad,
lastStage: previousStage,
timeSpent,
counts,
durations,
includedStages: [...includedStages],
handled: isInCompleteState,
hadError: ERROR_STAGES.includes(previousStage),
loadingStagesDuration,
spans,
flushReason,
}
}