UNPKG

@zendesk/react-measure-timing-hooks

Version:

react hooks for measuring time to interactive and time to render of components

1,223 lines (1,203 loc) 178 kB
import * as __WEBPACK_EXTERNAL_MODULE_react__ from "react"; /******/ // The require scope /******/ var __interna_require__ = {}; /******/ /************************************************************************/ /******/ /* webpack/runtime/define property getters */ /******/ (() => { /******/ // define getter functions for harmony exports /******/ __interna_require__.d = (exports, definition) => { /******/ for(var key in definition) { /******/ if(__interna_require__.o(definition, key) && !__interna_require__.o(exports, key)) { /******/ Object.defineProperty(exports, key, { enumerable: true, get: definition[key] }); /******/ } /******/ } /******/ }; /******/ })(); /******/ /******/ /* webpack/runtime/hasOwnProperty shorthand */ /******/ (() => { /******/ __interna_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop)) /******/ })(); /******/ /******/ /* webpack/runtime/make namespace object */ /******/ (() => { /******/ // define __esModule on exports /******/ __interna_require__.r = (exports) => { /******/ if(typeof Symbol !== 'undefined' && Symbol.toStringTag) { /******/ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' }); /******/ } /******/ Object.defineProperty(exports, '__esModule', { value: true }); /******/ }; /******/ })(); /******/ /************************************************************************/ var __webpack_exports__ = {}; /*!**********************************!*\ !*** ./src/main.ts + 37 modules ***! \**********************************/ // ESM COMPAT FLAG __interna_require__.r(__webpack_exports__); // EXPORTS __interna_require__.d(__webpack_exports__, { ACTION_TYPE: () => (/* reexport */ ACTION_TYPE), ActionLog: () => (/* reexport */ ActionLog), ActionLogCache: () => (/* reexport */ ActionLogCache), DEADLINE_BUFFER: () => (/* reexport */ DEADLINE_BUFFER), DEFAULT_DEBOUNCE_DURATION: () => (/* reexport */ DEFAULT_DEBOUNCE_DURATION), DEFAULT_DEBOUNCE_MS: () => (/* reexport */ DEFAULT_DEBOUNCE_MS), DEFAULT_GARBAGE_COLLECT_MS: () => (/* reexport */ DEFAULT_GARBAGE_COLLECT_MS), DEFAULT_INTERACTIVE_TIMEOUT_DURATION: () => (/* reexport */ DEFAULT_INTERACTIVE_TIMEOUT_DURATION), DEFAULT_LOADING_STAGES: () => (/* reexport */ DEFAULT_LOADING_STAGES), DEFAULT_STAGES: () => (/* reexport */ DEFAULT_STAGES), DEFAULT_TIMEOUT_MS: () => (/* reexport */ DEFAULT_TIMEOUT_MS), ERROR_STAGES: () => (/* reexport */ ERROR_STAGES), INFORMATIVE_STAGES: () => (/* reexport */ INFORMATIVE_STAGES), MARKER: () => (/* reexport */ MARKER), OBSERVER_SOURCE: () => (/* reexport */ OBSERVER_SOURCE), ReactMeasureErrorBoundary: () => (/* reexport */ ReactMeasureErrorBoundary), TraceManager: () => (/* reexport */ TraceManager), Tracer: () => (/* reexport */ Tracer), adjustTimestampBy: () => (/* reexport */ adjustTimestampBy), convertTraceToRUM: () => (/* reexport */ convertTraceToRUM), createCPUIdleProcessor: () => (/* reexport */ createCPUIdleProcessor), createDefaultPerformanceEntryDeduplicationStrategy: () => (/* reexport */ createDefaultPerformanceEntryDeduplicationStrategy), createQuietWindowDurationCalculator: () => (/* reexport */ createQuietWindowDurationCalculator), createTraceRecording: () => (/* reexport */ createTraceRecording), debounce: () => (/* reexport */ debounce), defaultEmbedSpanSelector: () => (/* reexport */ defaultEmbedSpanSelector), ensureTimestamp: () => (/* reexport */ ensureTimestamp), generateReport: () => (/* reexport */ generateReport), generateTimingHooks: () => (/* reexport */ generateTimingHooks), generateUseBeacon: () => (/* reexport */ generateUseBeacon), getBestSupportedEntryTypes: () => (/* reexport */ getBestSupportedEntryTypes), getCommonUrlForTracing: () => (/* reexport */ getCommonUrlForTracing), getComputedSpans: () => (/* reexport */ getComputedSpans), getComputedValues: () => (/* reexport */ getComputedValues), getCurrentBrowserSupportForNonResponsiveStateDetection: () => (/* reexport */ getCurrentBrowserSupportForNonResponsiveStateDetection), getEpochCorrectedForDrift: () => (/* reexport */ getEpochCorrectedForDrift), getExternalApi: () => (/* reexport */ getExternalApi), getSpanFromPerformanceEntry: () => (/* reexport */ getSpanFromPerformanceEntry), getSpanSummaryAttributes: () => (/* reexport */ getSpanSummaryAttributes), isRenderEntry: () => (/* reexport */ isRenderEntry), match: () => (/* reexport */ matchSpan_namespaceObject), observePerformanceWithTraceManager: () => (/* reexport */ observePerformanceWithTraceManager), performanceMark: () => (/* reexport */ performanceMark), performanceMeasure: () => (/* reexport */ performanceMeasure), switchFn: () => (/* reexport */ switchFn), useActionLog: () => (/* reexport */ useActionLog), useOnErrorBoundaryDidCatch: () => (/* reexport */ useOnErrorBoundaryDidCatch), useTiming: () => (/* reexport */ useTiming), useTimingMeasurement: () => (/* reexport */ useTimingMeasurement) }); // NAMESPACE OBJECT: ./src/v3/matchSpan.ts var matchSpan_namespaceObject = {}; __interna_require__.r(matchSpan_namespaceObject); __interna_require__.d(matchSpan_namespaceObject, { continueWithErrorStatus: () => (continueWithErrorStatus), fromDefinition: () => (fromDefinition), not: () => (not), whenIdle: () => (whenIdle), withAllConditions: () => (withAllConditions), withAttributes: () => (withAttributes), withComponentRenderCount: () => (withComponentRenderCount), withLabel: () => (withLabel), withMatchingRelations: () => (withMatchingRelations), withName: () => (withName), withOccurrence: () => (withOccurrence), withOneOfConditions: () => (withOneOfConditions), withPerformanceEntryName: () => (withPerformanceEntryName), withStatus: () => (withStatus), withType: () => (withType) }); ;// ./src/constants.ts /** * 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. */ const DEFAULT_STAGES = { INACTIVE: 'inactive', LOADING: 'loading', LOADING_MORE: 'loading-more', ERROR: 'error', ERROR_BOUNDARY: 'error-caught-by-error-boundary', READY: 'ready', }; const DEFAULT_LOADING_STAGES = [ DEFAULT_STAGES.LOADING, DEFAULT_STAGES.LOADING_MORE, ]; const ERROR_STAGES = [ DEFAULT_STAGES.ERROR, DEFAULT_STAGES.ERROR_BOUNDARY, ]; const INFORMATIVE_STAGES = { INITIAL: 'initial', TIMEOUT: 'timeout', DEPENDENCY_CHANGE: 'dependency-change', INTERACTIVE: 'interactive', RENDERED: 'rendered', INCOMPLETE_RENDER: 'incomplete-render', }; const ACTION_TYPE = { RENDER: 'render', UNRESPONSIVE: 'unresponsive', STAGE_CHANGE: 'stage-change', DEPENDENCY_CHANGE: 'dependency-change', }; const MARKER = { START: 'start', END: 'end', POINT: 'point', }; const OBSERVER_SOURCE = 'observer'; const DEFAULT_GARBAGE_COLLECT_MS = 2000; const DEFAULT_DEBOUNCE_MS = 500; const DEFAULT_TIMEOUT_MS = 45000; ;// ./src/debounce.ts /** * 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. */ const DebounceReason = Symbol('debounce'); const TimeoutReason = Symbol('timeout'); /** * A simple debounce function that is easier to test against than the lodash one. * In addition it offers a way to check whether the last call was due to a timeout or not, * and a way to manually clear that timeout state. * Options may change even after the debounce was created by the means of a ref. */ const debounce = (optionsRef) => { let timeoutTimer; let debounceTimer; let lastArgs; const cancel = () => { if (debounceTimer) clearTimeout(debounceTimer); debounceTimer = undefined; }; const reset = () => { cancel(); if (timeoutTimer) clearTimeout(timeoutTimer); timeoutTimer = undefined; const args = lastArgs; lastArgs = undefined; return args; }; const flush = (reason = 'manual') => { const args = lastArgs; if (args) { const shouldKeepTimeout = optionsRef.fn(...args, reason); if (shouldKeepTimeout) { cancel(); } else { reset(); } return true; } reset(); return false; }; const getIsScheduled = () => Boolean(debounceTimer) || Boolean(timeoutTimer); return Object.assign((...args) => { lastArgs = args; if (debounceTimer) clearTimeout(debounceTimer); debounceTimer = setTimeout(() => flush(DebounceReason), optionsRef.debounceMs); if (!timeoutTimer && typeof optionsRef.timeoutMs === 'number') { timeoutTimer = setTimeout(() => { flush(TimeoutReason); }, optionsRef.timeoutMs); } }, { flush, cancel, reset, getIsScheduled, }); }; ;// ./src/performanceMark.ts /** * 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. */ const getTimingMarkName = (name) => `useTiming: ${name}`; const performanceMark = (name, markOptions, realMark = false) => { const markName = getTimingMarkName(name); // default to a "fake performance.mark", to improve UX in the profiler // (otherwise we have thousands of little marks sprinkled everywhere) if (realMark) { // Since old browsers (like >1yr old Firefox/Gecko) unfortunately behaves differently to other browsers, // in that it doesn't immediately return the instance of PerformanceMark object // so we sort-of polyfill it cheaply below. // see: https://bugzilla.mozilla.org/show_bug.cgi?id=1724645 try { const mark = performance.mark(markName, markOptions); if (mark) return mark; } catch { // do nothing, polyfill below } } // polyfill: return { name: markName, duration: 0, startTime: markOptions?.startTime ?? performance.now(), entryType: 'mark', toJSON: () => ({}), // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment detail: markOptions?.detail ?? null, }; }; const performanceMeasure = (name, startMark, endMark, detail) => { // We want to use performance.mark, instead of performance.now or Date.now, // because those named metrics will then show up in the profiler and in Lighthouse audits // see: https://web.dev/user-timings/ // incidentally, this also makes testing waaay easier, because we don't have to deal with timestamps const measureName = getTimingMarkName(name); const end = endMark ? endMark.startTime + endMark.duration : performance.now(); // some old browsers might not like performance.measure / performance.mark // we don't want to crash due to reporting, so we'll polyfill instead try { const measure = performance.measure(measureName, { start: startMark.startTime + startMark.duration, end, detail: detail ?? {}, }); if (measure) return measure; } catch { // do nothing, polyfill below } return { name: measureName, duration: end - startMark.startTime, startTime: startMark.startTime, entryType: 'measure', toJSON: () => ({}), detail: detail ?? null, }; }; ;// ./src/utilities.ts /** * 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. */ const noop = () => { /* noop */ }; const every = (iterable, predicate) => { for (const value of iterable) if (!predicate(value)) return false; return true; }; let memoizedCurrentBrowserSupportForNonResponsiveStateDetection; /** can be easily mocked if needed */ const getCurrentBrowserSupportForNonResponsiveStateDetection = // eslint-disable-next-line no-return-assign () => memoizedCurrentBrowserSupportForNonResponsiveStateDetection ?? (memoizedCurrentBrowserSupportForNonResponsiveStateDetection = typeof PerformanceObserver !== 'undefined' && Array.isArray(PerformanceObserver.supportedEntryTypes) && PerformanceObserver.supportedEntryTypes.includes('longtask')); const resetMemoizedCurrentBrowserSupportForNonResponsiveStateDetection = () => { memoizedCurrentBrowserSupportForNonResponsiveStateDetection = undefined; }; ;// ./src/ActionLog.ts /* 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. */ const NO_IMMEDIATE_SEND_STAGES = []; const NO_FINAL_STAGES = []; class ActionLog { get lastStageEntry() { 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; } /** * 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() { return this.actions.find((action) => action.type === ACTION_TYPE.RENDER) ?.entry; } getActions() { return this.actions; } /** * Clear performance marks that were added by this ActionLog instance. */ clearPerformanceMarks() { 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() { 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() { this.debouncedTrigger.reset(); this.clear(); this.ensureReporting(); this.hasReportedAtLeastOnce = false; this.placementsCurrentlyRenderable.clear(); } get isInUse() { return this.placementsCurrentlyRenderable.size > 0; } getId() { return this.id; } constructor(options) { this.actions = []; this.onActionAddedCallback = undefined; this.lastStage = INFORMATIVE_STAGES.INITIAL; this.lastStageUpdatedAt = performance.now(); this.lastStageBySource = new Map(); this.finalStages = NO_FINAL_STAGES; this.loadingStages = DEFAULT_LOADING_STAGES; this.immediateSendReportStages = NO_IMMEDIATE_SEND_STAGES; this.dependenciesBySource = new Map(); this.hasReportedAtLeastOnce = false; this.flushUponDeactivation = false; this.customMetadataBySource = new Map(); this.reportedErrors = new WeakSet(); this.reportFn = noop; this.shouldResetOnDependencyChangeFnBySource = new Map(); this.id = 'default'; this.wasImported = false; this.onInternalError = // only a default, to be overridden in usage // eslint-disable-next-line no-console console.error; this.placementsCurrentlyRenderable = new Set(); this.waitForBeaconActivation = []; this._shouldReport = true; this.observer = typeof PerformanceObserver !== 'undefined' && new PerformanceObserver((entryList) => { if (!this.isCapturingData) return; const entries = entryList.getEntries(); for (const entry of entries) { this.addSpan({ type: ACTION_TYPE.UNRESPONSIVE, source: OBSERVER_SOURCE, entry, }); } }); this._isObserving = false; this.isCapturingDataBySource = new Map(); this.onDisposeCallbacks = new Map(); this.trigger = (flushReason) => { 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 = 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; let ttr; 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 = { ...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; }; this.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, }; this.debouncedTrigger = debounce(this.debounceOptionsRef); this.updateStaticOptions(options); } updateStaticOptions({ debounceMs, timeoutMs, finalStages, loadingStages, immediateSendReportStages, minimumExpectedSimultaneousBeacons, waitForBeaconActivation, flushUponDeactivation, reportFn, onActionAddedCallback, onInternalError, }) { 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) { 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() { this.wasImported = true; this.debouncedTrigger.reset(); this.clear(); this.disableReporting(); this.dispose(); } updateOptions({ id, reportFn, shouldResetOnDependencyChangeFn, onInternalError, onActionAddedCallback, }, source) { // 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 */ insertActionInOrder(action) { 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) { this.addAction({ ...info, marker: MARKER.START, timestamp: info.entry.startTime, }, { ...info, marker: MARKER.END, timestamp: info.entry.startTime + info.entry.duration, }); } addAction(...actions) { if (!this.isCapturingData) return; actions.forEach((action) => { this.insertActionInOrder({ ...action, mountedPlacements: [...this.placementsCurrentlyRenderable], timingId: this.id, }); }); this.onActionAdded(); } addStageChange(info, previousStage = 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, }); } onActionAdded() { 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() { 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) { 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); } } get shouldReport() { return this._shouldReport; } ensureReporting() { if (this._shouldReport) return; // should enable reporting if not in final stage! this._shouldReport = true; // and starting from scratch: this.clear(); } disableReporting() { this._shouldReport = false; } get isObserving() { return this._isObserving; } observe() { // 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() { if (!this._isObserving || !this.observer) return; this.observer.disconnect(); this._isObserving = false; } onBeaconRemoved(source) { this.isCapturingDataBySource.delete(source); this.dependenciesBySource.delete(source); this.shouldResetOnDependencyChangeFnBySource.delete(source); this.placementsCurrentlyRenderable.delete(source); if (!this.isInUse) this.dispose(); } dispose() { this.onDisposeCallbacks.forEach((callback) => void callback()); } /** * schedule action to be called once the ActionLog is no longer used * @param callback */ onDispose(name, callback) { this.onDisposeCallbacks.set(name, callback); } get isCapturingData() { // 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, source) { 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() { return (this.finalStages.every((stage) => ERROR_STAGES.includes(stage)) || this.finalStages.includes(this.lastStage)); } get isInImmediateSendStage() { return (ERROR_STAGES.includes(this.lastStage) || this.immediateSendReportStages.includes(this.lastStage)); } getRenderedCountBySource(sourceToFilterBy) { return this.actions.filter(({ type, marker, source }) => source === sourceToFilterBy && type === ACTION_TYPE.RENDER && marker === MARKER.END).length; } get lastRenderAction() { return [...this.actions] .reverse() .find(({ type }) => type === ACTION_TYPE.RENDER); } markDependencyChange(mark, source) { this.addAction({ type: ACTION_TYPE.DEPENDENCY_CHANGE, entry: mark, timestamp: mark.startTime, marker: MARKER.POINT, source, }); } onExternalDependenciesChange(newDependencies, timestamp, source) { // 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(); } } ;// ./src/ActionLogCache.ts class ActionLogCache { makeWrapperRef(actionLog, initialId) { let ref = { activeId: initialId, ids: new Set([initialId]), actionLog, getCurrent: (id) => { const cachedRef = this.cache.get(id); const cachedActionLog = cachedRef?.actionLog; const activeRef = cachedRef ?? ref; if (cachedActionLog !== ref.actionLog) { // when ID changes, we transfer the state from the old actionLog to the new one // because of the edge case when the child with the new ID // is re-rendered *before* the parent beacon gets the new ID if (cachedActionLog && !ref.actionLog.wasImported) { cachedActionLog.importState(ref.actionLog); } else { activeRef.actionLog = ref.actionLog; ref.ids.forEach((refId) => { activeRef.ids.add(refId); }); } } this.cache.set(id, activeRef); activeRef.activeId = id; activeRef.ids.add(id); this.garbageCollectUnusedIdLater(activeRef); ref = activeRef; return activeRef.actionLog; }, }; return ref; } get(id) { const existingRef = this.cache.get(id); if (existingRef) return existingRef.getCurrent(id); return undefined; } makeGetOrCreateFn(id) { const existingRef = this.cache.get(id); if (existingRef) return existingRef.getCurrent; const actionLog = new ActionLog(this.options); const ref = this.makeWrapperRef(actionLog, id); actionLog.onDispose('gc', () => { ref.ids.forEach((refId) => { if (this.cache.get(refId)?.actionLog === actionLog) { this.cache.delete(refId); } }); }); this.cache.set(id, ref); return ref.getCurrent; } constructor({ garbageCollectMs, ...actionLogOptions }) { this.cache = new Map(); this.options = actionLogOptions; this.garbageCollectUnusedIdLater = debounce({ fn: (ref) => { ref.ids.forEach((id) => { if (id !== ref.activeId && this.cache.get(id) === ref) { this.cache.delete(id); ref.ids.delete(id); } }); }, debounceMs: garbageCollectMs, }); } } ;// ./src/generateReport.ts /** * 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. */ function generateReport({ actions, timingId, isFirstLoad = true, immediateSendReportStages = [], loadingStages = DEFAULT_LOADING_STAGES, flushReason = 'auto', measures, }) { const lastStart = {}; let lastRenderEnd = null; const timeSpent = {}; let startTime = null; let endTime = null; let lastDependencyChange = null; let dependencyChanges = 0; const counts = {}; let previousStageEndTime = null; let previousStage = INFORMATIVE_STAGES.INITIAL; const stageDescriptions = []; const durations = {}; const hasObserverSupport = getCurrentBrowserSupportForNonResponsiveStateDetection(); const allImmediateSendReportStages = [ ...immediateSendReportStages, INFORMATIVE_STAGES.TIMEOUT, ]; const lastAction = [...actions].reverse()[0]; const includedStages = new Set(); const spans = []; const markStage = ({ stage, action, }) => { // 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 }) => ({ 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, 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, endTime: endTime, relativeEndTime: endTime - (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, }; } ;// ./src/getExternalApi.ts /** used to generate timing API that can be used outside of React, or together with React */ const getExternalApi = ({ actionLogCache, idPrefix, placement, }) => { const getFullId = (idSuffix) => `${idPrefix}/${idSuffix}`; const getActionLogForIdIfExists = (idSuffix) => { const id = getFullId(idSuffix); const actionLog = actionLogCache.get(id); actionLog?.updateOptions({ id }, placement); return actionLog; }; const getActionLogForId = (idSuffix) => { const id = getFullId(idSuffix); const getActionLog = actionLogCache.makeGetOrCreateFn(id); const actionLog = getActionLog(id); actionLog.updateOptions({ id }, placement); return actionLog; }; let renderStartMark = null; return { getActionLogForId, getActionLogForIdIfExists, markRenderStart: (idSuffix) => { const id = getFullId(idSuffix); const actionLog = getActionLogForId(idSuffix); actionLog.ensureReporting(); actionLog.setActive(true, placement); renderStartMark = renderStartMark ?? performanceMark(`${id}/${placement}/render-start`); }, markRenderEnd: (idSuffix) => { const id = getFullId(idSuffix); const actionLog = getActionLogForId(idSuffix); actionLog.setActive(true, placement); if (!renderStartMark) { actionLog.onInternalError(new Error(`Component