browsertime
Version:
Get performance metrics from your web page using Browsertime.
589 lines (541 loc) • 17.7 kB
JavaScript
import path from 'node:path';
import { createRequire } from 'node:module';
import { getLogger } from '@sitespeed.io/log';
import { timestamp as _timestamp } from '../../support/engineUtils.js';
import { Statistics } from '../../support/statistics.js';
import { pathToFolder } from '../../support/pathToFolder.js';
import { getConnectivitySettings } from '../../connectivity/index.js';
import { formatMetric, getProperty } from '../../support/util.js';
const require = createRequire(import.meta.url);
const version = require('../../../package.json').version;
const log = getLogger('browsertime');
function getNewResult(url, options) {
return {
info: {
browsertime: {
version
},
url,
timestamp: _timestamp(),
connectivity: {
engine: getProperty(options, 'connectivity.engine'),
profile: getProperty(options, 'connectivity.profile'),
settings: getConnectivitySettings(options) || undefined
},
extra: JSON.parse(getProperty(options, 'info.extra', '{}')),
browser: { name: options.browser }
},
files: {
video: [],
screenshot: [],
timeline: [],
consoleLog: [],
netLog: [],
perfLog: [],
geckoProfiles: [],
memoryReports: []
},
markedAsFailure: 0,
failureMessages: [],
cdp: { performance: [] },
coverage: { js: [], css: [] },
android: { batteryTemperature: [], power: [] },
timestamps: [],
browserScripts: [],
visualMetrics: [],
deltaToTTFB: [],
cpu: [],
googleWebVitals: [],
extras: [],
fullyLoaded: [],
mainDocumentTimings: [],
errors: [],
renderBlocking: [],
server: { processesAtStart: [] }
};
}
/**
* Create a new Collector instance. The collector will collect metrics
* per iteration and store what's needed to disk.
* @class
*/
export class Collector {
constructor(url, storageManager, options) {
this.options = options;
this.storageManager = storageManager;
this.collectChromeTimeline = options.chrome && options.chrome.timeline;
this.allStats = {};
this.allResults = {};
this.aliasAndUrl = {};
this.urlAndActualUrl = {};
this.urlFromCli = url;
}
/**
* Get the result from the collector. Will add and summarize all the statistics.
* @returns {json} A JSON blob with all the collected metrics
*/
getResults() {
const allTheRuns = [];
for (let url of Object.keys(this.allResults)) {
const result = this.allResults[url];
result.statistics = this.allStats[url].summarizeDeep(this.options);
allTheRuns.push(result);
}
return allTheRuns;
}
/**
* Back fill fully loaded metrics
* @param {*} url
* @param {*} fullyLoaded
*/
addFullyLoaded(url, fullyLoaded) {
// This is a hack if a URL change between runs. First try the
// URL from cli/script, then the actual used.
const statistics =
this.allStats[url] || this.allStats[this.urlAndActualUrl[url]];
const results =
this.allResults[url] || this.allResults[this.urlAndActualUrl[url]];
if (results) {
results.fullyLoaded.push(fullyLoaded);
}
if (fullyLoaded) {
if (statistics) {
statistics.addDeep({
timings: {
fullyLoaded: fullyLoaded
}
});
} else {
log.error(
'Could not add fullyLoaded metric to URL %s, we have statistic for the URLs %j ',
url,
Object.keys(this.allStats)
);
}
}
}
addMainDocumentTimings(url, timings) {
const statistics =
this.allStats[url] || this.allStats[this.urlAndActualUrl[url]];
const results =
this.allResults[url] || this.allResults[this.urlAndActualUrl[url]];
if (results) {
results.mainDocumentTimings.push(timings);
}
if (timings && statistics) {
statistics.addDeep({
timings: {
mainDocumentTimings: timings
}
});
}
}
/**
* Collect all individual runs, add it to the statistics, and store
* data that needs to be on disk (trace logs, sceenshots etc).
* @param {*} data - The data collected by the browser for one iteration
*/
async perIteration(allData, index) {
for (let data of allData) {
const { url, results, statistics } = this._resolveURL(data, allData);
// The user can add extra data in a script or post script
if (data.extras) {
results.extras.push(data.extras);
statistics.addDeep({ extras: data.extras });
}
if (allData.batteryTemperature) {
results.android.batteryTemperature.push(allData.batteryTemperature);
statistics.addDeep({
android: { batteryTemperature: allData.batteryTemperature }
});
}
if (allData.markedAsFailure) {
results.markedAsFailure = 1;
results.failureMessages = allData.failureMessages;
}
if (allData.processesAtStart) {
results.server.processesAtStart.push(allData.processesAtStart);
}
// If we don't have an error, use an empty array. In the future we want to push
// more than one errors per run (maybe).
results.errors.push(data.error || []);
// From each iteration, collect the result we want
results.timestamps.push(data.timestamp);
this._collectBrowserScripts(data, statistics, results);
if (data.alias) {
results.info.alias = data.alias;
}
// Only availible for Chrome
if (data.cdp && data.cdp.performance) {
results.cdp.performance.push(data.cdp.performance);
statistics.addDeep({
cdp: { performance: data.cdp.performance }
});
}
// Only available for Chrome with --chrome.coverage or
// --enableProfileRun.
if (data.coverage) {
results.coverage.js.push(data.coverage.js);
results.coverage.css.push(data.coverage.css);
}
this._logIterationMetrics(data, url);
if (data.visualMetrics) {
if (this.options.iterations > 1) {
this._logVisualMetrics(data);
}
for (let key of Object.keys(data.visualMetrics)) {
// Skip VisualProgress/ContentfulProgress etc
if (!key.includes('Progress')) {
const d = { visualMetrics: {} };
d['visualMetrics'][key] = data.visualMetrics[key];
statistics.addDeep(d);
}
}
results.visualMetrics.push(data.visualMetrics);
}
this._collectPerformanceData(data, statistics, results);
this._computeDeltaToTTFB(data, results, statistics);
// Store all extra JSON metrics
// Put all work that involves storing metrics to disk in a
// array and the promise them all later on
const extraWork = [];
for (let filename of Object.keys(data.extraJson)) {
extraWork.push(
this.storageManager.writeJson(
path.join(pathToFolder(url, this.options), filename),
data.extraJson[filename],
true
)
);
}
this._collectFilePaths(url, index, results);
await Promise.all(extraWork);
this.allStats[url] = statistics;
this.allResults[url] = results;
}
}
_resolveURL(data, allData) {
const alias = data.alias;
let url = data.url || this.urlFromCli;
if (data.browserScripts && data.browserScripts.pageinfo) {
const actualURL = data.browserScripts.pageinfo.url;
// If the URL that we wanted to test do not match the actual URL used
// which can happen in scripting when using alias and URLs has session etc in it
if (url !== actualURL) {
this.urlAndActualUrl[actualURL] = url;
}
}
if (alias && !this.aliasAndUrl[alias]) {
this.aliasAndUrl[alias] = url;
} else if (alias && this.aliasAndUrl[alias]) {
url = this.aliasAndUrl[alias];
}
const results = this.allResults[url] ?? getNewResult(url, this.options);
results.info.description = allData.description;
results.info.title = allData.title;
results.info.browser.userAgent = getProperty(
allData[0],
'browserScripts.browser.userAgent',
''
);
if (allData.screenshots && allData.screenshots.length > 0) {
results.files.screenshot.push(allData.screenshots);
}
const statistics = this.allStats[url] ?? new Statistics();
return { url, results, statistics };
}
_collectBrowserScripts(data, statistics, results) {
if (data.browserScripts) {
results.browserScripts.push(data.browserScripts);
// Add all browserscripts to the stats
const equals = (a1, a2) => JSON.stringify(a1) === JSON.stringify(a2);
statistics.addDeep(data.browserScripts, (keyPath, value) => {
if (equals(keyPath.slice(-2), ['userTimings', 'marks'])) {
// eslint-disable-next-line unicorn/no-array-reduce
return value.reduce((result, mark) => {
result[mark.name] = mark.startTime;
return result;
}, {});
} else if (equals(keyPath.slice(-2), ['userTimings', 'measures'])) {
// eslint-disable-next-line unicorn/no-array-reduce
return value.reduce((result, mark) => {
result[mark.name] = mark.duration;
return result;
}, {});
} else if (equals(keyPath.slice(-1), ['resourceTimings'])) {
return {};
} else if (equals(keyPath.slice(-2), ['timings', 'serverTimings'])) {
// eslint-disable-next-line unicorn/no-array-reduce
return value.reduce((result, timing) => {
result[timing.name] = timing.duration;
return result;
}, {});
}
return value;
});
}
}
_logIterationMetrics(data, url) {
if (this.options.iterations > 1) {
const ttfb = getProperty(
data,
'browserScripts.timings.pageTimings.backEndTime'
);
const domContentLoaded = getProperty(
data,
'browserScripts.timings.pageTimings.domContentLoadedTime'
);
const firstPaint = getProperty(data, 'browserScripts.timings.firstPaint');
const fcp = getProperty(
data,
"browserScripts.timings.paintTiming['first-contentful-paint']"
);
const pageLoadTime = getProperty(
data,
'browserScripts.timings.pageTimings.pageLoadTime'
);
const lcp = getProperty(
data,
'browserScripts.timings.largestContentfulPaint.renderTime'
);
const cls = getProperty(
data,
'browserScripts.pageinfo.cumulativeLayoutShift'
);
const tbt = getProperty(data, 'cpu.longTasks.totalBlockingTime');
const mem = getProperty(data, 'memory');
log.info(
`${url} ${ttfb ? formatMetric('TTFB', ttfb, false, true) + ' ' : ''}${
domContentLoaded
? formatMetric('DOMContentLoaded', domContentLoaded, false, true) +
' '
: ''
}${
firstPaint
? formatMetric('firstPaint', firstPaint, false, true) + ' '
: ''
}${fcp ? formatMetric('FCP', fcp, false, true) + ' ' : ''}${
lcp ? formatMetric('LCP', lcp, false, true) + ' ' : ''
}${
pageLoadTime
? formatMetric('Load', pageLoadTime, false, true) + ' '
: ''
}${tbt ? formatMetric('TBT', tbt, false, true) + ' ' : ''}${
cls ? 'CLS:' + cls.toFixed(4) : ''
}${
mem
? formatMetric(
'Memory',
Math.round(mem / 1024 / 1024),
false,
false
) + 'mb '
: ''
}`
);
}
}
_logVisualMetrics(data) {
log.info(
`VisualMetrics: ${formatMetric(
'FirstVisualChange',
data.visualMetrics.FirstVisualChange,
false,
true
)} ${formatMetric(
'SpeedIndex',
data.visualMetrics.SpeedIndex,
false,
true
)}${
data.visualMetrics.PerceptualSpeedIndex
? formatMetric(
' PerceptualSpeedIndex',
data.visualMetrics.PerceptualSpeedIndex,
false,
true
)
: ''
}${
data.visualMetrics.ContentfulSpeedIndex
? formatMetric(
' ContentfulSpeedIndex',
data.visualMetrics.ContentfulSpeedIndex,
false,
true
)
: ''
} ${formatMetric(
'VisualComplete85',
data.visualMetrics.VisualComplete85,
false,
true
)} ${formatMetric(
'LastVisualChange',
data.visualMetrics.LastVisualChange,
false,
true
)}`
);
}
_collectPerformanceData(data, statistics, results) {
if (
data.browserScripts &&
data.browserScripts.pageinfo &&
data.browserScripts.pageinfo.longTask
) {
for (let longTask of data.browserScripts.pageinfo.longTask) {
statistics.addDeep({
cpu: {
longTasks: {
durations: longTask.duration
}
}
});
}
}
// Add CPU data (if we have it)
if (data.cpu) {
results.cpu.push(data.cpu);
// We skip adding stats per URL .., we should do it per domain instead in sitespeed.io
if (data.cpu.categories) {
statistics.addDeep({
cpu: {
categories: data.cpu.categories,
events: data.cpu.events
}
});
}
if (data.cpu.longTasks) {
statistics.addDeep({
cpu: {
longTasks: data.cpu.longTasks
}
});
}
}
if (data.googleWebVitals) {
results.googleWebVitals.push(data.googleWebVitals);
statistics.addDeep({
googleWebVitals: data.googleWebVitals
});
}
if (data.renderBlocking) {
results.renderBlocking.push(data.renderBlocking);
statistics.addDeep({
renderBlocking: {
recalculateStyle: data.renderBlocking.recalculateStyle
}
});
}
// Add power data (if we have it)
if (data.power) {
results.android.power.push(data.power);
statistics.addDeep({
android: { power: data.power }
});
}
// Add Firefox perfStats if available
if (this.options.firefox && this.options.firefox.perfStats) {
if (!results.geckoPerfStats) {
results.geckoPerfStats = [];
}
results.geckoPerfStats.push(data.perfStats);
}
// Add Firefox cpu power
if (data.powerConsumption) {
if (!results.powerConsumption) {
results.powerConsumption = [];
}
results.powerConsumption.push(data.powerConsumption);
statistics.addDeep({
powerConsumption: data.powerConsumption
});
}
// Add total memory
if (this.options.firefox && this.options.firefox.memoryReport) {
statistics.addDeep({
memory: data.memory
});
if (!results.memory) {
results.memory = [];
}
results.memory.push(data.memory);
}
}
_computeDeltaToTTFB(data, results, statistics) {
// Add delta to TTFB
const deltaToTTFB = {};
const fcp = getProperty(
data,
'browserScripts.timings.paintTiming.first-contentful-paint'
);
const ttfb = getProperty(data, 'browserScripts.timings.ttfb');
const lcp = getProperty(
data,
'browserScripts.timings.largestContentfulPaint.renderTime'
);
const firstVisualChange = getProperty(
data,
'visualMetrics.FirstVisualChange'
);
const lastVisualChange = getProperty(
data,
'visualMetrics.LastVisualChange'
);
if (fcp) {
deltaToTTFB['firstContentfulPaint'] = fcp - ttfb;
}
if (lcp) {
deltaToTTFB['largestContentfulPaint'] = lcp - ttfb;
}
if (firstVisualChange) {
deltaToTTFB['firstVisualChange'] = firstVisualChange - ttfb;
}
if (lastVisualChange) {
deltaToTTFB['lastVisualChange'] = lastVisualChange - ttfb;
}
results.deltaToTTFB.push(deltaToTTFB);
statistics.addDeep({ deltaToTFFB: deltaToTTFB });
}
_collectFilePaths(url, index, results) {
if (this.options.video) {
results.files.video.push(
`${pathToFolder(url, this.options)}video/${index}.mp4`
);
}
if (this.options.chrome && this.options.chrome.timeline) {
results.files.timeline.push(
`${pathToFolder(url, this.options)}trace-${index}.json.gz`
);
}
if (this.options.chrome && this.options.chrome.collectConsoleLog) {
results.files.consoleLog.push(
`${pathToFolder(url, this.options)}console-${index}.json.gz`
);
}
if (this.options.chrome && this.options.chrome.collectNetLog) {
results.files.netLog.push(
`${pathToFolder(url, this.options)}chromeNetlog-${index}.json.gz`
);
}
if (this.options.chrome && this.options.chrome.collectPerfLog) {
results.files.perfLog.push(
`${pathToFolder(url, this.options)}chromePerflog-${index}.json.gz`
);
}
if (this.options.firefox && this.options.firefox.geckoProfiler) {
const name = this.options.enableProfileRun
? `geckoProfile-${index}-extra.json.gz`
: `geckoProfile-${index}.json.gz`;
results.files.geckoProfiles.push(
`${pathToFolder(url, this.options)}${name}`
);
}
if (this.options.firefox && this.options.firefox.memoryReport) {
results.files.memoryReports.push(
`${pathToFolder(url, this.options)}memory-report-${index}.json.gz`
);
}
}
}