@microsoft/windows-admin-center-sdk
Version:
Microsoft - Windows Admin Center Shell
472 lines (470 loc) • 24.8 kB
JavaScript
import { getCLS, getFCP, getFID, getLCP, getTTFB } from 'web-vitals';
import { Logging } from '../diagnostics/logging';
import { SmeWebTelemetry } from '../diagnostics/sme-web-telemetry';
/**
* Summarizes a sequence of measurements
*/
export class SmePerformanceSummary {
label;
startTime;
totalLoadTime;
timestamps;
}
/**
* Web Vitals metrics to track. See more details at https://web.dev/vitals/
*/
export var WebVitalFields;
(function (WebVitalFields) {
WebVitalFields["CLS"] = "CumulativeLayoutShift";
WebVitalFields["LCP"] = "LargestContentfulPaint";
WebVitalFields["FCP"] = "FirstContentfulPaint";
WebVitalFields["FID"] = "FirstInputDelay";
WebVitalFields["TTFB"] = "TimeToFirstByte";
WebVitalFields["TBT"] = "TotalBlockingTime";
WebVitalFields["TTI"] = "TimeToInteractive";
})(WebVitalFields || (WebVitalFields = {}));
/**
* List performance entry types that are relevant for our metrics.
*/
export var PerformanceEntryType;
(function (PerformanceEntryType) {
PerformanceEntryType["LongTask"] = "longtask";
PerformanceEntryType["Resource"] = "resource";
PerformanceEntryType["Mark"] = "mark";
PerformanceEntryType["Navigation"] = "navigation";
})(PerformanceEntryType || (PerformanceEntryType = {}));
/**
* Simplified performance entry type with only a start time, end time, and entryType.
*/
export class TaskTiming {
startTime;
endTime;
entryType;
}
/**
* Internal further simplification for a range of time with only start and end time.
*/
class TimeWindow {
start;
end;
}
/**
* Performance tracker class handles two things:
* 1) Tracking lighthouse metrics (see https://web.dev/vitals/)
* 2) Providing a wrapper to mark various times in code for performance measurement.
* This is used primarily for shell and various tools to determine load times.
*/
export class PerformanceTracker {
static get smePrefix() {
return 'SME:';
}
/**
* The source name to use when logging about this service.
*/
static get logSourceName() {
return 'PerformanceTracker';
}
// indexLoaded is logged by each extension when there index.html file starts executing code
// The value itself is used, not the variable. This is kept here to have a complete list of sme marks used.
static markIndexLoaded = PerformanceTracker.smePrefix + 'IndexLoaded';
static markCoreEnvironmentInitStarted = PerformanceTracker.smePrefix + 'CoreEnvironmentInitializeStarted';
static markCoreEnvironmentInitCompleted = PerformanceTracker.smePrefix + 'CoreEnvironmentInitializeCompleted';
static markManifestLoadStarted = PerformanceTracker.smePrefix + 'ManifestLoadStarted';
static markLocalizationStarted = PerformanceTracker.smePrefix + 'LocalizationStarted';
static markAccessibilityManagerStarted = PerformanceTracker.smePrefix + 'AccessibilityManagerStarted';
static markAppContextServiceStarted = PerformanceTracker.smePrefix + 'AppContextServiceStarted';
static markAppContextRpcInitStarted = PerformanceTracker.smePrefix + 'AppContextRpcInitStarted';
static markAppContextRpcInitComplete = PerformanceTracker.smePrefix + 'AppContextRpcInitComplete';
static markAppModuleInitialized = PerformanceTracker.smePrefix + 'AppModuleInitialized';
static markAppComponentInitialized = PerformanceTracker.smePrefix + 'AppComponentInitialized';
static markCriticalDataLoaded = PerformanceTracker.smePrefix + 'CriticalDataLoaded';
static markNavigationInitialized = PerformanceTracker.smePrefix + 'NavigationInitialized';
static markNavigationStarted = PerformanceTracker.smePrefix + 'NavigationStarted';
static markNavigationCompleted = PerformanceTracker.smePrefix + 'NavigationCompleted';
static markNavigationMeasure = PerformanceTracker.smePrefix + 'NavigationMeasure';
static moduleOpening = true;
/**
* Used to keep track of various marks when measuring arbitrary timings.
*/
static dataLoadMap = {};
/**
* Counter for total blocking time on initial loadup. This should be reset every time a new page is initialized.
*/
static totalBlockingTime = 0;
// initialize totalBlockingTime as null so we can distinguish when majority of lighthouse metrics are collected
static lighthouseMetrics = { TotalBlockingTime: null };
static ttiIntervalCheck;
static ttiInProgress = false;
static wacPo;
static fcp = 0;
static tasks = [];
static timeToInteractiveNotFound = -1;
// Under TTI guidelines, a quiet window is defined to be a 5 second period where no long tasks run and no more than
// 2 network requests at any given point.
static quietWindowLength = 5000;
// Time to interactive has to be calculated retroactively - every 10 seconds, we'll calculate and see if we can find TTI.
// After 20 seconds, cancel and just find current tti (assume a quiet window will appear somewhere in the future)
// These windows were selected arbitrarily - can be tweaked if necessary.
static ttiTimeout = 10000;
static ttiTimeoutThreshold = 2;
// Long events (events in excess of 50ms) qualify for total blocking time - any duration in excess of 50ms,
// the excess time will be added to total blocking time.
static totalBlockingTimeThresholdRequirement = 50;
static setLighthouseField(field, value) {
this.lighthouseMetrics[field] = value;
if (this.lighthouseMetrics.TotalBlockingTime !== null) {
// if TBT is calculated, we (should) have all required fields, log this in telemetry.
// It is possible once TBT is calculated other fields can be triggered afterwards (eg CLS or LCP).
// We will simply update those fields within lighthouse object and send the whole thing.
// On telemetry end we can decide how to handle (ie take first or last instance).
SmeWebTelemetry.traceLighthouseData(this.lighthouseMetrics);
}
}
/**
* Calculate Time to Interactive using list of longtask and resource events. Time to Interactive calculation
* method can be found at https://web.dev/tti#what-is-tti
* @param taskList List of all longtask and resource events from current time.
* @returns Returns time to interactive if it exists, TTI_NOT_FOUND if not.
*/
static tryFindTti(taskList) {
const timestamps = [];
taskList.forEach((entry) => {
timestamps.push({ entryType: entry.entryType, time: entry.startTime, isStart: true });
timestamps.push({ entryType: entry.entryType, time: entry.endTime });
});
// push an entry for the current timestamp, so that if the quiet window is at the end
// (eg mass entries early on, and emptiness later), we can capture the end of the window with this event.
timestamps.push({ entryType: PerformanceEntryType.LongTask, time: window.performance.now(), isStart: true });
timestamps.sort((a, b) => a.time < b.time ? -1 : 1);
const quietWindows = [];
let startTime = this.fcp;
let longTaskCount = 0;
let networkRequestCount = 0;
// Split ranges into starts and ends, iterate through all timestamps of start/end
// for each start/end, identify and keep track of long tasks and network requests to identify potential windows
// isStart means the timestamp is a startTime of a performance entry (long task or resource).
for (const { entryType, time, isStart } of timestamps) {
if (isStart) {
// check if ends current window
if ((entryType === PerformanceEntryType.LongTask && longTaskCount === 0 && networkRequestCount <= 2) ||
(entryType === PerformanceEntryType.Resource && longTaskCount === 0 && networkRequestCount === 2)) {
const newStartTime = startTime ? Math.max(startTime, this.fcp) : this.fcp;
const endTime = time;
if (endTime > newStartTime) {
quietWindows.push({ start: newStartTime, end: endTime });
}
}
}
else {
if ((entryType === PerformanceEntryType.LongTask && longTaskCount === 1 && networkRequestCount <= 2) ||
(entryType === PerformanceEntryType.Resource && longTaskCount === 0 && networkRequestCount === 3)) {
startTime = time;
}
}
// For both cases, if we just hit an isStart timestamp, we are starting a new task - increment count depending on type.
// if not isStart, we are ending a task, decrement count.
if (entryType === PerformanceEntryType.LongTask) {
longTaskCount += isStart ? 1 : -1;
}
else if (entryType === PerformanceEntryType.Resource) {
networkRequestCount += isStart ? 1 : -1;
}
}
const firstValidWindow = quietWindows.find((window) => (window.end - window.start) >= this.quietWindowLength);
const relevantLongTasks = taskList.filter((task) => firstValidWindow && task.entryType === PerformanceEntryType.LongTask
&& task.startTime >= this.fcp && task.endTime <= firstValidWindow.start);
const timeToInteractiveTimestamp = relevantLongTasks.length > 0
? relevantLongTasks[relevantLongTasks.length - 1].endTime : this.fcp;
// if no valid window found, return timeToInteractiveNotFound
return firstValidWindow ? timeToInteractiveTimestamp : this.timeToInteractiveNotFound;
}
/**
* Initialize interval to calculate time to interactive. TTI will only be calculated if WAC is in non-production mode;
* b/c calculation itself is non-performant. In production, we will use FID as a proxy for TTI.
*/
static tryStartCalculatingTimeToInteractive() {
if (SmeWebTelemetry.isProduction || this.ttiInProgress) {
return;
}
this.ttiInProgress = true;
let ttiTrackerCount = 0;
this.ttiIntervalCheck = setInterval(() => {
const tti = this.tryFindTti(this.tasks);
if (tti !== this.timeToInteractiveNotFound) {
this.setLighthouseField(WebVitalFields.TTI, tti);
// need to recalculate here because this is dependent on where the TTI is.
const recalculatedTotalBlockingTime = this.tasks.filter((entry) => entry.entryType === PerformanceEntryType.LongTask).reduce((totalBlockTime, nextEntry) => {
const duration = nextEntry.endTime - nextEntry.startTime;
const entrySatisfiesBlockingCriteria = duration > this.totalBlockingTimeThresholdRequirement
&& nextEntry.endTime < tti
&& nextEntry.startTime > this.fcp;
return totalBlockTime + (entrySatisfiesBlockingCriteria ? duration : 0);
}, 0);
this.setLighthouseField(WebVitalFields.TBT, recalculatedTotalBlockingTime);
clearInterval(this.ttiIntervalCheck);
this.wacPo.disconnect();
}
else if (ttiTrackerCount > this.ttiTimeoutThreshold) {
// if we have gone past threshold and no TTI found,
// give up on TTI and just search for last long task, we will already have bad score
const lastLongTask = this.tasks.filter((entry) => entry.entryType === PerformanceEntryType.LongTask).slice(-1)[0];
this.setLighthouseField(WebVitalFields.TTI, lastLongTask ? lastLongTask.endTime : this.ttiTimeout * this.ttiTimeoutThreshold);
this.setLighthouseField(WebVitalFields.TBT, this.totalBlockingTime);
this.wacPo.disconnect();
clearInterval(this.ttiIntervalCheck);
}
++ttiTrackerCount;
}, this.ttiTimeout);
}
/**
* Initialize web-vitals trackers for all lighthouse metrics. If function not supported (eg due to browser limitations)
* log warning and return.
*/
static initializeLighthouseMetricsTrackers() {
if (!getLCP) {
Logging.logWarning(this.logSourceName, 'Web Vitals not supported in current environment');
return;
}
getCLS((item) => this.setLighthouseField(WebVitalFields.CLS, item.value), true);
getFCP((item) => {
this.setLighthouseField(WebVitalFields.FCP, item.value);
this.fcp = item.value;
this.tryStartCalculatingTimeToInteractive();
}, true);
getLCP((item) => this.setLighthouseField(WebVitalFields.LCP, item.value), true);
getFID((item) => {
this.setLighthouseField(WebVitalFields.FID, item.value);
if (!this.ttiInProgress) {
this.setLighthouseField(WebVitalFields.TBT, this.totalBlockingTime);
}
});
getTTFB((item) => this.setLighthouseField(WebVitalFields.TTFB, item.value));
// Set a performance observer to collect all the long task and resource events we need to calculate lighthouse metrics.
this.wacPo = new PerformanceObserver((list) => {
const entries = list.getEntries();
for (let i = 0; i < entries.length; ++i) {
if (entries[i].entryType === PerformanceEntryType.LongTask) {
// measure TBT as we collect long task events - if using FID, we can just send this accumulated value as is,
// otherwise can recalculate this later when we find TTI.
const entrySatisfiesBlockingCriteria = entries[i].duration > this.totalBlockingTimeThresholdRequirement
&& entries[i].startTime > this.fcp;
this.totalBlockingTime += entrySatisfiesBlockingCriteria ? entries[i].duration : 0;
this.tasks.push({ startTime: entries[i].startTime,
endTime: entries[i].startTime + entries[i].duration,
entryType: PerformanceEntryType.LongTask });
}
else {
this.tasks.push({ startTime: entries[i].startTime,
endTime: entries[i].startTime + entries[i].duration,
entryType: PerformanceEntryType.Resource });
}
}
});
this.wacPo.observe({ entryTypes: [PerformanceEntryType.LongTask, PerformanceEntryType.Resource] });
}
static coreEnvironmentInitStarted() {
performance.mark(PerformanceTracker.markCoreEnvironmentInitStarted);
}
static coreEnvironmentInitCompleted() {
performance.mark(PerformanceTracker.markCoreEnvironmentInitCompleted);
}
static manifestLoadStarted() {
performance.mark(PerformanceTracker.markManifestLoadStarted);
}
static localizationStarted() {
performance.mark(PerformanceTracker.markLocalizationStarted);
}
static accessibilityManagerStarted() {
performance.mark(PerformanceTracker.markAccessibilityManagerStarted);
}
static appContextServiceStarted() {
performance.mark(PerformanceTracker.markAppContextServiceStarted);
}
static appContextRpcInitStarted() {
performance.mark(PerformanceTracker.markAppContextRpcInitStarted);
}
static appContextRpcInitComplete() {
performance.mark(PerformanceTracker.markAppContextRpcInitComplete);
}
static appModuleInitialized() {
performance.mark(PerformanceTracker.markAppModuleInitialized);
}
static appComponentInitialized() {
performance.mark(PerformanceTracker.markAppComponentInitialized);
PerformanceTracker.logAppComponentLoadTime();
}
static navigationInitialized() {
const navigationMarks = performance.getEntriesByType(PerformanceEntryType.Mark)
.filter(e => e.name.startsWith(PerformanceTracker.smePrefix + 'Navigation'));
// If we reinitialize navigation, clear all previous navigation marks.
navigationMarks.forEach(m => {
performance.clearMarks(m.name);
});
performance.mark(PerformanceTracker.markNavigationInitialized);
}
static navigationStarted() {
// clear previous marks prior to starting new navigation
performance.clearMarks(PerformanceTracker.markNavigationStarted);
performance.clearMarks(PerformanceTracker.markNavigationCompleted);
performance.mark(PerformanceTracker.markNavigationStarted);
}
static navigationCompleted(moduleOpened) {
// clear last measure b/c we are replacing.
performance.clearMeasures(PerformanceTracker.markNavigationMeasure);
// If the navigation via module open, set this flag so we can treat the criticalLoad differently than normal. We want to
// separate this data b/c some modules have pivot tabs that can also trigger critical data loads multiple times.
this.moduleOpening = moduleOpened;
performance.mark(PerformanceTracker.markNavigationCompleted);
performance.measure(PerformanceTracker.markNavigationMeasure, PerformanceTracker.markNavigationStarted, PerformanceTracker.markNavigationCompleted);
// clear navigation start/end marks after we measure - only maintain one set of start/end to keep simple.
performance.clearMarks(PerformanceTracker.markNavigationCompleted);
performance.clearMarks(PerformanceTracker.markNavigationStarted);
}
static criticalDataLoaded() {
performance.mark(PerformanceTracker.markCriticalDataLoaded);
PerformanceTracker.logCriticalDataLoadTime();
}
/**
* Mark an arbitrary measurement with a label. Clear the previous mark(s) prior to starting new one - allow only one at
* all times.
* @param label The label for the mark and the index for the entry in the internal map.
*/
static markDataLoadStart(label) {
const smeLabelString = `${PerformanceTracker.smePrefix}${label}`;
// If we start, overwrite and clear existing entries.
if (smeLabelString in this.dataLoadMap) {
this.dataLoadMap[smeLabelString].forEach((mark) => {
performance.clearMarks(mark.name);
});
}
performance.mark(smeLabelString);
// get latest one
const startMark = performance.getEntriesByName(smeLabelString).slice(-1)[0];
this.dataLoadMap[smeLabelString] = [startMark];
}
/**
* Mark an arbitrary measurement with a label. Intended to be a midpoint between a start and end.
* @param label The label for the entry in the data map.
* @param specificLabel The label for the mark
*/
static markDataIntermediary(label, specificLabel) {
const smeLabelString = `${PerformanceTracker.smePrefix}${label}`;
if (!(smeLabelString in this.dataLoadMap)) {
return;
}
const intermediaryLabel = specificLabel ? `${PerformanceTracker.smePrefix}${specificLabel}`
: `${PerformanceTracker.smePrefix}${label}-${this.dataLoadMap[smeLabelString].length + 1}`;
performance.mark(intermediaryLabel);
// get latest one
const mark = performance.getEntriesByName(intermediaryLabel).slice(-1)[0];
this.dataLoadMap[smeLabelString].push(mark);
}
/**
* Get a summary object from the aggregate data points marked previously.
* @param label The label for the entry in the data map dictionary
* @param specificLabel The label for the new measurement
* @returns The performance summary of the multiple marks.
*/
static markDataLoadEnd(label, specificLabel) {
const smeLabelString = `${PerformanceTracker.smePrefix}${label}`;
if (!(smeLabelString in this.dataLoadMap)) {
return null;
}
const dataEntry = this.dataLoadMap[smeLabelString];
const intermediaryLabel = specificLabel ? `${PerformanceTracker.smePrefix}${specificLabel}`
: `${PerformanceTracker.smePrefix}${label}-${dataEntry.length + 1}`;
performance.mark(intermediaryLabel);
// get latest one
const endMark = performance.getEntriesByName(intermediaryLabel).slice(-1)[0];
dataEntry.push(endMark);
const startEntry = dataEntry[0];
const totalLoadTime = endMark.startTime - startEntry.startTime;
// remove SME prefix here
const summary = {
label: label,
timestamps: {},
totalLoadTime: totalLoadTime,
startTime: startEntry.startTime
};
dataEntry.forEach((mark) => {
performance.clearMarks(mark.name);
summary.timestamps[mark.name.replace(PerformanceTracker.smePrefix, '')] = mark.startTime - startEntry.startTime;
});
delete this.dataLoadMap[smeLabelString];
return summary;
}
/**
* Log app component load time - this contains timings up to when the appComponent is loaded.
*/
static logAppComponentLoadTime() {
const data = {
sme: {},
resources: {},
navigation: {},
totalLoadTime: 0
};
/// dont include sme angular navigation marks in the generic data load
const smeMarks = performance.getEntriesByType(PerformanceEntryType.Mark).filter(e => e.name.startsWith(PerformanceTracker.smePrefix)
&& !e.name.startsWith(PerformanceTracker.smePrefix + 'Navigation'));
const resourceMarks = performance.getEntriesByType(PerformanceEntryType.Resource);
smeMarks.forEach(m => {
data.sme[m.name.replace(PerformanceTracker.smePrefix, '')] = m.startTime;
performance.clearMarks(m.name);
});
resourceMarks.forEach((m) => {
try {
const url = new URL(m.name);
data.resources[url.pathname] = m.duration;
}
catch (e) {
data.resources[m.name] = m.duration;
}
performance.clearMarks(m.name);
});
data.totalLoadTime = data.sme[this.markAppComponentInitialized.replace(PerformanceTracker.smePrefix, '')];
const navigationEntries = performance.getEntriesByType(PerformanceEntryType.Navigation);
if (navigationEntries && navigationEntries.length) {
data.navigation = navigationEntries[0];
}
SmeWebTelemetry.traceModuleOpenPerformance(data);
}
/**
* Log critical data load time - this measures the time from angular's startNavigation event to a module to the point at
* which criticalDataLoad() is called, usually in the ngInit of a module
*/
static logCriticalDataLoadTime() {
const data = {
sme: {}, totalLoadTime: 0
};
// If there are multiple somehow, take the latest one
const navigationMeasure = performance.getEntriesByName(PerformanceTracker.markNavigationMeasure).slice(-1)[0];
const criticalData = performance.getEntriesByName(PerformanceTracker.markCriticalDataLoaded).slice(-1)[0];
// clear performance marks either way - if they exist, we clear them. If not, clear b/c it's faulty data.
performance.clearMeasures(PerformanceTracker.markNavigationMeasure);
performance.clearMarks(PerformanceTracker.markCriticalDataLoaded);
if (!navigationMeasure || !criticalData) {
Logging.logInformational('PerformanceTracker', 'Critical data loaded without either criticalData call or navigationMeasure call');
return;
}
data.sme.criticalDataLoadTime = criticalData.startTime - navigationMeasure.startTime;
data.sme.navigationTime = navigationMeasure.duration;
data.totalLoadTime = data.sme.criticalDataLoadTime;
// If there are multiple somehow, take the latest one
const navInitialized = performance.getEntriesByName(PerformanceTracker.markNavigationInitialized).slice(-1)[0];
if (navInitialized) {
data.sme.timeToFirstNavigateInit = navInitialized.startTime;
data.sme.timeToFirstNavigateStart = navigationMeasure.startTime;
data.sme.timeToFirstNavigateEnd = navigationMeasure.startTime + navigationMeasure.duration;
data.sme.timeToFirstCriticalData = criticalData.startTime;
data.totalLoadTime = criticalData.startTime; // overwrite if navInit is true.
performance.clearMarks(navInitialized.name);
}
if (this.moduleOpening) {
SmeWebTelemetry.traceModuleOpenPerformance(data);
}
else {
SmeWebTelemetry.tracePerformanceData(data);
}
}
}
//# sourceMappingURL=performance-tracker.js.map