@zendesk/retrace
Version:
define and capture Product Operation Traces along with computed metrics with an optional friendly React beacon API
737 lines (624 loc) • 22 kB
text/typescript
/* eslint-disable no-underscore-dangle */
/**
* 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 type { DependencyList } from 'react'
import {
ACTION_TYPE,
DEFAULT_DEBOUNCE_MS,
DEFAULT_LOADING_STAGES,
DEFAULT_TIMEOUT_MS,
ERROR_STAGES,
INFORMATIVE_STAGES,
MARKER,
OBSERVER_SOURCE,
} from './constants'
import type { DebounceOptionsRef, FlushReason } from './debounce'
import { debounce, TimeoutReason } from './debounce'
import { performanceMark, performanceMeasure } from './performanceMark'
import type {
Action,
ActionWithStateMetadata,
DynamicActionLogOptions,
ReportArguments,
ReportFnV1,
ShouldResetOnDependencyChange,
SpanAction,
StageChangeAction,
StaticActionLogOptions,
WithOnInternalError,
} from './types'
import type { DistributiveOmit } from './typescriptHelpers'
import {
every,
getCurrentBrowserSupportForNonResponsiveStateDetection,
noop,
} from './utilities'
const NO_IMMEDIATE_SEND_STAGES = [] as const
const NO_FINAL_STAGES = [] as const
export class ActionLog<CustomMetadata extends Record<string, unknown>> {
private actions: ActionWithStateMetadata[] = []
private onActionAddedCallback:
| ((actionLog: ActionLog<CustomMetadata>) => void)
| undefined = undefined
private lastStage: string = INFORMATIVE_STAGES.INITIAL
private lastStageUpdatedAt = performance.now()
private get lastStageEntry(): PerformanceEntry | undefined {
const lastStageChangeEntry = this.lastStageChange?.entry
if (lastStageChangeEntry) return lastStageChangeEntry
const lastRenderEntry = this.actions.find(
(action) => action.type === ACTION_TYPE.RENDER,
)?.entry
return lastRenderEntry?.startMark ?? lastRenderEntry
}
private lastStageBySource: Map<string, string> = new Map()
finalStages: readonly string[] = NO_FINAL_STAGES
loadingStages: readonly string[] = DEFAULT_LOADING_STAGES
immediateSendReportStages: readonly string[] = NO_IMMEDIATE_SEND_STAGES
private dependenciesBySource: Map<string, DependencyList> = new Map()
private hasReportedAtLeastOnce = false
private flushUponDeactivation = false
customMetadataBySource: Map<string, CustomMetadata> = new Map()
reportedErrors: WeakSet<object> = new WeakSet()
/**
* Returns the PerformanceEntry from the last render, or undefined if no render has completed.
* Enables verifying whether timing beacons have rendered even outside the scope of the timingHook.
*/
getLastRenderedActionEntry(): PerformanceEntry | undefined {
return this.actions.find((action) => action.type === ACTION_TYPE.RENDER)
?.entry
}
getActions(): ActionWithStateMetadata[] {
return this.actions
}
/**
* Clear performance marks that were added by this ActionLog instance.
*/
private clearPerformanceMarks(): void {
this.actions.forEach((action) => {
if (!action.entry.name) return
try {
if (action.entry instanceof PerformanceMeasure) {
performance.clearMeasures(action.entry.name)
}
} catch {
// ignore
}
})
}
/**
* Clear parts of the internal state, so it's ready for the next measurement.
*/
clear(): void {
if (this.willFlushTimeout) {
clearTimeout(this.willFlushTimeout)
this.willFlushTimeout = undefined
// flush immediately
this.debouncedTrigger.flush('clear')
} else {
this.debouncedTrigger.cancel()
}
this.stopObserving()
this.clearPerformanceMarks()
this.actions = []
this.lastStage = INFORMATIVE_STAGES.INITIAL
this.lastStageUpdatedAt = performance.now()
this.lastStageBySource.clear()
}
/**
* Complete reset of internal state,
* except for configuration options which are always updated on render.
*/
reset(): void {
this.debouncedTrigger.reset()
this.clear()
this.ensureReporting()
this.hasReportedAtLeastOnce = false
this.placementsCurrentlyRenderable.clear()
}
private reportFn: ReportFnV1<CustomMetadata> = noop
private shouldResetOnDependencyChangeFnBySource: Map<
string,
ShouldResetOnDependencyChange
> = new Map()
private id = 'default'
wasImported = false
onInternalError: Required<
WithOnInternalError<CustomMetadata>
>['onInternalError'] =
// only a default, to be overridden in usage
// eslint-disable-next-line no-console
console.error
private minimumExpectedSimultaneousBeacons?: number
private placementsCurrentlyRenderable = new Set<string>()
private waitForBeaconActivation: readonly string[] = []
get isInUse(): boolean {
return this.placementsCurrentlyRenderable.size > 0
}
getId(): string {
return this.id
}
constructor(options: StaticActionLogOptions<string, CustomMetadata>) {
this.updateStaticOptions(options)
}
updateStaticOptions({
debounceMs,
timeoutMs,
finalStages,
loadingStages,
immediateSendReportStages,
minimumExpectedSimultaneousBeacons,
waitForBeaconActivation,
flushUponDeactivation,
reportFn,
onActionAddedCallback,
onInternalError,
}: StaticActionLogOptions<string, CustomMetadata>): void {
if (typeof minimumExpectedSimultaneousBeacons === 'number') {
this.minimumExpectedSimultaneousBeacons =
minimumExpectedSimultaneousBeacons
}
if (onInternalError) this.onInternalError = onInternalError
if (reportFn) this.reportFn = reportFn
if (onActionAddedCallback) {
this.onActionAddedCallback = onActionAddedCallback
}
this.debounceOptionsRef.debounceMs = debounceMs ?? DEFAULT_DEBOUNCE_MS
this.debounceOptionsRef.timeoutMs = timeoutMs ?? DEFAULT_TIMEOUT_MS
this.finalStages = finalStages ?? NO_FINAL_STAGES
this.loadingStages = loadingStages ?? DEFAULT_LOADING_STAGES
this.immediateSendReportStages =
immediateSendReportStages ?? NO_IMMEDIATE_SEND_STAGES
this.flushUponDeactivation = flushUponDeactivation ?? false
this.waitForBeaconActivation = waitForBeaconActivation ?? []
}
/**
* Use to import internal state from another ActionLog instance.
*/
importState(otherLog: ActionLog<CustomMetadata>): void {
this.hasReportedAtLeastOnce =
this.hasReportedAtLeastOnce || otherLog.hasReportedAtLeastOnce
if (this.reportFn === noop) this.reportFn = otherLog.reportFn
if (!this.onInternalError) this.onInternalError = otherLog.onInternalError
if (
otherLog.lastStage !== INFORMATIVE_STAGES.INITIAL &&
otherLog.lastStageUpdatedAt > this.lastStageUpdatedAt
) {
this.lastStage = otherLog.lastStage
this.lastStageUpdatedAt = otherLog.lastStageUpdatedAt
}
otherLog.lastStageBySource.forEach((stage, source) => {
this.lastStageBySource.set(source, stage)
})
otherLog.dependenciesBySource.forEach((dependencies, source) => {
this.dependenciesBySource.set(source, dependencies)
})
otherLog.placementsCurrentlyRenderable.forEach((placement) => {
this.placementsCurrentlyRenderable.add(placement)
})
otherLog.shouldResetOnDependencyChangeFnBySource.forEach(
(shouldResetOnDependencyChangeFn, source) => {
this.shouldResetOnDependencyChangeFnBySource.set(
source,
shouldResetOnDependencyChangeFn,
)
},
)
otherLog.actions.forEach((action) => {
this.insertActionInOrder(action)
})
this.onActionAdded()
otherLog.setAsImported()
}
setAsImported(): void {
this.wasImported = true
this.debouncedTrigger.reset()
this.clear()
this.disableReporting()
this.dispose()
}
updateOptions(
{
id,
reportFn,
shouldResetOnDependencyChangeFn,
onInternalError,
onActionAddedCallback,
}: DynamicActionLogOptions<CustomMetadata>,
source: string,
): void {
// any source can change the ID
this.id = id
if (onInternalError) this.onInternalError = onInternalError
if (reportFn) this.reportFn = reportFn
if (shouldResetOnDependencyChangeFn) {
this.shouldResetOnDependencyChangeFnBySource.set(
source,
shouldResetOnDependencyChangeFn,
)
}
if (onActionAddedCallback) {
this.onActionAddedCallback = onActionAddedCallback
}
}
/**
* Inserts an action while maintaining order
* @returns {boolean} true when inserted at the very end of actions
*/
private insertActionInOrder(action: ActionWithStateMetadata): boolean {
const insertBeforeIndex = this.actions.findIndex(
(a) => a.timestamp > action.timestamp,
)
// do not insert unresponsive tasks as first in order, cause they could have started before the actual render
const index =
action.type === ACTION_TYPE.UNRESPONSIVE && insertBeforeIndex === 0
? 1
: insertBeforeIndex
this.actions.splice(index === -1 ? this.actions.length : index, 0, action)
return index === -1
}
addSpan(info: DistributiveOmit<SpanAction, 'timestamp' | 'marker'>): void {
this.addAction(
{
...info,
marker: MARKER.START,
timestamp: info.entry.startTime,
},
{
...info,
marker: MARKER.END,
timestamp: info.entry.startTime + info.entry.duration,
},
)
}
addAction(...actions: Action[]): void {
if (!this.isCapturingData) return
actions.forEach((action) => {
this.insertActionInOrder({
...action,
mountedPlacements: [...this.placementsCurrentlyRenderable],
timingId: this.id,
})
})
this.onActionAdded()
}
private addStageChange(
info: Omit<StageChangeAction, 'type' | 'marker' | 'entry' | 'timestamp'>,
previousStage: string = INFORMATIVE_STAGES.INITIAL,
) {
const { stage, renderEntry } = info
const previousStageEntry = this.lastStageEntry
const measureName = previousStageEntry
? `${this.id}/${info.source}/${previousStage}-till-${stage}`
: `${this.id}/${info.source}/start-${stage}`
const entry = previousStageEntry
? performanceMeasure(measureName, previousStageEntry, renderEntry)
: performanceMark(measureName, { startTime: renderEntry?.startTime })
this.addAction({
...info,
type: ACTION_TYPE.STAGE_CHANGE,
marker: MARKER.POINT,
entry: Object.assign(entry, { startMark: previousStageEntry }),
timestamp: entry.startTime + entry.duration,
})
}
private willFlushTimeout?: ReturnType<typeof setTimeout>
private onActionAdded(): void {
this.onActionAddedCallback?.(this)
if (this.isInImmediateSendStage) {
this.stopObserving()
if (this.willFlushTimeout) return
// we want to wait till the next frame in case the render completes
// if an error is thrown, the component will unmount and we have a more complete picture
this.willFlushTimeout = setTimeout(() => {
this.willFlushTimeout = undefined
this.debouncedTrigger.flush('immediate send')
}, 0)
} else {
this.observe()
this.debouncedTrigger()
}
}
get lastStageChange(): StageChangeAction | undefined {
for (let i = this.actions.length - 1; i >= 0; i--) {
const action = this.actions[i]
if (action && action.type === ACTION_TYPE.STAGE_CHANGE) return action
}
return undefined
}
markStage(
info: Omit<StageChangeAction, 'type' | 'marker' | 'timestamp' | 'entry'>,
): void {
const { stage, source } = info
const previousStageForThisSource = this.lastStageBySource.get(source)
// we don't want different beacons racing with one another with re-renders and switching stages
if (previousStageForThisSource === stage) return
const previousStage = this.lastStage
this.lastStage = stage
this.lastStageUpdatedAt = performance.now()
this.lastStageBySource.set(source, stage)
if (!this.isInFinalStage) {
// we might have just moved back from a final stage to a non-final one
// in such case, ensure reporting is enabled and reset state:
this.ensureReporting()
}
if (!this.isCapturingData) return
if (previousStage !== stage || this.actions.length === 0) {
this.addStageChange(
info,
this.actions.length === 0 ? INFORMATIVE_STAGES.INITIAL : previousStage,
)
}
}
private _shouldReport = true
get shouldReport(): boolean {
return this._shouldReport
}
ensureReporting(): void {
if (this._shouldReport) return
// should enable reporting if not in final stage!
this._shouldReport = true
// and starting from scratch:
this.clear()
}
disableReporting(): void {
this._shouldReport = false
}
private observer =
typeof PerformanceObserver !== 'undefined' &&
new PerformanceObserver((entryList: PerformanceObserverEntryList) => {
if (!this.isCapturingData) return
const entries = entryList.getEntries()
for (const entry of entries) {
this.addSpan({
type: ACTION_TYPE.UNRESPONSIVE,
source: OBSERVER_SOURCE,
entry,
})
}
})
private _isObserving = false
get isObserving(): boolean {
return this._isObserving
}
private observe(): void {
// this is a no-op on browsers that don't support 'longtask',
// but let's guard anyway:
if (
this.observer &&
getCurrentBrowserSupportForNonResponsiveStateDetection() &&
!this.isObserving
) {
this.observer.observe({ entryTypes: ['longtask'] })
this._isObserving = true
}
}
stopObserving(): void {
if (!this._isObserving || !this.observer) return
this.observer.disconnect()
this._isObserving = false
}
private isCapturingDataBySource: Map<string, boolean> = new Map()
onBeaconRemoved(source: string): void {
this.isCapturingDataBySource.delete(source)
this.dependenciesBySource.delete(source)
this.shouldResetOnDependencyChangeFnBySource.delete(source)
this.placementsCurrentlyRenderable.delete(source)
if (!this.isInUse) this.dispose()
}
private dispose() {
this.onDisposeCallbacks.forEach((callback) => void callback())
}
private onDisposeCallbacks = new Map<string, () => void>()
/**
* schedule action to be called once the ActionLog is no longer used
* @param callback
*/
onDispose(name: string, callback: () => void): void {
this.onDisposeCallbacks.set(name, callback)
}
get isCapturingData(): boolean {
// if at least one source is inactive, all of reporting is inactive
return (
this.shouldReport &&
this.isCapturingDataBySource.size > 0 &&
this.waitForBeaconActivation.every((placement) =>
this.placementsCurrentlyRenderable.has(placement),
) &&
every(this.isCapturingDataBySource.values(), Boolean)
)
}
setActive(active: boolean, source: string): void {
this.placementsCurrentlyRenderable.add(source)
const wasActive = this.isCapturingData
const newlyDeactivated = wasActive !== active && !active
if (
this.flushUponDeactivation &&
newlyDeactivated &&
this.lastStage !== INFORMATIVE_STAGES.INITIAL
) {
// flush any previous measurements
this.debouncedTrigger.flush('deactivation')
}
this.isCapturingDataBySource.set(source, active)
const { isCapturingData } = this
const newlyActivated = wasActive !== isCapturingData && isCapturingData
if (newlyActivated) {
// clear state upon activation
this.clear()
}
}
get isInFinalStage(): boolean {
return (
this.finalStages.every((stage) => ERROR_STAGES.includes(stage)) ||
this.finalStages.includes(this.lastStage)
)
}
get isInImmediateSendStage(): boolean {
return (
ERROR_STAGES.includes(this.lastStage) ||
this.immediateSendReportStages.includes(this.lastStage)
)
}
private getRenderedCountBySource(sourceToFilterBy: string): number {
return this.actions.filter(
({ type, marker, source }) =>
source === sourceToFilterBy &&
type === ACTION_TYPE.RENDER &&
marker === MARKER.END,
).length
}
private get lastRenderAction(): ActionWithStateMetadata | undefined {
return [...this.actions]
.reverse()
.find(({ type }) => type === ACTION_TYPE.RENDER)
}
private markDependencyChange(mark: PerformanceMark, source: string): void {
this.addAction({
type: ACTION_TYPE.DEPENDENCY_CHANGE,
entry: mark,
timestamp: mark.startTime,
marker: MARKER.POINT,
source,
})
}
onExternalDependenciesChange(
newDependencies: DependencyList,
timestamp: PerformanceMark,
source: string,
): void {
// any dependency change should re-enable reporting:
this.ensureReporting()
const oldDependencies = this.dependenciesBySource.get(source) ?? []
this.dependenciesBySource.set(source, newDependencies)
// the first time this runs we wouldn't have captured anything
if (this.getRenderedCountBySource(source) === 0) return
const shouldResetFn =
this.shouldResetOnDependencyChangeFnBySource.get(source)
const shouldReset =
(oldDependencies.length > 0 || newDependencies.length > 0) &&
(!shouldResetFn || shouldResetFn(oldDependencies, newDependencies))
this.markDependencyChange(timestamp, source)
if (!shouldReset) return
// we can flush previous measurement (if any) and completely reset
this.debouncedTrigger.flush('dependency change')
this.reset()
}
private trigger = (flushReason: FlushReason): boolean | undefined => {
const firstAction = this.actions[0]
const lastAction = this.actions[this.actions.length - 1]
const { timeoutMs } = this.debounceOptionsRef
// The second part or the OR is a workaround for the situation where someone puts their laptop to sleep
// while a feature is in a non-final stage and then opens it many minutes/hours later.
// Timer fires a looong long time after and may not be timed out.
// For this reason we calculate the actual time that passed as an additional guard.
const timedOut =
flushReason === TimeoutReason ||
(typeof timeoutMs === 'number' &&
(lastAction?.timestamp ?? 0) - (firstAction?.timestamp ?? 0) >
timeoutMs)
const shouldContinue =
timedOut || this.isInFinalStage || this.isInImmediateSendStage
if (!shouldContinue) {
// UI is not ready yet (probably data loading),
// there's gonna be more renders soon...
// return true to keep the timeout
return true
}
if (this.actions.length === 0 || !firstAction || !lastAction) {
// nothing to report:
this.stopObserving()
return undefined
}
const highestNumberOfActiveBeaconsCountAtAnyGivenTime =
this.actions
.map((action) => action.mountedPlacements.length)
.sort()
.reverse()[0] ?? 0
const hadReachedTheRequiredActiveBeaconsCount =
typeof this.minimumExpectedSimultaneousBeacons !== 'number' ||
highestNumberOfActiveBeaconsCountAtAnyGivenTime >=
this.minimumExpectedSimultaneousBeacons
const { lastRenderAction } = this
const metadataValues = [...this.customMetadataBySource.values()]
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const metadata: CustomMetadata = Object.assign({}, ...metadataValues)
const detail = {
metadata,
timingId: this.id,
isFirstLoad: !this.hasReportedAtLeastOnce,
maximumActiveBeaconsCount:
highestNumberOfActiveBeaconsCountAtAnyGivenTime,
minimumExpectedSimultaneousBeacons:
this.minimumExpectedSimultaneousBeacons,
flushReason:
typeof flushReason === 'symbol'
? flushReason.description ?? 'manual'
: flushReason,
}
let tti: PerformanceMeasure | undefined
let ttr: PerformanceMeasure | undefined
if (timedOut) {
this.addStageChange(
{
stage: INFORMATIVE_STAGES.TIMEOUT,
source: 'timeout',
},
this.lastStage,
)
} else if (lastRenderAction) {
ttr = performanceMeasure(
`${this.id}/ttr`,
firstAction.entry.startMark ?? firstAction.entry,
lastRenderAction.entry.endMark ?? lastRenderAction.entry,
detail,
)
// add a measure so we can use it in Lighthouse runs
tti = performanceMeasure(
`${this.id}/tti`,
firstAction.entry.startMark ?? firstAction.entry,
lastAction.entry.endMark ?? lastAction.entry,
detail,
)
}
const reportArgs: ReportArguments<CustomMetadata> = {
...detail,
actions: this.actions,
loadingStages: this.loadingStages,
finalStages: this.finalStages,
immediateSendReportStages:
this.immediateSendReportStages.length > 0
? [...ERROR_STAGES, ...this.immediateSendReportStages]
: ERROR_STAGES,
measures: { tti, ttr },
}
if (this.reportFn === noop) {
this.onInternalError(
new Error(
`useTiming: reportFn was not set, please set it to a function that will be called with the timing report`,
),
reportArgs,
)
}
if (hadReachedTheRequiredActiveBeaconsCount) {
this.reportFn(reportArgs)
}
// clear slate for next re-render (stop observing) and disable reporting
this.clear()
this.disableReporting()
if (!timedOut && hadReachedTheRequiredActiveBeaconsCount) {
this.hasReportedAtLeastOnce = true
}
return undefined
}
private debounceOptionsRef: DebounceOptionsRef<[]> = {
fn: this.trigger,
// these are just the defaults and can be overwritten by the options passed to the constructor:
debounceMs: DEFAULT_DEBOUNCE_MS,
timeoutMs: DEFAULT_TIMEOUT_MS,
}
private debouncedTrigger = debounce(this.debounceOptionsRef)
}