Skip to content

Commit 629b0a1

Browse files
Connor ClarkDevtools-frontend LUCI CQ
authored andcommitted
[RPP] Sort insights by estimated savings and field data
See go/cpq:enhance-trace-crux Bug: 368135130 Change-Id: I86e4da9ca0d1e888ac9283045cb3130dfac210dc Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/6088178 Commit-Queue: Connor Clark <[email protected]> Reviewed-by: Paul Irish <[email protected]>
1 parent 761baac commit 629b0a1

File tree

8 files changed

+341
-69
lines changed

8 files changed

+341
-69
lines changed

config/gni/devtools_grd_files.gni

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1103,6 +1103,7 @@ grd_files_debug_sources = [
11031103
"front_end/models/trace/insights/Models.js",
11041104
"front_end/models/trace/insights/RenderBlocking.js",
11051105
"front_end/models/trace/insights/SlowCSSSelector.js",
1106+
"front_end/models/trace/insights/Statistics.js",
11061107
"front_end/models/trace/insights/ThirdParties.js",
11071108
"front_end/models/trace/insights/Viewport.js",
11081109
"front_end/models/trace/insights/types.js",

front_end/models/trace/ModelImpl.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ export class Model extends EventTarget {
116116
await this.#processor.parse(traceEvents, {
117117
isFreshRecording,
118118
isCPUProfile,
119+
metadata,
119120
});
120121
this.#storeParsedFileData(file, this.#processor.parsedTrace, this.#processor.insights);
121122
// We only push the file onto this.#traces here once we know it's valid

front_end/models/trace/Processor.ts

Lines changed: 103 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ export interface ParseOptions {
6161
* @default false
6262
*/
6363
isCPUProfile?: boolean;
64+
metadata?: Types.File.MetaData;
6465
}
6566

6667
export class TraceProcessor extends EventTarget {
@@ -173,7 +174,7 @@ export class TraceProcessor extends EventTarget {
173174
this.#status = Status.PARSING;
174175
await this.#computeParsedTrace(traceEvents);
175176
if (this.#data && !options.isCPUProfile) { // We do not calculate insights for CPU Profiles.
176-
this.#computeInsights(this.#data, traceEvents);
177+
this.#computeInsights(this.#data, traceEvents, options);
177178
}
178179
this.#status = Status.FINISHED_PARSING;
179180
} catch (e) {
@@ -342,9 +343,99 @@ export class TraceProcessor extends EventTarget {
342343
return {graph, simulator, metrics};
343344
}
344345

345-
#computeInsightSets(
346+
/**
347+
* Sort the insight models based on the impact of each insight's estimated savings, additionally weighted by the
348+
* worst metrics according to field data (if present).
349+
*/
350+
#sortInsightSet(
351+
insights: Insights.Types.TraceInsightSets, insightSet: Insights.Types.InsightSet, options: ParseOptions): void {
352+
// The initial order of the insights is alphabetical, based on `front_end/models/trace/insights/Models.ts`.
353+
// The order here provides a baseline that groups insights in a more logical way.
354+
const baselineOrder: Record<keyof Insights.Types.InsightModels, null> = {
355+
InteractionToNextPaint: null,
356+
LCPPhases: null,
357+
LCPDiscovery: null,
358+
CLSCulprits: null,
359+
RenderBlocking: null,
360+
ImageDelivery: null,
361+
DocumentLatency: null,
362+
FontDisplay: null,
363+
Viewport: null,
364+
DOMSize: null,
365+
ThirdParties: null,
366+
SlowCSSSelector: null,
367+
};
368+
369+
// Determine the weights for each metric based on field data, utilizing the same scoring curve that Lighthouse uses.
370+
const weights = Insights.Common.calculateMetricWeightsForSorting(insightSet, options.metadata ?? null);
371+
372+
// Normalize the estimated savings to a single number, weighted by its relative impact
373+
// to the page experience based on the same scoring curve that Lighthouse uses.
374+
const observedLcp = Insights.Common.getLCP(insights, insightSet.id)?.value;
375+
const observedInp = Insights.Common.getINP(insights, insightSet.id)?.value;
376+
const observedCls = Insights.Common.getCLS(insights, insightSet.id).value;
377+
const observedLcpScore =
378+
observedLcp !== undefined ? Insights.Common.evaluateLCPMetricScore(observedLcp) : undefined;
379+
const observedInpScore =
380+
observedInp !== undefined ? Insights.Common.evaluateINPMetricScore(observedInp) : undefined;
381+
const observedClsScore = Insights.Common.evaluateCLSMetricScore(observedCls);
382+
383+
const insightToSortingRank = new Map<string, number>();
384+
for (const [name, model] of Object.entries(insightSet.model)) {
385+
const lcp = model.metricSavings?.LCP ?? 0;
386+
const inp = model.metricSavings?.INP ?? 0;
387+
const cls = model.metricSavings?.CLS ?? 0;
388+
389+
const lcpPostSavings = observedLcp !== undefined ? Math.max(0, observedLcp - lcp) : undefined;
390+
const inpPostSavings = observedInp !== undefined ? Math.max(0, observedInp - inp) : undefined;
391+
const clsPostSavings = observedCls !== undefined ? Math.max(0, observedCls - cls) : undefined;
392+
393+
let score = 0;
394+
if (weights.lcp && lcp && observedLcpScore !== undefined && lcpPostSavings !== undefined) {
395+
score += weights.lcp * (Insights.Common.evaluateLCPMetricScore(lcpPostSavings) - observedLcpScore);
396+
}
397+
if (weights.inp && inp && observedInpScore !== undefined && inpPostSavings !== undefined) {
398+
score += weights.inp * (Insights.Common.evaluateINPMetricScore(inpPostSavings) - observedInpScore);
399+
}
400+
if (weights.cls && cls && observedClsScore !== undefined && clsPostSavings !== undefined) {
401+
score += weights.cls * (Insights.Common.evaluateCLSMetricScore(clsPostSavings) - observedClsScore);
402+
}
403+
404+
insightToSortingRank.set(name, score);
405+
}
406+
407+
// Now perform the actual sorting.
408+
const baselineOrderKeys = Object.keys(baselineOrder);
409+
const orderedKeys = Object.keys(insightSet.model);
410+
orderedKeys.sort((a, b) => {
411+
const a1 = baselineOrderKeys.indexOf(a);
412+
const b1 = baselineOrderKeys.indexOf(b);
413+
if (a1 >= 0 && b1 >= 0) {
414+
return a1 - b1;
415+
}
416+
if (a1 >= 0) {
417+
return -1;
418+
}
419+
if (b1 >= 0) {
420+
return 1;
421+
}
422+
return 0;
423+
});
424+
orderedKeys.sort((a, b) => (insightToSortingRank.get(b) ?? 0) - (insightToSortingRank.get(a) ?? 0));
425+
426+
const newModel = {} as Insights.Types.InsightModels;
427+
for (const key of orderedKeys as Array<keyof Insights.Types.InsightModels>) {
428+
const model = insightSet.model[key];
429+
// @ts-expect-error Maybe someday typescript will be powerful enough to handle this.
430+
newModel[key] = model;
431+
}
432+
insightSet.model = newModel;
433+
}
434+
435+
#computeInsightSet(
346436
insights: Insights.Types.TraceInsightSets, parsedTrace: Handlers.Types.ParsedTrace,
347-
insightRunners: Partial<typeof Insights.Models>, context: Insights.Types.InsightSetContext): void {
437+
insightRunners: Partial<typeof Insights.Models>, context: Insights.Types.InsightSetContext,
438+
options: ParseOptions): void {
348439
const model = {} as Insights.Types.InsightSet['model'];
349440

350441
for (const [name, insight] of Object.entries(insightRunners)) {
@@ -376,21 +467,24 @@ export class TraceProcessor extends EventTarget {
376467
return;
377468
}
378469

379-
const insightSets = {
470+
const insightSet: Insights.Types.InsightSet = {
380471
id,
381472
url,
382473
navigation,
383474
frameId: context.frameId,
384475
bounds: context.bounds,
385476
model,
386477
};
387-
insights.set(insightSets.id, insightSets);
478+
insights.set(insightSet.id, insightSet);
479+
this.#sortInsightSet(insights, insightSet, options);
388480
}
389481

390482
/**
391483
* Run all the insights and set the result to `#insights`.
392484
*/
393-
#computeInsights(parsedTrace: Handlers.Types.ParsedTrace, traceEvents: readonly Types.Events.Event[]): void {
485+
#computeInsights(
486+
parsedTrace: Handlers.Types.ParsedTrace, traceEvents: readonly Types.Events.Event[],
487+
options: ParseOptions): void {
394488
this.#insights = new Map();
395489

396490
const enabledInsightRunners = TraceProcessor.getEnabledInsightRunners(parsedTrace);
@@ -410,15 +504,15 @@ export class TraceProcessor extends EventTarget {
410504
bounds,
411505
frameId: parsedTrace.Meta.mainFrameId,
412506
};
413-
this.#computeInsightSets(this.#insights, parsedTrace, enabledInsightRunners, context);
507+
this.#computeInsightSet(this.#insights, parsedTrace, enabledInsightRunners, context, options);
414508
}
415509
// If threshold is not met, then the very beginning of the trace is ignored by the insights engine.
416510
} else {
417511
const context: Insights.Types.InsightSetContext = {
418512
bounds: parsedTrace.Meta.traceBounds,
419513
frameId: parsedTrace.Meta.mainFrameId,
420514
};
421-
this.#computeInsightSets(this.#insights, parsedTrace, enabledInsightRunners, context);
515+
this.#computeInsightSet(this.#insights, parsedTrace, enabledInsightRunners, context, options);
422516
}
423517

424518
// Now run the insights for each navigation in isolation.
@@ -466,7 +560,7 @@ export class TraceProcessor extends EventTarget {
466560
lantern,
467561
};
468562

469-
this.#computeInsightSets(this.#insights, parsedTrace, enabledInsightRunners, context);
563+
this.#computeInsightSet(this.#insights, parsedTrace, enabledInsightRunners, context, options);
470564
}
471565
}
472566
}

front_end/models/trace/insights/BUILD.gn

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ devtools_module("insights") {
2020
"Models.ts",
2121
"RenderBlocking.ts",
2222
"SlowCSSSelector.ts",
23+
"Statistics.ts",
2324
"ThirdParties.ts",
2425
"Viewport.ts",
2526
"types.ts",

front_end/models/trace/insights/Common.ts

Lines changed: 118 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@
22
// Use of this source code is governed by a BSD-style license that can be
33
// found in the LICENSE file.
44

5-
import type {InsightModels, TraceInsightSets} from './types.js';
5+
import type * as CrUXManager from '../../crux-manager/crux-manager.js';
6+
import * as Helpers from '../helpers/helpers.js';
7+
import type * as Types from '../types/types.js';
8+
9+
import {getLogNormalScore} from './Statistics.js';
10+
import type {InsightModels, InsightSet, TraceInsightSets} from './types.js';
611

712
export function getInsight<InsightName extends keyof InsightModels>(
813
insightName: InsightName, insights: TraceInsightSets|null, key: string|null): InsightModels[InsightName]|null {
@@ -23,3 +28,115 @@ export function getInsight<InsightName extends keyof InsightModels>(
2328
// For some reason typescript won't narrow the type by removing Error, so do it manually.
2429
return insight as InsightModels[InsightName];
2530
}
31+
32+
export function getLCP(insights: TraceInsightSets|null, key: string|null):
33+
{value: Types.Timing.MicroSeconds, event: Types.Events.LargestContentfulPaintCandidate}|null {
34+
const insight = getInsight('LCPPhases', insights, key);
35+
if (!insight || !insight.lcpMs || !insight.lcpEvent) {
36+
return null;
37+
}
38+
39+
const value = Helpers.Timing.millisecondsToMicroseconds(insight.lcpMs);
40+
return {value, event: insight.lcpEvent};
41+
}
42+
43+
export function getINP(insights: TraceInsightSets|null, key: string|null):
44+
{value: Types.Timing.MicroSeconds, event: Types.Events.SyntheticInteractionPair}|null {
45+
const insight = getInsight('InteractionToNextPaint', insights, key);
46+
if (!insight?.longestInteractionEvent?.dur) {
47+
return null;
48+
}
49+
50+
const value = insight.longestInteractionEvent.dur;
51+
return {value, event: insight.longestInteractionEvent};
52+
}
53+
54+
export function getCLS(
55+
insights: TraceInsightSets|null, key: string|null): {value: number, worstShiftEvent: Types.Events.Event|null} {
56+
const insight = getInsight('CLSCulprits', insights, key);
57+
if (!insight) {
58+
// Unlike the other metrics, there is always a value for CLS even with no data.
59+
return {value: 0, worstShiftEvent: null};
60+
}
61+
62+
// TODO(cjamcl): the CLS insight should be doing this for us.
63+
let maxScore = 0;
64+
let worstCluster;
65+
for (const cluster of insight.clusters) {
66+
if (cluster.clusterCumulativeScore > maxScore) {
67+
maxScore = cluster.clusterCumulativeScore;
68+
worstCluster = cluster;
69+
}
70+
}
71+
72+
return {value: maxScore, worstShiftEvent: worstCluster?.worstShiftEvent ?? null};
73+
}
74+
75+
export function evaluateLCPMetricScore(value: number): number {
76+
return getLogNormalScore({p10: 2500, median: 4000}, value);
77+
}
78+
79+
export function evaluateINPMetricScore(value: number): number {
80+
return getLogNormalScore({p10: 200, median: 500}, value);
81+
}
82+
83+
export function evaluateCLSMetricScore(value: number): number {
84+
return getLogNormalScore({p10: 0.1, median: 0.25}, value);
85+
}
86+
87+
export function calculateMetricWeightsForSorting(
88+
insightSet: InsightSet, metadata: Types.File.MetaData|null): {lcp: number, inp: number, cls: number} {
89+
const weights = {
90+
lcp: 1 / 3,
91+
inp: 1 / 3,
92+
cls: 1 / 3,
93+
};
94+
95+
const cruxFieldData = metadata?.cruxFieldData;
96+
if (!cruxFieldData) {
97+
return weights;
98+
}
99+
100+
const getPageResult = (url: string, origin: string): CrUXManager.PageResult|undefined => {
101+
return cruxFieldData.find(result => {
102+
const key = (result['url-ALL'] || result['origin-ALL'])?.record.key;
103+
return (key?.url && key.url === url) || (key?.origin && key.origin === origin);
104+
});
105+
};
106+
const getMetricValue = (pageResult: CrUXManager.PageResult, name: CrUXManager.StandardMetricNames): number|null => {
107+
const score = pageResult['url-ALL']?.record.metrics[name]?.percentiles?.p75 ??
108+
pageResult['origin-ALL']?.record.metrics[name]?.percentiles?.p75;
109+
if (typeof score === 'number') {
110+
return score;
111+
}
112+
if (typeof score === 'string' && Number.isFinite(Number(score))) {
113+
return Number(score);
114+
}
115+
return null;
116+
};
117+
118+
const pageResult = getPageResult(insightSet.url.href, insightSet.url.origin);
119+
if (!pageResult) {
120+
return weights;
121+
}
122+
123+
const fieldLcp = getMetricValue(pageResult, 'largest_contentful_paint');
124+
const fieldInp = getMetricValue(pageResult, 'interaction_to_next_paint');
125+
const fieldCls = getMetricValue(pageResult, 'cumulative_layout_shift');
126+
const fieldLcpScore = fieldLcp !== null ? evaluateLCPMetricScore(fieldLcp) : 0;
127+
const fieldInpScore = fieldInp !== null ? evaluateINPMetricScore(fieldInp) : 0;
128+
const fieldClsScore = fieldCls !== null ? evaluateCLSMetricScore(fieldCls) : 0;
129+
const fieldLcpScoreInverted = 1 - fieldLcpScore;
130+
const fieldInpScoreInverted = 1 - fieldInpScore;
131+
const fieldClsScoreInverted = 1 - fieldClsScore;
132+
const invertedSum = fieldLcpScoreInverted + fieldInpScoreInverted + fieldClsScoreInverted;
133+
if (!invertedSum) {
134+
return weights;
135+
}
136+
137+
weights.lcp = fieldLcpScoreInverted / invertedSum;
138+
weights.inp = fieldInpScoreInverted / invertedSum;
139+
weights.cls = fieldClsScoreInverted / invertedSum;
140+
141+
return weights;
142+
}

0 commit comments

Comments
 (0)