@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
JavaScript
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