UNPKG

browsertime

Version:

Get performance metrics from your web page using Browsertime.

473 lines (428 loc) 13.6 kB
import { createRequire } from 'node:module'; import { pathToFolder } from '../pathToFolder.js'; import { localTime } from '../util.js'; import { getLogger } from '@sitespeed.io/log'; const log = getLogger('browsertime'); const require = createRequire(import.meta.url); const version = require('../../../package.json').version; import { pick, isEmpty, getProperty, merge } from '../../support/util.js'; const sensitiveHeaders = new Set([ 'authorization', 'proxy-authorization', 'cookie', 'set-cookie', 'x-api-key', 'x-auth-token', 'x-access-token', 'x-client-secret', 'x-csrf-token', 'x-xsrf-token', 'x-amz-security-token', 'x-amz-signature' ]); function generateUniquePageId(baseId, existingIdMap) { let newId = baseId; while (existingIdMap.has(newId)) { newId = newId + '-1'; } return newId; } function getDocumentRequests(entries, pageId) { let pageEntries = [...entries]; if (pageId) { pageEntries = entries.filter(entry => entry.pageref === pageId); } const requests = []; let entry; do { entry = pageEntries.shift(); if (!entry) break; requests.push(entry); } while (entry.response.redirectURL); return requests; } function getFinalURL(entries, pageref) { const requests = getDocumentRequests(entries, pageref); const finalEntry = requests.pop(); return finalEntry ? finalEntry.request.url : undefined; } function addExtrasToHAR( harPage, visualMetricsData, timings, cpu, googleWebVitals, pageinfo ) { const harPageTimings = harPage.pageTimings; const _visualMetrics = (harPage._visualMetrics = {}); harPage._cpu = cpu; if (googleWebVitals) { harPage._googleWebVitals = googleWebVitals; } // We add the timings both as a hidden field and add // in pageTimings so we can easily show them in PerfCascade if (visualMetricsData) { const DO_NOT_INCLUDE_IN_HAR_TIMINGS = new Set([ 'VisualReadiness', 'videoRecordingStart', 'SpeedIndex', 'PerceptualSpeedIndex', 'ContentfulSpeedIndex', 'VisualProgress', 'ContentfulSpeedIndexProgress', 'PerceptualSpeedIndexProgress' ]); for (let key of Object.keys(visualMetricsData)) { if (!DO_NOT_INCLUDE_IN_HAR_TIMINGS.has(key)) { harPageTimings['_' + key.charAt(0).toLowerCase() + key.slice(1)] = visualMetricsData[key]; _visualMetrics[key] = visualMetricsData[key]; } else if (!key.endsWith('Progress')) { _visualMetrics[key] = visualMetricsData[key]; } } // Make the visual progress structure more JSON _visualMetrics.VisualProgress = jsonifyVisualProgress( visualMetricsData.VisualProgress ); } else if (timings && timings.firstPaint) { // only add first paint if we don't have visual metrics harPageTimings._firstPaint = timings.firstPaint; } /* if (cpu && cpu.longTasks && cpu.longTasks.lastLongTask) { harPageTimings._lastCPULongTask = cpu.longTasks.lastLongTask; } */ if (timings && timings.largestContentfulPaint) { harPageTimings._largestContentfulPaint = timings.largestContentfulPaint.renderTime; } if ( timings && timings.paintTiming && timings.paintTiming['first-contentful-paint'] ) { harPageTimings._firstContentfulPaint = timings.paintTiming['first-contentful-paint']; } if (timings && timings.pageTimings) { harPageTimings._domInteractiveTime = timings.pageTimings.domInteractiveTime; harPageTimings._domContentLoadedTime = timings.pageTimings.domContentLoadedTime; } // Long-task ranges as a `_longTasks` extension on pageTimings. Each // entry is `[startMs, endMs]`, the format consumed by WebPageTest-derived // waterfall viewers (e.g. waterfall-tools). Tri-state presence semantic: // a non-empty array draws individual blocked spans; an empty array // signals "instrumented, observed nothing"; field absent means "parser // can't instrument". Browsertime always instruments via PerformanceObserver // when the page-info script runs, so always emit the array (possibly // empty) when `pageinfo.longTask` exists. if (pageinfo && Array.isArray(pageinfo.longTask)) { harPageTimings._longTasks = pageinfo.longTask.map(t => [ Number(t.startTime), Number(t.startTime + t.duration) ]); } // User-timing marks + measures as a `_userTimings` extension on // pageTimings. Object keyed by entry name so a downstream waterfall // renderer can paint each as a vertical line. Measures override marks // on name collision. const userTimings = timings && timings.userTimings; if ( userTimings && ((userTimings.marks && userTimings.marks.length > 0) || (userTimings.measures && userTimings.measures.length > 0)) ) { const map = {}; for (const mark of userTimings.marks || []) { if (mark && mark.name) map[mark.name] = mark.startTime; } for (const measure of userTimings.measures || []) { if (measure && measure.name) map[measure.name] = measure.startTime; } if (Object.keys(map).length > 0) harPageTimings._userTimings = map; } } function addMetaToHAR(index, harPage, url, browserScript, options) { const _meta = (harPage._meta = {}); _meta.connectivity = getProperty(options, 'connectivity.profile', 'native'); _meta.connectivity = getProperty( options, 'connectivity.alias', _meta.connectivity ); if (options.resultURL) { const base = options.resultURL.endsWith('/') ? options.resultURL : options.resultURL + '/'; if (options.screenshot) { _meta.screenshot = `${base}${pathToFolder(url, options)}screenshots/${ index + 1 }/afterPageCompleteCheck.${options.screenshotParams.type}`; } if (options.video) { _meta.video = `${base}${pathToFolder(url, options)}video/${ index + 1 }.mp4`; } if (options.chrome && options.chrome.timeline) { _meta.timeline = `${base}${pathToFolder(url, options)}trace-${ index + 1 }.json.gz`; } } if (browserScript && browserScript.pageinfo) { _meta.generator = browserScript.pageinfo.generator; } } function jsonifyVisualProgress(visualProgress) { // Original data looks like // "0=0%, 1500=81%, 1516=81%, 1533=84%, 1550=84%, 1566=84%, 1600=95%, 1683=95%, 1833=100%" const visualProgressJSON = {}; for (let value of visualProgress) { visualProgressJSON[value.timestamp] = value.percent; } return visualProgressJSON; } export function addBrowser(har, name, version, comment) { merge(har.log, { browser: { name, version, comment } }); if (!comment) { delete har.log.browser.comment; } return har; } export function addCreator(har, comment) { merge(har.log, { creator: { name: 'Browsertime', version: version, comment: comment } }); if (!comment) { delete har.log.creator.comment; } return har; } export function getMainDocumentTimings(har) { // Sometimes the HAR is dirty try { const timings = []; const entries = [...har.log.entries]; for (let page of har.log.pages) { const pageId = page.id; const url = page._url; if (url === undefined) continue; let pageEntries = [...entries]; const finalURL = getFinalURL(pageEntries, pageId); if (finalURL === undefined) continue; pageEntries = pageEntries.filter( entry => entry.pageref === pageId && entry.request.url === finalURL ); if (pageEntries.length === 0) continue; timings.push({ url, timings: pageEntries[0].timings }); } return timings; } catch (error) { log.error('Could not get main document timings ' + error); return []; } } export function getFullyLoaded(har) { const fullyLoaded = []; const entries = [...har.log.entries]; for (let page of har.log.pages) { const pageStartDateTime = new Date(page.startedDateTime).getTime(); const pageId = page.id; const url = page._url; let pageEntries = [...entries]; pageEntries = pageEntries.filter(entry => entry.pageref === pageId); let pageEnd = 0; for (let entry of pageEntries) { let entryEnd = new Date(entry.startedDateTime).getTime() + entry.time - new Date(pageStartDateTime).getTime(); if (entryEnd > pageEnd) { pageEnd = entryEnd; } } fullyLoaded.push({ url, fullyLoaded: Number(pageEnd.toFixed(0)) }); } return fullyLoaded; } export function mergeHars(hars) { if (isEmpty(hars)) { return; } if (hars.length === 1) { return hars[0]; } let firstLog = hars[0].log; let combinedHar = { log: pick(firstLog, ['version', 'creator', 'browser', 'comment']) }; let pagesById = new Map(); let allEntries = []; for (const har of hars) { let pages = har.log.pages; let entries = har.log.entries; for (const page of pages) { let pageId = page.id; if (pagesById.has(pageId)) { const oldPageId = pageId; pageId = generateUniquePageId(oldPageId, pagesById); page.id = pageId; entries = entries.map(entry => { if (entry.pageref === oldPageId) { entry.pageref = pageId; } return entry; }); } pagesById.set(pageId, page); } allEntries = [...allEntries, ...entries]; } combinedHar.log.pages = [...pagesById.values()]; combinedHar.log.entries = allEntries; return combinedHar; } export function getEmptyHAR(url, browser) { return { log: { version: '1.2', creator: { name: 'Browsertime', version: version, comment: '' }, browser: { name: browser, version: '' }, pages: [ { startedDateTime: localTime(), id: 'failing_page', title: url, pageTimings: {}, comment: '' } ], entries: [], comment: '' } }; } // Tag the first entry of a page with the document URL it navigated to. // Downstream consumers (waterfall-tools) read `entries[0]._documentURL` // to flag cross-origin requests in the rendered waterfall — the first // entry's own URL would also work for the no-redirect case, but breaks // the moment the document follows a redirect chain to a different host. function addDocumentURLToFirstEntry(harPage, entries, url) { if (!harPage || !entries || !url) return; const first = entries.find(entry => entry.pageref === harPage.id); if (first) first._documentURL = url; } export function addExtraFieldsToHar(totalResults, har, options) { if (har) { let harPageNumber = 0; // We test one page // Let's do a better fix for this later on // right now this fixes https://github.com/sitespeedio/browsertime/issues/754 if (har.log.pages.length === options.iterations) { for (let harPage of har.log.pages) { let pageNumber = harPageNumber % totalResults.length; const visualMetric = totalResults[pageNumber].visualMetrics[harPageNumber]; const browserScript = totalResults[pageNumber].browserScripts[harPageNumber]; const cpu = totalResults[pageNumber].cpu[harPageNumber]; const googleWebVitals = totalResults[pageNumber].googleWebVitals[harPageNumber]; addMetaToHAR( harPageNumber, harPage, totalResults[pageNumber].info.url, browserScript, options ); addDocumentURLToFirstEntry( harPage, har.log.entries, totalResults[pageNumber].info.url ); if (browserScript) { addExtrasToHAR( harPage, visualMetric, browserScript.timings, cpu, googleWebVitals, browserScript.pageinfo ); } harPageNumber++; } } else { const numberOfPages = totalResults.length; for (let page = 0; page < numberOfPages; page++) { for (let iteration = 0; iteration < options.iterations; iteration++) { let harIndex = iteration * totalResults.length; harIndex += page; const visualMetric = totalResults[page].visualMetrics[iteration]; const browserScript = totalResults[page].browserScripts[iteration]; const cpu = totalResults[page].cpu[iteration]; const googleWebVitals = totalResults[page].googleWebVitals[iteration]; const harPage = har.log.pages[harIndex]; // Only add meta if we have a HAR if (harPage) { addMetaToHAR( iteration, harPage, totalResults[page].info.url, browserScript, options ); addDocumentURLToFirstEntry( harPage, har.log.entries, totalResults[page].info.url ); } else { log.error( 'Could not add meta data to the HAR, miss page ' + harIndex ); } // Only add the metrics if we was able to collect the metrics and have a HAR if (browserScript && harPage) { addExtrasToHAR( harPage, visualMetric, browserScript.timings, cpu, googleWebVitals, browserScript.pageinfo ); } } } } } } export function cleanSensitiveHeaders(name, value) { if (sensitiveHeaders.has(name.toLowerCase())) { return '[REMOVED]'; } return value; }