UNPKG

@quick-game/cli

Version:

Command line interface for rapid qg development

416 lines 19.2 kB
// Copyright 2022 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /** * This handler stores page load metrics, including web vitals, * and exports them in the shape of a map with the following shape: * Map(FrameId -> Map(navigationID -> metrics) ) * * It also exports all markers in a trace in an array. * * Some metrics are taken directly from a page load events (AKA markers) like DCL. * Others require processing multiple events to be determined, like CLS and TBT. */ import * as Platform from '../../../core/platform/platform.js'; import * as Helpers from '../helpers/helpers.js'; import * as Types from '../types/types.js'; import { data as metaHandlerData } from './MetaHandler.js'; /** * This represents the metric scores for all navigations, for all frames in a trace. * Given a frame id, the map points to another map from navigation id to metric scores. * The metric scores include the event related to the metric as well as the data regarding * the score itself. */ const metricScoresByFrameId = new Map(); /** * Page load events with no associated duration that happened in the * main frame. */ let allMarkerEvents = []; export function reset() { metricScoresByFrameId.clear(); pageLoadEventsArray = []; allMarkerEvents = []; selectedLCPCandidateEvents.clear(); } let pageLoadEventsArray = []; // Once we've found the LCP events in the trace we want to fetch their DOM Node // from the backend. We could do this by parsing through our Map of frame => // navigation => metric, but it's easier to keep a set of LCP events. As we // parse the trace, any time we store an LCP candidate as the potential LCP // event, we store the event here. If we later find a new candidate in the // trace, we store that and delete the prior event. When we've parsed the // entire trace this set will contain all the LCP events that were used - e.g. // the candidates that were the actual LCP events. const selectedLCPCandidateEvents = new Set(); export const MarkerName = ['MarkDOMContent', 'MarkLoad', 'firstPaint', 'firstContentfulPaint', 'largestContentfulPaint::Candidate']; const markerTypeGuards = [ Types.TraceEvents.isTraceEventMarkDOMContent, Types.TraceEvents.isTraceEventMarkLoad, Types.TraceEvents.isTraceEventFirstPaint, Types.TraceEvents.isTraceEventFirstContentfulPaint, Types.TraceEvents.isTraceEventLargestContentfulPaintCandidate, Types.TraceEvents.isTraceEventNavigationStart, ]; export function isTraceEventMarkerEvent(event) { return markerTypeGuards.some(fn => fn(event)); } const pageLoadEventTypeGuards = [ ...markerTypeGuards, Types.TraceEvents.isTraceEventInteractiveTime, ]; export function eventIsPageLoadEvent(event) { return pageLoadEventTypeGuards.some(fn => fn(event)); } export function handleEvent(event) { if (!eventIsPageLoadEvent(event)) { return; } pageLoadEventsArray.push(event); } function storePageLoadMetricAgainstNavigationId(navigation, event) { const navigationId = navigation.args.data?.navigationId; if (!navigationId) { throw new Error('Navigation event unexpectedly had no navigation ID.'); } const frameId = getFrameIdForPageLoadEvent(event); const { rendererProcessesByFrame } = metaHandlerData(); // If either of these pieces of data do not exist, the most likely // explanation is that the page load metric we found is for a frame/process // combo that the MetaHandler discarded. This typically happens if we get a // navigation event with an empty URL. Therefore, we will silently return and // drop this metric. If we didn't care about the navigation, we certainly do // not need to care about metrics for that navigation. const rendererProcessesInFrame = rendererProcessesByFrame.get(frameId); if (!rendererProcessesInFrame) { return; } const processData = rendererProcessesInFrame.get(event.pid); if (!processData) { return; } if (Types.TraceEvents.isTraceEventNavigationStart(event)) { return; } // We compare the timestamp of the event to determine if it happened during the // time window in which its process was considered active. const minTime = processData[0].window.min; const maxTime = processData.at(-1)?.window.max || 0; const eventBelongsToProcess = event.ts >= minTime && event.ts <= maxTime; if (!eventBelongsToProcess) { // If the event occurred outside its process' active time window we ignore it. return; } if (Types.TraceEvents.isTraceEventFirstContentfulPaint(event)) { const fcpTime = Types.Timing.MicroSeconds(event.ts - navigation.ts); const score = Helpers.Timing.formatMicrosecondsTime(fcpTime, { format: 2 /* Types.Timing.TimeUnit.SECONDS */, maximumFractionDigits: 2, }); const classification = scoreClassificationForFirstContentfulPaint(fcpTime); const metricScore = { event, score, metricName: "FCP" /* MetricName.FCP */, classification, navigation }; storeMetricScore(frameId, navigationId, metricScore); return; } if (Types.TraceEvents.isTraceEventFirstPaint(event)) { const paintTime = Types.Timing.MicroSeconds(event.ts - navigation.ts); const score = Helpers.Timing.formatMicrosecondsTime(paintTime, { format: 2 /* Types.Timing.TimeUnit.SECONDS */, maximumFractionDigits: 2, }); const classification = "unclassified" /* ScoreClassification.UNCLASSIFIED */; const metricScore = { event, score, metricName: "FP" /* MetricName.FP */, classification, navigation }; storeMetricScore(frameId, navigationId, metricScore); return; } if (Types.TraceEvents.isTraceEventMarkDOMContent(event)) { const dclTime = Types.Timing.MicroSeconds(event.ts - navigation.ts); const score = Helpers.Timing.formatMicrosecondsTime(dclTime, { format: 2 /* Types.Timing.TimeUnit.SECONDS */, maximumFractionDigits: 2, }); const metricScore = { event, score, metricName: "DCL" /* MetricName.DCL */, classification: scoreClassificationForDOMContentLoaded(dclTime), navigation, }; storeMetricScore(frameId, navigationId, metricScore); return; } if (Types.TraceEvents.isTraceEventInteractiveTime(event)) { const ttiValue = Types.Timing.MicroSeconds(event.ts - navigation.ts); const ttiScore = Helpers.Timing.formatMicrosecondsTime(ttiValue, { format: 2 /* Types.Timing.TimeUnit.SECONDS */, maximumFractionDigits: 2, }); const tti = { event, score: ttiScore, metricName: "TTI" /* MetricName.TTI */, classification: scoreClassificationForTimeToInteractive(ttiValue), navigation, }; storeMetricScore(frameId, navigationId, tti); const tbtValue = Helpers.Timing.millisecondsToMicroseconds(Types.Timing.MilliSeconds(event.args.args.total_blocking_time_ms)); const tbtScore = Helpers.Timing.formatMicrosecondsTime(tbtValue, { format: 1 /* Types.Timing.TimeUnit.MILLISECONDS */, maximumFractionDigits: 2, }); const tbt = { event, score: tbtScore, metricName: "TBT" /* MetricName.TBT */, classification: scoreClassificationForTotalBlockingTime(tbtValue), navigation, }; storeMetricScore(frameId, navigationId, tbt); return; } if (Types.TraceEvents.isTraceEventMarkLoad(event)) { const loadTime = Types.Timing.MicroSeconds(event.ts - navigation.ts); const score = Helpers.Timing.formatMicrosecondsTime(loadTime, { format: 2 /* Types.Timing.TimeUnit.SECONDS */, maximumFractionDigits: 2, }); const metricScore = { event, score, metricName: "L" /* MetricName.L */, classification: "unclassified" /* ScoreClassification.UNCLASSIFIED */, navigation, }; storeMetricScore(frameId, navigationId, metricScore); return; } if (Types.TraceEvents.isTraceEventLargestContentfulPaintCandidate(event)) { const candidateIndex = event.args.data?.candidateIndex; if (!candidateIndex) { throw new Error('Largest Contenful Paint unexpectedly had no candidateIndex.'); } const lcpTime = Types.Timing.MicroSeconds(event.ts - navigation.ts); const lcpScore = Helpers.Timing.formatMicrosecondsTime(lcpTime, { format: 2 /* Types.Timing.TimeUnit.SECONDS */, maximumFractionDigits: 2, }); const lcp = { event, score: lcpScore, metricName: "LCP" /* MetricName.LCP */, classification: scoreClassificationForLargestContentfulPaint(lcpTime), navigation, }; const metricsByNavigation = Platform.MapUtilities.getWithDefault(metricScoresByFrameId, frameId, () => new Map()); const metrics = Platform.MapUtilities.getWithDefault(metricsByNavigation, navigationId, () => new Map()); const lastLCPCandidate = metrics.get("LCP" /* MetricName.LCP */); if (lastLCPCandidate === undefined) { selectedLCPCandidateEvents.add(lcp.event); storeMetricScore(frameId, navigationId, lcp); return; } const lastLCPCandidateEvent = lastLCPCandidate.event; if (!Types.TraceEvents.isTraceEventLargestContentfulPaintCandidate(lastLCPCandidateEvent)) { return; } const lastCandidateIndex = lastLCPCandidateEvent.args.data?.candidateIndex; if (!lastCandidateIndex) { // lastCandidateIndex cannot be undefined because we don't store candidates with // with an undefined candidateIndex value. This check is only to make TypeScript // treat the field as not undefined below. return; } if (lastCandidateIndex < candidateIndex) { selectedLCPCandidateEvents.delete(lastLCPCandidateEvent); selectedLCPCandidateEvents.add(lcp.event); storeMetricScore(frameId, navigationId, lcp); } return; } if (Types.TraceEvents.isTraceEventLayoutShift(event)) { return; } return Platform.assertNever(event, `Unexpected event type: ${event}`); } function storeMetricScore(frameId, navigationId, metricScore) { const metricsByNavigation = Platform.MapUtilities.getWithDefault(metricScoresByFrameId, frameId, () => new Map()); const metrics = Platform.MapUtilities.getWithDefault(metricsByNavigation, navigationId, () => new Map()); // If an entry with that metric name is present, delete it so that the new entry that // will replace it is added at the end of the map. This way we guarantee the map entries // are ordered in ASC manner by timestamp. metrics.delete(metricScore.metricName); metrics.set(metricScore.metricName, metricScore); } export function getFrameIdForPageLoadEvent(event) { if (Types.TraceEvents.isTraceEventFirstContentfulPaint(event) || Types.TraceEvents.isTraceEventInteractiveTime(event) || Types.TraceEvents.isTraceEventLargestContentfulPaintCandidate(event) || Types.TraceEvents.isTraceEventNavigationStart(event) || Types.TraceEvents.isTraceEventLayoutShift(event) || Types.TraceEvents.isTraceEventFirstPaint(event)) { return event.args.frame; } if (Types.TraceEvents.isTraceEventMarkDOMContent(event) || Types.TraceEvents.isTraceEventMarkLoad(event)) { const frameId = event.args.data?.frame; if (!frameId) { throw new Error('MarkDOMContent unexpectedly had no frame ID.'); } return frameId; } Platform.assertNever(event, `Unexpected event type: ${event}`); } function getNavigationForPageLoadEvent(event) { if (Types.TraceEvents.isTraceEventFirstContentfulPaint(event) || Types.TraceEvents.isTraceEventLargestContentfulPaintCandidate(event) || Types.TraceEvents.isTraceEventFirstPaint(event)) { const navigationId = event.args.data?.navigationId; if (!navigationId) { throw new Error('Trace event unexpectedly had no navigation ID.'); } const { navigationsByNavigationId } = metaHandlerData(); const navigation = navigationsByNavigationId.get(navigationId); if (!navigation) { // This event's navigation has been filtered out by the meta handler as a noise event. return null; } return navigation; } if (Types.TraceEvents.isTraceEventMarkDOMContent(event) || Types.TraceEvents.isTraceEventInteractiveTime(event) || Types.TraceEvents.isTraceEventLayoutShift(event) || Types.TraceEvents.isTraceEventMarkLoad(event)) { const frameId = getFrameIdForPageLoadEvent(event); const { navigationsByFrameId } = metaHandlerData(); return Helpers.Trace.getNavigationForTraceEvent(event, frameId, navigationsByFrameId); } if (Types.TraceEvents.isTraceEventNavigationStart(event)) { // We don't want to compute metrics of the navigation relative to itself, so we'll avoid avoid all that. return null; } return Platform.assertNever(event, `Unexpected event type: ${event}`); } /** * Classifications sourced from * https://web.dev/fcp/ */ export function scoreClassificationForFirstContentfulPaint(fcpScoreInMicroseconds) { const FCP_GOOD_TIMING = Helpers.Timing.secondsToMicroseconds(Types.Timing.Seconds(1.8)); const FCP_MEDIUM_TIMING = Helpers.Timing.secondsToMicroseconds(Types.Timing.Seconds(3.0)); let scoreClassification = "bad" /* ScoreClassification.BAD */; if (fcpScoreInMicroseconds <= FCP_MEDIUM_TIMING) { scoreClassification = "ok" /* ScoreClassification.OK */; } if (fcpScoreInMicroseconds <= FCP_GOOD_TIMING) { scoreClassification = "good" /* ScoreClassification.GOOD */; } return scoreClassification; } /** * Classifications sourced from * https://web.dev/interactive/#how-lighthouse-determines-your-tti-score */ export function scoreClassificationForTimeToInteractive(ttiTimeInMicroseconds) { const TTI_GOOD_TIMING = Helpers.Timing.secondsToMicroseconds(Types.Timing.Seconds(3.8)); const TTI_MEDIUM_TIMING = Helpers.Timing.secondsToMicroseconds(Types.Timing.Seconds(7.3)); let scoreClassification = "bad" /* ScoreClassification.BAD */; if (ttiTimeInMicroseconds <= TTI_MEDIUM_TIMING) { scoreClassification = "ok" /* ScoreClassification.OK */; } if (ttiTimeInMicroseconds <= TTI_GOOD_TIMING) { scoreClassification = "good" /* ScoreClassification.GOOD */; } return scoreClassification; } /** * Classifications sourced from * https://web.dev/lcp/#what-is-lcp */ export function scoreClassificationForLargestContentfulPaint(lcpTimeInMicroseconds) { const LCP_GOOD_TIMING = Helpers.Timing.secondsToMicroseconds(Types.Timing.Seconds(2.5)); const LCP_MEDIUM_TIMING = Helpers.Timing.secondsToMicroseconds(Types.Timing.Seconds(4)); let scoreClassification = "bad" /* ScoreClassification.BAD */; if (lcpTimeInMicroseconds <= LCP_MEDIUM_TIMING) { scoreClassification = "ok" /* ScoreClassification.OK */; } if (lcpTimeInMicroseconds <= LCP_GOOD_TIMING) { scoreClassification = "good" /* ScoreClassification.GOOD */; } return scoreClassification; } /** * DCL does not have a classification. */ export function scoreClassificationForDOMContentLoaded(_dclTimeInMicroseconds) { return "unclassified" /* ScoreClassification.UNCLASSIFIED */; } /** * Classifications sourced from * https://web.dev/lighthouse-total-blocking-#time/ */ export function scoreClassificationForTotalBlockingTime(tbtTimeInMicroseconds) { const TBT_GOOD_TIMING = Helpers.Timing.millisecondsToMicroseconds(Types.Timing.MilliSeconds(200)); const TBT_MEDIUM_TIMING = Helpers.Timing.millisecondsToMicroseconds(Types.Timing.MilliSeconds(600)); let scoreClassification = "bad" /* ScoreClassification.BAD */; if (tbtTimeInMicroseconds <= TBT_MEDIUM_TIMING) { scoreClassification = "ok" /* ScoreClassification.OK */; } if (tbtTimeInMicroseconds <= TBT_GOOD_TIMING) { scoreClassification = "good" /* ScoreClassification.GOOD */; } return scoreClassification; } /** * Gets all the Largest Contentful Paint scores of all the frames in the * trace. */ function gatherFinalLCPEvents() { const allFinalLCPEvents = []; const dataForAllFrames = [...metricScoresByFrameId.values()]; const dataForAllNavigations = dataForAllFrames.flatMap(frameData => [...frameData.values()]); for (let i = 0; i < dataForAllNavigations.length; i++) { const navigationData = dataForAllNavigations[i]; const lcpInNavigation = navigationData.get("LCP" /* MetricName.LCP */); if (!lcpInNavigation || !lcpInNavigation.event) { continue; } allFinalLCPEvents.push(lcpInNavigation.event); } return allFinalLCPEvents; } export async function finalize() { pageLoadEventsArray.sort((a, b) => a.ts - b.ts); for (const pageLoadEvent of pageLoadEventsArray) { const navigation = getNavigationForPageLoadEvent(pageLoadEvent); if (navigation) { // Event's navigation was not filtered out as noise. storePageLoadMetricAgainstNavigationId(navigation, pageLoadEvent); } } // NOTE: if you are looking for the TBT calculation, it has temporarily been // removed. See crbug.com/1424335 for details. const allFinalLCPEvents = gatherFinalLCPEvents(); const mainFrame = metaHandlerData().mainFrameId; // Filter out LCP candidates to use only definitive LCP values const allEventsButLCP = pageLoadEventsArray.filter(event => !Types.TraceEvents.isTraceEventLargestContentfulPaintCandidate(event)); const markerEvents = [...allFinalLCPEvents, ...allEventsButLCP].filter(isTraceEventMarkerEvent); // Filter by main frame and sort. allMarkerEvents = markerEvents.filter(event => getFrameIdForPageLoadEvent(event) === mainFrame).sort((a, b) => a.ts - b.ts); } export function data() { return { /** * This represents the metric scores for all navigations, for all frames in a trace. * Given a frame id, the map points to another map from navigation id to metric scores. * The metric scores include the event related to the metric as well as the data regarding * the score itself. */ metricScoresByFrameId: new Map(metricScoresByFrameId), /** * Page load events with no associated duration that happened in the * main frame. */ allMarkerEvents: [...allMarkerEvents], }; } export function deps() { return ['Meta']; } //# sourceMappingURL=PageLoadMetricsHandler.js.map