Skip to content

Commit

Permalink
core(lantern): move lantern metrics to lib/lantern (#15875)
Browse files Browse the repository at this point in the history
  • Loading branch information
connorjclark committed Mar 29, 2024
1 parent 924ead7 commit 0475357
Show file tree
Hide file tree
Showing 25 changed files with 1,040 additions and 780 deletions.
193 changes: 15 additions & 178 deletions core/computed/metrics/lantern-first-contentful-paint.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,192 +5,29 @@
*/

import {makeComputedArtifact} from '../computed-artifact.js';
import {LanternMetric} from './lantern-metric.js';
import {BaseNode} from '../../lib/lantern/base-node.js';
import {getComputationDataParams} from './lantern-metric.js';
import {FirstContentfulPaint} from '../../lib/lantern/metrics/first-contentful-paint.js';

/** @typedef {import('../../lib/lantern/base-node.js').Node<LH.Artifacts.NetworkRequest>} Node */
/** @typedef {import('../../lib/lantern/cpu-node.js').CPUNode<LH.Artifacts.NetworkRequest>} CPUNode */
/** @typedef {import('../../lib/lantern/network-node.js').NetworkNode<LH.Artifacts.NetworkRequest>} NetworkNode */
/** @typedef {import('../../lib/lantern/metric.js').Extras} Extras */

class LanternFirstContentfulPaint extends LanternMetric {
class LanternFirstContentfulPaint extends FirstContentfulPaint {
/**
* @return {LH.Gatherer.Simulation.MetricCoefficients}
* @param {LH.Artifacts.MetricComputationDataInput} data
* @param {LH.Artifacts.ComputedContext} context
* @param {Omit<Extras, 'optimistic'>=} extras
* @return {Promise<LH.Artifacts.LanternMetric>}
*/
static get COEFFICIENTS() {
return {
intercept: 0,
optimistic: 0.5,
pessimistic: 0.5,
};
static async computeMetricWithGraphs(data, context, extras) {
return this.compute(await getComputationDataParams(data, context), extras);
}

/**
* @typedef FirstPaintBasedGraphOpts
* @property {number} cutoffTimestamp The timestamp used to filter out tasks that occured after
* our paint of interest. Typically this is First Contentful Paint or First Meaningful Paint.
* @property {function(NetworkNode):boolean} treatNodeAsRenderBlocking The function that determines
* which resources should be considered *possibly* render-blocking.
* @property {(function(CPUNode):boolean)=} additionalCpuNodesToTreatAsRenderBlocking The function that
* determines which CPU nodes should also be included in our blocking node IDs set,
* beyond what getRenderBlockingNodeData() already includes.
* @param {LH.Artifacts.MetricComputationDataInput} data
* @param {LH.Artifacts.ComputedContext} context
* @return {Promise<LH.Artifacts.LanternMetric>}
*/

/**
* This function computes the set of URLs that *appeared* to be render-blocking based on our filter,
* *but definitely were not* render-blocking based on the timing of their EvaluateScript task.
* It also computes the set of corresponding CPU node ids that were needed for the paint at the
* given timestamp.
*
* @param {Node} graph
* @param {FirstPaintBasedGraphOpts} opts
* @return {{definitelyNotRenderBlockingScriptUrls: Set<string>, renderBlockingCpuNodeIds: Set<string>}}
*/
static getRenderBlockingNodeData(
graph,
{cutoffTimestamp, treatNodeAsRenderBlocking, additionalCpuNodesToTreatAsRenderBlocking}
) {
/** @type {Map<string, CPUNode>} A map of blocking script URLs to the earliest EvaluateScript task node that executed them. */
const scriptUrlToNodeMap = new Map();

/** @type {Array<CPUNode>} */
const cpuNodes = [];
graph.traverse(node => {
if (node.type === BaseNode.TYPES.CPU) {
// A task is *possibly* render blocking if it *started* before cutoffTimestamp.
// We use startTime here because the paint event can be *inside* the task that was render blocking.
if (node.startTime <= cutoffTimestamp) cpuNodes.push(node);

// Build our script URL map to find the earliest EvaluateScript task node.
const scriptUrls = node.getEvaluateScriptURLs();
for (const url of scriptUrls) {
// Use the earliest CPU node we find.
const existing = scriptUrlToNodeMap.get(url) || node;
scriptUrlToNodeMap.set(url, node.startTime < existing.startTime ? node : existing);
}
}
});

cpuNodes.sort((a, b) => a.startTime - b.startTime);

// A script is *possibly* render blocking if it finished loading before cutoffTimestamp.
const possiblyRenderBlockingScriptUrls = LanternMetric.getScriptUrls(graph, node => {
// The optimistic LCP treatNodeAsRenderBlocking fn wants to exclude some images in the graph,
// but here it only receives scripts to evaluate. It's a no-op in this case, but it will
// matter below in the getFirstPaintBasedGraph clone operation.
return node.endTime <= cutoffTimestamp && treatNodeAsRenderBlocking(node);
});

// A script is *definitely not* render blocking if its EvaluateScript task started after cutoffTimestamp.
/** @type {Set<string>} */
const definitelyNotRenderBlockingScriptUrls = new Set();
/** @type {Set<string>} */
const renderBlockingCpuNodeIds = new Set();
for (const url of possiblyRenderBlockingScriptUrls) {
// Lookup the CPU node that had the earliest EvaluateScript for this URL.
const cpuNodeForUrl = scriptUrlToNodeMap.get(url);

// If we can't find it at all, we can't conclude anything, so just skip it.
if (!cpuNodeForUrl) continue;

// If we found it and it was in our `cpuNodes` set that means it finished before cutoffTimestamp, so it really is render-blocking.
if (cpuNodes.includes(cpuNodeForUrl)) {
renderBlockingCpuNodeIds.add(cpuNodeForUrl.id);
continue;
}

// We couldn't find the evaluate script in the set of CPU nodes that ran before our paint, so
// it must not have been necessary for the paint.
definitelyNotRenderBlockingScriptUrls.add(url);
}

// The first layout, first paint, and first ParseHTML are almost always necessary for first paint,
// so we always include those CPU nodes.
const firstLayout = cpuNodes.find(node => node.didPerformLayout());
if (firstLayout) renderBlockingCpuNodeIds.add(firstLayout.id);
const firstPaint = cpuNodes.find(node => node.childEvents.some(e => e.name === 'Paint'));
if (firstPaint) renderBlockingCpuNodeIds.add(firstPaint.id);
const firstParse = cpuNodes.find(node => node.childEvents.some(e => e.name === 'ParseHTML'));
if (firstParse) renderBlockingCpuNodeIds.add(firstParse.id);

// If a CPU filter was passed in, we also want to include those extra nodes.
if (additionalCpuNodesToTreatAsRenderBlocking) {
cpuNodes
.filter(additionalCpuNodesToTreatAsRenderBlocking)
.forEach(node => renderBlockingCpuNodeIds.add(node.id));
}

return {
definitelyNotRenderBlockingScriptUrls,
renderBlockingCpuNodeIds,
};
}

/**
* This function computes the graph required for the first paint of interest.
*
* @param {Node} dependencyGraph
* @param {FirstPaintBasedGraphOpts} opts
* @return {Node}
*/
static getFirstPaintBasedGraph(
dependencyGraph,
{cutoffTimestamp, treatNodeAsRenderBlocking, additionalCpuNodesToTreatAsRenderBlocking}
) {
const rbData = this.getRenderBlockingNodeData(dependencyGraph, {
cutoffTimestamp,
treatNodeAsRenderBlocking,
additionalCpuNodesToTreatAsRenderBlocking,
});
const {definitelyNotRenderBlockingScriptUrls, renderBlockingCpuNodeIds} = rbData;

return dependencyGraph.cloneWithRelationships(node => {
if (node.type === BaseNode.TYPES.NETWORK) {
// Exclude all nodes that ended after cutoffTimestamp (except for the main document which we always consider necessary)
// endTime is negative if request does not finish, make sure startTime isn't after cutoffTimestamp in this case.
const endedAfterPaint = node.endTime > cutoffTimestamp || node.startTime > cutoffTimestamp;
if (endedAfterPaint && !node.isMainDocument()) return false;

const url = node.record.url;
// If the URL definitely wasn't render-blocking then we filter it out.
if (definitelyNotRenderBlockingScriptUrls.has(url)) {
return false;
}

// Lastly, build up the FCP graph of all nodes we consider render blocking
return treatNodeAsRenderBlocking(node);
} else {
// If it's a CPU node, just check if it was blocking.
return renderBlockingCpuNodeIds.has(node.id);
}
});
}

/**
* @param {Node} dependencyGraph
* @param {LH.Artifacts.ProcessedNavigation} processedNavigation
* @return {Node}
*/
static getOptimisticGraph(dependencyGraph, processedNavigation) {
return this.getFirstPaintBasedGraph(dependencyGraph, {
cutoffTimestamp: processedNavigation.timestamps.firstContentfulPaint,
// In the optimistic graph we exclude resources that appeared to be render blocking but were
// initiated by a script. While they typically have a very high importance and tend to have a
// significant impact on the page's content, these resources don't technically block rendering.
treatNodeAsRenderBlocking: node =>
node.hasRenderBlockingPriority() && node.initiatorType !== 'script',
});
}

/**
* @param {Node} dependencyGraph
* @param {LH.Artifacts.ProcessedNavigation} processedNavigation
* @return {Node}
*/
static getPessimisticGraph(dependencyGraph, processedNavigation) {
return this.getFirstPaintBasedGraph(dependencyGraph, {
cutoffTimestamp: processedNavigation.timestamps.firstContentfulPaint,
treatNodeAsRenderBlocking: node => node.hasRenderBlockingPriority(),
});
static async compute_(data, context) {
return this.computeMetricWithGraphs(data, context);
}
}

Expand Down
59 changes: 10 additions & 49 deletions core/computed/metrics/lantern-first-meaningful-paint.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,60 +5,21 @@
*/

import {makeComputedArtifact} from '../computed-artifact.js';
import {LanternMetric} from './lantern-metric.js';
import {LighthouseError} from '../../lib/lh-error.js';
import {getComputationDataParams} from './lantern-metric.js';
import {FirstMeaningfulPaint} from '../../lib/lantern/metrics/first-meaningful-paint.js';
import {LanternFirstContentfulPaint} from './lantern-first-contentful-paint.js';

/** @typedef {import('../../lib/lantern/base-node.js').Node<LH.Artifacts.NetworkRequest>} Node */
/** @typedef {import('../../lib/lantern/metric.js').Extras} Extras */

class LanternFirstMeaningfulPaint extends LanternMetric {
class LanternFirstMeaningfulPaint extends FirstMeaningfulPaint {
/**
* @return {LH.Gatherer.Simulation.MetricCoefficients}
*/
static get COEFFICIENTS() {
return {
intercept: 0,
optimistic: 0.5,
pessimistic: 0.5,
};
}

/**
* @param {Node} dependencyGraph
* @param {LH.Artifacts.ProcessedNavigation} processedNavigation
* @return {Node}
*/
static getOptimisticGraph(dependencyGraph, processedNavigation) {
const fmp = processedNavigation.timestamps.firstMeaningfulPaint;
if (!fmp) {
throw new LighthouseError(LighthouseError.errors.NO_FMP);
}
return LanternFirstContentfulPaint.getFirstPaintBasedGraph(dependencyGraph, {
cutoffTimestamp: fmp,
// See LanternFirstContentfulPaint's getOptimisticGraph implementation for a longer description
// of why we exclude script initiated resources here.
treatNodeAsRenderBlocking: node =>
node.hasRenderBlockingPriority() && node.initiatorType !== 'script',
});
}

/**
* @param {Node} dependencyGraph
* @param {LH.Artifacts.ProcessedNavigation} processedNavigation
* @return {Node}
* @param {LH.Artifacts.MetricComputationDataInput} data
* @param {LH.Artifacts.ComputedContext} context
* @param {Omit<Extras, 'optimistic'>=} extras
* @return {Promise<LH.Artifacts.LanternMetric>}
*/
static getPessimisticGraph(dependencyGraph, processedNavigation) {
const fmp = processedNavigation.timestamps.firstMeaningfulPaint;
if (!fmp) {
throw new LighthouseError(LighthouseError.errors.NO_FMP);
}

return LanternFirstContentfulPaint.getFirstPaintBasedGraph(dependencyGraph, {
cutoffTimestamp: fmp,
treatNodeAsRenderBlocking: node => node.hasRenderBlockingPriority(),
// For pessimistic FMP we'll include *all* layout nodes
additionalCpuNodesToTreatAsRenderBlocking: node => node.didPerformLayout(),
});
static async computeMetricWithGraphs(data, context, extras) {
return this.compute(await getComputationDataParams(data, context), extras);
}

/**
Expand Down
95 changes: 11 additions & 84 deletions core/computed/metrics/lantern-interactive.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,78 +5,21 @@
*/

import {makeComputedArtifact} from '../computed-artifact.js';
import {LanternMetric} from './lantern-metric.js';
import {BaseNode} from '../../lib/lantern/base-node.js';
import {NetworkRequest} from '../../lib/network-request.js';
import {LanternFirstMeaningfulPaint} from './lantern-first-meaningful-paint.js';
import {Interactive} from '../../lib/lantern/metrics/interactive.js';
import {getComputationDataParams} from './lantern-metric.js';

/** @typedef {import('../../lib/lantern/base-node.js').Node<LH.Artifacts.NetworkRequest>} Node */

// Any CPU task of 20 ms or more will end up being a critical long task on mobile
const CRITICAL_LONG_TASK_THRESHOLD = 20;

class LanternInteractive extends LanternMetric {
/**
* @return {LH.Gatherer.Simulation.MetricCoefficients}
*/
static get COEFFICIENTS() {
return {
intercept: 0,
optimistic: 0.5,
pessimistic: 0.5,
};
}

/**
* @param {Node} dependencyGraph
* @return {Node}
*/
static getOptimisticGraph(dependencyGraph) {
// Adjust the critical long task threshold for microseconds
const minimumCpuTaskDuration = CRITICAL_LONG_TASK_THRESHOLD * 1000;

return dependencyGraph.cloneWithRelationships(node => {
// Include everything that might be a long task
if (node.type === BaseNode.TYPES.CPU) {
return node.event.dur > minimumCpuTaskDuration;
}

// Include all scripts and high priority requests, exclude all images
const isImage = node.record.resourceType === NetworkRequest.TYPES.Image;
const isScript = node.record.resourceType === NetworkRequest.TYPES.Script;
return (
!isImage &&
(isScript ||
node.record.priority === 'High' ||
node.record.priority === 'VeryHigh')
);
});
}
/** @typedef {import('../../lib/lantern/metric.js').Extras} Extras */

class LanternInteractive extends Interactive {
/**
* @param {Node} dependencyGraph
* @return {Node}
*/
static getPessimisticGraph(dependencyGraph) {
return dependencyGraph;
}

/**
* @param {LH.Gatherer.Simulation.Result} simulationResult
* @param {import('../../lib/lantern/metric.js').Extras} extras
* @return {LH.Gatherer.Simulation.Result}
* @param {LH.Artifacts.MetricComputationDataInput} data
* @param {LH.Artifacts.ComputedContext} context
* @param {Omit<Extras, 'optimistic'>=} extras
* @return {Promise<LH.Artifacts.LanternMetric>}
*/
static getEstimateFromSimulation(simulationResult, extras) {
if (!extras.fmpResult) throw new Error('missing fmpResult');

const lastTaskAt = LanternInteractive.getLastLongTaskEndTime(simulationResult.nodeTimings);
const minimumTime = extras.optimistic
? extras.fmpResult.optimisticEstimate.timeInMs
: extras.fmpResult.pessimisticEstimate.timeInMs;
return {
timeInMs: Math.max(minimumTime, lastTaskAt),
nodeTimings: simulationResult.nodeTimings,
};
static async computeMetricWithGraphs(data, context, extras) {
return this.compute(await getComputationDataParams(data, context), extras);
}

/**
Expand All @@ -86,23 +29,7 @@ class LanternInteractive extends LanternMetric {
*/
static async compute_(data, context) {
const fmpResult = await LanternFirstMeaningfulPaint.request(data, context);
const metricResult = await this.computeMetricWithGraphs(data, context, {fmpResult});
metricResult.timing = Math.max(metricResult.timing, fmpResult.timing);
return metricResult;
}

/**
* @param {LH.Gatherer.Simulation.Result['nodeTimings']} nodeTimings
* @return {number}
*/
static getLastLongTaskEndTime(nodeTimings, duration = 50) {
return Array.from(nodeTimings.entries())
.filter(([node, timing]) => {
if (node.type !== BaseNode.TYPES.CPU) return false;
return timing.duration > duration;
})
.map(([_, timing]) => timing.endTime)
.reduce((max, x) => Math.max(max || 0, x || 0), 0);
return this.computeMetricWithGraphs(data, context, {fmpResult});
}
}

Expand Down
Loading

0 comments on commit 0475357

Please sign in to comment.