UNPKG

chrome-devtools-frontend

Version:
545 lines (487 loc) • 21 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 {type TraceEventHandlerName, type EnabledHandlerDataWithMeta, type Handlers} from './types.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</* Frame id */ string, Map</* navigation id */ string, Map<MetricName, MetricScore>>>(); /** * Page load events with no associated duration that happened in the * main frame. */ let allMarkerEvents: Types.TraceEvents.PageLoadEvent[] = []; export function reset(): void { metricScoresByFrameId.clear(); pageLoadEventsArray = []; allMarkerEvents = []; selectedLCPCandidateEvents.clear(); } let pageLoadEventsArray: Types.TraceEvents.PageLoadEvent[] = []; // 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<Types.TraceEvents.TraceEventLargestContentfulPaintCandidate>(); export const MarkerName = ['MarkDOMContent', 'MarkLoad', 'firstPaint', 'firstContentfulPaint', 'largestContentfulPaint::Candidate'] as const; const markerTypeGuards = [ Types.TraceEvents.isTraceEventMarkDOMContent, Types.TraceEvents.isTraceEventMarkLoad, Types.TraceEvents.isTraceEventFirstPaint, Types.TraceEvents.isTraceEventFirstContentfulPaint, Types.TraceEvents.isTraceEventLargestContentfulPaintCandidate, ]; interface MakerEvent extends Types.TraceEvents.TraceEventData { name: typeof MarkerName[number]; } export function isTraceEventMarkerEvent(event: Types.TraceEvents.TraceEventData): event is MakerEvent { return markerTypeGuards.some(fn => fn(event)); } const pageLoadEventTypeGuards = [ ...markerTypeGuards, Types.TraceEvents.isTraceEventInteractiveTime, ]; export function eventIsPageLoadEvent(event: Types.TraceEvents.TraceEventData): event is Types.TraceEvents.PageLoadEvent { return pageLoadEventTypeGuards.some(fn => fn(event)); } export function handleEvent(event: Types.TraceEvents.TraceEventData): void { if (!eventIsPageLoadEvent(event)) { return; } pageLoadEventsArray.push(event); } function storePageLoadMetricAgainstNavigationId( navigation: Types.TraceEvents.TraceEventNavigationStart, event: Types.TraceEvents.PageLoadEvent): void { 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; } // We compare the timestamp of the event to determine if it happened during the // time window in which its process was considered active. const eventBelongsToProcess = event.ts >= processData.window.min && event.ts <= processData.window.max; 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: Types.Timing.TimeUnit.SECONDS, maximumFractionDigits: 2, }); const classification = scoreClassificationForFirstContentfulPaint(fcpTime); const metricScore = {event, score, metricName: 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: Types.Timing.TimeUnit.SECONDS, maximumFractionDigits: 2, }); const classification = ScoreClassification.UNCLASSIFIED; const metricScore = {event, score, metricName: 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: Types.Timing.TimeUnit.SECONDS, maximumFractionDigits: 2, }); const metricScore = { event, score, metricName: 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: Types.Timing.TimeUnit.SECONDS, maximumFractionDigits: 2, }); const tti = { event, score: ttiScore, metricName: 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: Types.Timing.TimeUnit.MILLISECONDS, maximumFractionDigits: 2, }); const tbt = { event, score: tbtScore, metricName: 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: Types.Timing.TimeUnit.SECONDS, maximumFractionDigits: 2, }); const metricScore = { event, score, metricName: MetricName.L, classification: 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: Types.Timing.TimeUnit.SECONDS, maximumFractionDigits: 2, }); const lcp = { event, score: lcpScore, metricName: 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(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: string, navigationId: string, metricScore: MetricScore): void { 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: Types.TraceEvents.PageLoadEvent): string { if (Types.TraceEvents.isTraceEventFirstContentfulPaint(event) || Types.TraceEvents.isTraceEventInteractiveTime(event) || Types.TraceEvents.isTraceEventLargestContentfulPaintCandidate(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: Types.TraceEvents.PageLoadEvent): Types.TraceEvents.TraceEventNavigationStart|null { 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); } return Platform.assertNever(event, `Unexpected event type: ${event}`); } /* * When we first load a new trace, rather than position the playhead at time 0, * we want to position it such that the thumbnail likely shows something rather * than a blank white page, and so that it's positioned somewhere that's useful * for the user. This function takes the model data, and returns either the * timestamp of the first FCP event, or null if it couldn't find one. */ export function getFirstFCPTimestampFromModelData(model: EnabledHandlerDataWithMeta<Handlers>): Types.Timing.MicroSeconds|null { const mainFrameID = model.Meta.mainFrameId; const metricsForMainFrameByNavigationID = model.PageLoadMetrics.metricScoresByFrameId.get(mainFrameID); if (!metricsForMainFrameByNavigationID) { return null; } // Now find the first FCP event by timestamp. Events may not have the raw // data including timestamp, and if so we skip that event. let firstFCPEventInTimeline: Types.Timing.MicroSeconds|null = null; for (const metrics of metricsForMainFrameByNavigationID.values()) { const fcpMetric = metrics.get(MetricName.FCP); const fcpTimestamp = fcpMetric?.event?.ts; if (fcpTimestamp) { if (!firstFCPEventInTimeline) { firstFCPEventInTimeline = fcpTimestamp; } else if (fcpTimestamp < firstFCPEventInTimeline) { firstFCPEventInTimeline = fcpTimestamp; } } } return firstFCPEventInTimeline; } /** * Classifications sourced from * https://web.dev/fcp/ */ export function scoreClassificationForFirstContentfulPaint(fcpScoreInMicroseconds: Types.Timing.MicroSeconds): ScoreClassification { 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 = ScoreClassification.BAD; if (fcpScoreInMicroseconds <= FCP_MEDIUM_TIMING) { scoreClassification = ScoreClassification.OK; } if (fcpScoreInMicroseconds <= FCP_GOOD_TIMING) { scoreClassification = ScoreClassification.GOOD; } return scoreClassification; } /** * Classifications sourced from * https://web.dev/interactive/#how-lighthouse-determines-your-tti-score */ export function scoreClassificationForTimeToInteractive(ttiTimeInMicroseconds: Types.Timing.MicroSeconds): ScoreClassification { 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 = ScoreClassification.BAD; if (ttiTimeInMicroseconds <= TTI_MEDIUM_TIMING) { scoreClassification = ScoreClassification.OK; } if (ttiTimeInMicroseconds <= TTI_GOOD_TIMING) { scoreClassification = ScoreClassification.GOOD; } return scoreClassification; } /** * Classifications sourced from * https://web.dev/lcp/#what-is-lcp */ export function scoreClassificationForLargestContentfulPaint(lcpTimeInMicroseconds: Types.Timing.MicroSeconds): ScoreClassification { 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 = ScoreClassification.BAD; if (lcpTimeInMicroseconds <= LCP_MEDIUM_TIMING) { scoreClassification = ScoreClassification.OK; } if (lcpTimeInMicroseconds <= LCP_GOOD_TIMING) { scoreClassification = ScoreClassification.GOOD; } return scoreClassification; } /** * DCL does not have a classification. */ export function scoreClassificationForDOMContentLoaded(_dclTimeInMicroseconds: Types.Timing.MicroSeconds): ScoreClassification { return ScoreClassification.UNCLASSIFIED; } /** * Classifications sourced from * https://web.dev/lighthouse-total-blocking-#time/ */ export function scoreClassificationForTotalBlockingTime(tbtTimeInMicroseconds: Types.Timing.MicroSeconds): ScoreClassification { const TBT_GOOD_TIMING = Helpers.Timing.millisecondsToMicroseconds(Types.Timing.MilliSeconds(200)); const TBT_MEDIUM_TIMING = Helpers.Timing.millisecondsToMicroseconds(Types.Timing.MilliSeconds(600)); let scoreClassification = ScoreClassification.BAD; if (tbtTimeInMicroseconds <= TBT_MEDIUM_TIMING) { scoreClassification = ScoreClassification.OK; } if (tbtTimeInMicroseconds <= TBT_GOOD_TIMING) { scoreClassification = ScoreClassification.GOOD; } return scoreClassification; } /** * Gets all the Largest Contentful Paint scores of all the frames in the * trace. */ function gatherFinalLCPEvents(): Types.TraceEvents.PageLoadEvent[] { const allFinalLCPEvents: Types.TraceEvents.PageLoadEvent[] = []; 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(MetricName.LCP); if (!lcpInNavigation || !lcpInNavigation.event) { continue; } allFinalLCPEvents.push(lcpInNavigation.event); } return allFinalLCPEvents; } export async function finalize(): Promise<void> { 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 type PageLoadMetricsData = { metricScoresByFrameId: Map<string, Map<string, Map<MetricName, MetricScore>>>, allMarkerEvents: Types.TraceEvents.PageLoadEvent[], }; export function data(): PageLoadMetricsData { 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(): TraceEventHandlerName[] { return ['Meta']; } export const enum ScoreClassification { GOOD = 'good', OK = 'ok', BAD = 'bad', // Some metrics (such as DOMContentLoaded) don't have a Good/OK/Bad classification, hence this additional entry. UNCLASSIFIED = 'unclassified', } export const enum MetricName { // First Contentful Paint FCP = 'FCP', // First Paint FP = 'FP', // MarkLoad L = 'L', LCP = 'LCP', // Mark DOM Content DCL = 'DCL', // Time To Interactive TTI = 'TTI', // Total Blocking Time TBT = 'TBT', // Cumulative Layout Shift CLS = 'CLS', } export interface MetricScore { score: string; metricName: MetricName; classification: ScoreClassification; event?: Types.TraceEvents.PageLoadEvent; // The last navigation that occured before this metric score. navigation?: Types.TraceEvents.TraceEventNavigationStart; estimated?: boolean; }