From 0fd6d9edbb76a5ce03a9980575966da468ef21fc Mon Sep 17 00:00:00 2001 From: Peter Hedenskog Date: Tue, 30 Jun 2020 09:09:47 +0200 Subject: [PATCH] Get CrUx data for a URL (or more) and origin. (#3061) * Get CrUx data for a URL (or more) and origin. Use the CrUx API to get Crux Data * Correct cli --- .github/workflows/linux.yml | 2 + lib/cli/cli.js | 5 +- lib/plugins/crux/cli.js | 15 ++++ lib/plugins/crux/index.js | 149 ++++++++++++++++++++++++++++++++ lib/plugins/crux/pageSummary.js | 48 ++++++++++ lib/plugins/crux/pug/index.pug | 64 ++++++++++++++ lib/plugins/crux/send.js | 47 ++++++++++ lib/plugins/crux/summary.js | 50 +++++++++++ lib/plugins/html/index.js | 3 +- 9 files changed, 381 insertions(+), 2 deletions(-) create mode 100644 lib/plugins/crux/cli.js create mode 100644 lib/plugins/crux/index.js create mode 100644 lib/plugins/crux/pageSummary.js create mode 100644 lib/plugins/crux/pug/index.pug create mode 100644 lib/plugins/crux/send.js create mode 100644 lib/plugins/crux/summary.js diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml index d1acd1e55..7487551a7 100644 --- a/.github/workflows/linux.yml +++ b/.github/workflows/linux.yml @@ -48,6 +48,8 @@ jobs: run: bin/sitespeed.js https://www.sitespeed.io/ -n 1 --graphite.host 127.0.0.1 --xvfb - name: Run test without a CLI run: xvfb-run node test/runWithoutCli.js + - name: Run tests with CruX + run: bin/sitespeed.js -b chrome -n 1 --crux.key ${{ secrets.CRUX_KEY }} --xvfb https://www.sitespeed.io - name: Run tests on WebPageTest run: bin/sitespeed.js -b chrome -n 2 --summaryDetail --browsertime.chrome.timeline https://www.sitespeed.io/ --webpagetest.key ${{ secrets.WPT_KEY }} --xvfb \ No newline at end of file diff --git a/lib/cli/cli.js b/lib/cli/cli.js index 0d5a563a9..265cb6fda 100644 --- a/lib/cli/cli.js +++ b/lib/cli/cli.js @@ -14,6 +14,7 @@ const toArray = require('../support/util').toArray; const grafanaPlugin = require('../plugins/grafana/index'); const graphitePlugin = require('../plugins/graphite/index'); const influxdbPlugin = require('../plugins/influxdb/index'); +const cruxPlugin = require('../plugins/crux/index'); const browsertimeConfig = require('../plugins/browsertime/index').config; const metricsConfig = require('../plugins/metrics/index').config; @@ -1301,7 +1302,9 @@ module.exports.parseCommandLine = function parseCommandLine() { describe: 'Instead of using the local copy of the hosting database, you can use the latest version through the Green Web Foundation API. This means sitespeed.io will make HTTP GET to the the hosting info.', group: 'Sustainable' - }) + }); + cliUtil.registerPluginOptions(parsed, cruxPlugin); + parsed .option('mobile', { describe: 'Access pages as mobile a fake mobile device. Set UA and width/height. For Chrome it will use device Apple iPhone 6.', diff --git a/lib/plugins/crux/cli.js b/lib/plugins/crux/cli.js new file mode 100644 index 000000000..895d91152 --- /dev/null +++ b/lib/plugins/crux/cli.js @@ -0,0 +1,15 @@ +module.exports = { + key: { + describe: + 'You need to use a key to get data from CrUx. Get the key from https://developers.google.com/web/tools/chrome-user-experience-report/api/guides/getting-started#APIKey', + group: 'CrUx' + }, + formFactor: { + default: 'ALL', + type: 'string', + choices: ['ALL', 'DESKTOP', 'PHONE', 'TABLET'], + describe: + 'A form factor is the type of device on which a user visits a website.', + group: 'CrUx' + } +}; diff --git a/lib/plugins/crux/index.js b/lib/plugins/crux/index.js new file mode 100644 index 000000000..215d81e5a --- /dev/null +++ b/lib/plugins/crux/index.js @@ -0,0 +1,149 @@ +'use strict'; + +const defaultConfig = {}; +const log = require('intel').getLogger('plugin.crux'); +const merge = require('lodash.merge'); +const throwIfMissing = require('../../support/util').throwIfMissing; +const cliUtil = require('../../cli/util'); +const send = require('./send'); +const path = require('path'); +const pageSummary = require('./pageSummary'); +const summary = require('./summary'); +const fs = require('fs'); + +const DEFAULT_METRICS_PAGESUMMARY = [ + 'loadingExperience.*.FIRST_CONTENTFUL_PAINT_MS.*', + 'loadingExperience.*.FIRST_INPUT_DELAY_MS.*', + 'loadingExperience.*.LARGEST_CONTENTFUL_PAINT_MS.*', + 'loadingExperience.*.CUMULATIVE_LAYOUT_SHIFT_SCORE.*' +]; +const DEFAULT_METRICS_SUMMARY = [ + 'originLoadingExperience.*.FIRST_CONTENTFUL_PAINT_MS.*', + 'originLoadingExperience.*.FIRST_INPUT_DELAY_MS.*', + 'originLoadingExperience.*.LARGEST_CONTENTFUL_PAINT_MS.*', + 'originLoadingExperience.*.CUMULATIVE_LAYOUT_SHIFT_SCORE.*' +]; + +module.exports = { + name() { + return path.basename(__dirname); + }, + open(context, options) { + this.make = context.messageMaker('crux').make; + this.options = merge({}, defaultConfig, options.crux); + this.testedOrigins = {}; + throwIfMissing(options.crux, ['key'], 'crux'); + this.formFactors = Array.isArray(this.options.formFactor) + ? this.options.formFactor + : [this.options.formFactor]; + this.pug = fs.readFileSync( + path.resolve(__dirname, 'pug', 'index.pug'), + 'utf8' + ); + context.filterRegistry.registerFilterForType( + DEFAULT_METRICS_PAGESUMMARY, + 'crux.pageSummary' + ); + + context.filterRegistry.registerFilterForType( + DEFAULT_METRICS_SUMMARY, + 'crux.summary' + ); + }, + async processMessage(message, queue) { + const make = this.make; + switch (message.type) { + case 'sitespeedio.setup': { + queue.postMessage(make('crux.setup')); + // Add the HTML pugs + queue.postMessage( + make('html.pug', { + id: 'crux', + name: 'CrUx', + pug: this.pug, + type: 'pageSummary' + }) + ); + queue.postMessage( + make('html.pug', { + id: 'crux', + name: 'CrUx', + pug: this.pug, + type: 'run' + }) + ); + break; + } + case 'url': { + let url = message.url; + let group = message.group; + const originResult = { originLoadingExperience: {} }; + if (!this.testedOrigins[group]) { + log.info(`Get CrUx data for domain ${group}`); + for (let formFactor of this.formFactors) { + originResult.originLoadingExperience[formFactor] = await send.get( + url, + this.options.key, + formFactor, + false + ); + if (originResult.originLoadingExperience[formFactor].error) { + log.error( + `${ + originResult.originLoadingExperience[formFactor].message + } for ${url} using ${formFactor}` + ); + } else { + originResult.originLoadingExperience[ + formFactor + ] = pageSummary.repackage( + originResult.originLoadingExperience[formFactor] + ); + } + } + queue.postMessage(make('crux.summary', originResult, { group })); + this.testedOrigins[group] = true; + } + + log.info(`Get CrUx data for url ${url}`); + const urlResult = { loadingExperience: {} }; + for (let formFactor of this.formFactors) { + urlResult.loadingExperience[formFactor] = await send.get( + url, + this.options.key, + formFactor, + true + ); + + if (urlResult.loadingExperience[formFactor].error) { + log.error( + `${ + urlResult.loadingExperience[formFactor].message + } for ${url} using ${formFactor}` + ); + } else { + urlResult.loadingExperience[formFactor] = summary.repackage( + urlResult.loadingExperience[formFactor] + ); + } + } + // Attach origin result so we can show it in the HTML + urlResult.originLoadingExperience = + originResult.originLoadingExperience; + + queue.postMessage( + make('crux.pageSummary', urlResult, { + url, + group + }) + ); + } + } + }, + get cliOptions() { + return require(path.resolve(__dirname, 'cli.js')); + }, + get config() { + return cliUtil.pluginDefaults(this.cliOptions); + } +}; diff --git a/lib/plugins/crux/pageSummary.js b/lib/plugins/crux/pageSummary.js new file mode 100644 index 000000000..dca09dfef --- /dev/null +++ b/lib/plugins/crux/pageSummary.js @@ -0,0 +1,48 @@ +'use strict'; + +module.exports = { + repackage(cruxResult) { + const result = { + FIRST_CONTENTFUL_PAINT_MS: { + p75: cruxResult.record.metrics.first_contentful_paint.percentiles.p75, + fast: + cruxResult.record.metrics.first_contentful_paint.histogram[0].density, + moderate: + cruxResult.record.metrics.first_contentful_paint.histogram[1].density, + slow: + cruxResult.record.metrics.first_contentful_paint.histogram[2].density + }, + FIRST_INPUT_DELAY_MS: { + p75: cruxResult.record.metrics.first_input_delay.percentiles.p75, + fast: cruxResult.record.metrics.first_input_delay.histogram[0].density, + moderate: + cruxResult.record.metrics.first_input_delay.histogram[1].density, + slow: cruxResult.record.metrics.first_input_delay.histogram[2].density + }, + CUMULATIVE_LAYOUT_SHIFT_SCORE: { + p75: cruxResult.record.metrics.cumulative_layout_shift.percentiles.p75, + fast: + cruxResult.record.metrics.cumulative_layout_shift.histogram[0] + .density, + moderate: + cruxResult.record.metrics.cumulative_layout_shift.histogram[1] + .density, + slow: + cruxResult.record.metrics.cumulative_layout_shift.histogram[2].density + }, + LARGEST_CONTENTFUL_PAINT_MS: { + p75: cruxResult.record.metrics.largest_contentful_paint.percentiles.p75, + fast: + cruxResult.record.metrics.largest_contentful_paint.histogram[0] + .density, + moderate: + cruxResult.record.metrics.largest_contentful_paint.histogram[1] + .density, + slow: + cruxResult.record.metrics.largest_contentful_paint.histogram[2].densit + } + }; + result.data = cruxResult; + return result; + } +}; diff --git a/lib/plugins/crux/pug/index.pug b/lib/plugins/crux/pug/index.pug new file mode 100644 index 000000000..6027257ec --- /dev/null +++ b/lib/plugins/crux/pug/index.pug @@ -0,0 +1,64 @@ +mixin rowHeading(items) + thead + tr + each item in items + th= item + +mixin numberCell(title, number) + td.number(data-title=title)= number + +mixin sizeCell(title, size) + td.number(data-title=title, data-value= size)= h.size.format(size) + +a +h2 CrUx + +- const crux = pageInfo.data.crux.pageSummary; +- const metrics = {first_contentful_paint:'First Contentful Paint (FCP)', largest_contentful_paint: 'Largest Contentful Paint (LCP)', first_input_delay:'First Input Delay (FID)', cumulative_layout_shift: 'Cumulative Layout Shift'}; +- const experiences = ['loadingExperience','originLoadingExperience']; + +each experience in experiences + if experience === 'loadingExperience' + p Over the last 30 days, this is the field data for this page from the Chrome User Experience Report. + else + h4 All pages served from this origin + p This is a summary of all pages served from this origin in the Chrome User Experience Report over the last 30 days. + + if crux[experience] + each formFactor in Object.keys(crux[experience]) + if (crux[experience][formFactor] && crux[experience][formFactor].data) + h3 Form Factor #{formFactor} + table + thead + tr + th Metric + th Value + tbody + each name, key in metrics + tr + td #{name} 75 percentile + td #{crux[experience][formFactor].data.record.metrics[key].percentiles.p75} #{key.indexOf('cumulative') > -1 ? '': 'ms'} + + h4 Distribution + table + each name, key in metrics + tr + th #{name} + th Min + th Max + th Users + tr + td Fast + td #{crux[experience][formFactor].data.record.metrics[key].histogram[0].start} + td #{crux[experience][formFactor].data.record.metrics[key].histogram[0].end} + td #{Number(crux[experience][formFactor].data.record.metrics[key].histogram[0].density * 100).toFixed(2)} % + tr + td Moderate + td #{crux[experience][formFactor].data.record.metrics[key].histogram[1].start} + td #{crux[experience][formFactor].data.record.metrics[key].histogram[1].end} + td #{Number(crux[experience][formFactor].data.record.metrics[key].histogram[1].density * 100).toFixed(2)} % + tr + td Slow + td #{crux[experience][formFactor].data.record.metrics[key].histogram[2].start} + td #{crux[experience][formFactor].data.record.metrics[key].histogram[2].end} + td #{Number(crux[experience][formFactor].data.record.metrics[key].histogram[2].density * 100).toFixed(2)} % diff --git a/lib/plugins/crux/send.js b/lib/plugins/crux/send.js new file mode 100644 index 000000000..05fcc1661 --- /dev/null +++ b/lib/plugins/crux/send.js @@ -0,0 +1,47 @@ +'use strict'; + +const https = require('https'); +const log = require('intel').getLogger('plugin.crux'); + +module.exports = { + async get(url, key, formFactor, shouldWeTestThURL) { + let data = shouldWeTestThURL ? { url } : { origin: url }; + if (formFactor !== 'ALL') { + data.formFactor = formFactor; + } + data = JSON.stringify(data); + // Return new promise + return new Promise(function(resolve, reject) { + // Do async job + const req = https.request( + { + host: 'chromeuxreport.googleapis.com', + port: 443, + path: `/v1/records:queryRecord?key=${key}`, + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(data, 'utf8') + }, + method: 'POST' + }, + function(res) { + if (res.statusCode < 200 || res.statusCode >= 300) { + log.error(`Got error from CrUx. Error Code: ${res.statusCode}`); + return reject(new Error(`Status Code: ${res.statusCode}`)); + } + const data = []; + + res.on('data', chunk => { + data.push(chunk); + }); + + res.on('end', () => + resolve(JSON.parse(Buffer.concat(data).toString())) + ); + } + ); + req.write(data); + req.end(); + }); + } +}; diff --git a/lib/plugins/crux/summary.js b/lib/plugins/crux/summary.js new file mode 100644 index 000000000..99fd49ce2 --- /dev/null +++ b/lib/plugins/crux/summary.js @@ -0,0 +1,50 @@ +'use strict'; + +module.exports = { + repackage(cruxResult) { + const result = { + FIRST_CONTENTFUL_PAINT_MS: { + p75: cruxResult.record.metrics.first_contentful_paint.percentiles.p75, + fast: + cruxResult.record.metrics.first_contentful_paint.histogram[0].density, + moderate: + cruxResult.record.metrics.first_contentful_paint.histogram[1].density, + slow: + cruxResult.record.metrics.first_contentful_paint.histogram[2].density + }, + FIRST_INPUT_DELAY_MS: { + p75: cruxResult.record.metrics.first_input_delay.percentiles.p75, + fast: cruxResult.record.metrics.first_input_delay.histogram[0].density, + moderate: + cruxResult.record.metrics.first_input_delay.histogram[1].density, + slow: cruxResult.record.metrics.first_input_delay.histogram[2].density + }, + CUMULATIVE_LAYOUT_SHIFT_SCORE: { + p75: cruxResult.record.metrics.cumulative_layout_shift.percentiles.p75, + fast: + cruxResult.record.metrics.cumulative_layout_shift.histogram[0] + .density, + moderate: + cruxResult.record.metrics.cumulative_layout_shift.histogram[1] + .density, + slow: + cruxResult.record.metrics.cumulative_layout_shift.histogram[2].density + }, + LARGEST_CONTENTFUL_PAINT_MS: { + p75: cruxResult.record.metrics.largest_contentful_paint.percentiles.p75, + fast: + cruxResult.record.metrics.largest_contentful_paint.histogram[0] + .density, + moderate: + cruxResult.record.metrics.largest_contentful_paint.histogram[1] + .density, + slow: + cruxResult.record.metrics.largest_contentful_paint.histogram[2] + .density + } + }; + + result.data = cruxResult; + return result; + } +}; diff --git a/lib/plugins/html/index.js b/lib/plugins/html/index.js index 08a9cbd69..1e50a2223 100644 --- a/lib/plugins/html/index.js +++ b/lib/plugins/html/index.js @@ -97,7 +97,8 @@ module.exports = { 'webpagetest.run', 'webpagetest.pageSummary', 'thirdparty.run', - 'thirdparty.pageSummary' + 'thirdparty.pageSummary', + 'crux.pageSummary' ]; }, processMessage(message, queue) {