[go: nahoru, domu]

Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

core: add time-to-first-byte and lcp-breakdown #14941

Merged
merged 40 commits into from
Apr 25, 2023
Merged
Show file tree
Hide file tree
Changes from 35 commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
7ad95ad
core: add lcp request timings for wpt analysis
adamraine Mar 31, 2023
f1e2815
tests
adamraine Mar 31, 2023
aa7b9c3
add ttfb + wpt fixes
adamraine Apr 4, 2023
05b1533
test
adamraine Apr 4, 2023
f0b8c99
expand
adamraine Apr 5, 2023
1e71d31
wpt on gcp
adamraine Apr 5, 2023
89c772a
non img
adamraine Apr 5, 2023
ae30f5c
gcp scripts
adamraine Apr 7, 2023
befa77d
tests
adamraine Apr 7, 2023
2665cb4
ttfb no throttling
adamraine Apr 11, 2023
105b93f
document urls
adamraine Apr 11, 2023
94db20e
lcp record
adamraine Apr 11, 2023
9390257
type
adamraine Apr 11, 2023
44fdd50
timing summary test
adamraine Apr 11, 2023
2f2e8db
sample
adamraine Apr 11, 2023
2d6cf90
Merge branch 'main' into lcp-load-delay-analysis
adamraine Apr 11, 2023
acfb79e
unit
adamraine Apr 11, 2023
5328e61
better ttfb
adamraine Apr 14, 2023
a57c51f
comments
adamraine Apr 17, 2023
fc47184
rn
adamraine Apr 17, 2023
5ebd448
better
adamraine Apr 18, 2023
a1acfa1
fix tests
adamraine Apr 18, 2023
e98110c
Merge branch 'main' into lcp-load-delay-analysis
adamraine Apr 18, 2023
6656fff
rename
adamraine Apr 18, 2023
8fec330
image record test
adamraine Apr 18, 2023
53d56df
ttfb test
adamraine Apr 18, 2023
f342e99
ope
adamraine Apr 18, 2023
32407e8
lcp breakdown test
adamraine Apr 18, 2023
77433d3
smokes
adamraine Apr 18, 2023
f80ad2e
sample
adamraine Apr 18, 2023
d2b176e
extra
adamraine Apr 18, 2023
30b3b61
I cannot trust you
adamraine Apr 19, 2023
46887f5
small things
adamraine Apr 19, 2023
6ca196e
Merge branch 'main' into lcp-load-delay-analysis
adamraine Apr 20, 2023
9dd7aab
lantern
adamraine Apr 24, 2023
2a58f89
comments
adamraine Apr 25, 2023
556d85b
real traces
adamraine Apr 25, 2023
1def483
new metrics test
adamraine Apr 25, 2023
54059a2
undefined if text
adamraine Apr 25, 2023
225e437
defer lantern test to new PR
adamraine Apr 25, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions cli/test/smokehouse/test-definitions/dobetterweb.js
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,13 @@ const expectations = {
},
},
},
'metrics': {
details: {items: {0: {
timeToFirstByte: '450+/-100',
lcpLoadStart: '7750+/-500',
lcpLoadEnd: '7750+/-500',
adamraine marked this conversation as resolved.
Show resolved Hide resolved
}}},
},
},
fullPageScreenshot: {
screenshot: {
Expand Down
12 changes: 10 additions & 2 deletions core/audits/predictive-perf.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {LanternFirstMeaningfulPaint} from '../computed/metrics/lantern-first-mea
import {LanternInteractive} from '../computed/metrics/lantern-interactive.js';
import {LanternSpeedIndex} from '../computed/metrics/lantern-speed-index.js';
import {LanternLargestContentfulPaint} from '../computed/metrics/lantern-largest-contentful-paint.js';
import {TimingSummary} from '../computed/metrics/timing-summary.js';
import {defaultSettings} from '../config/constants.js';

// Parameters (in ms) for log-normal CDF scoring. To see the curve:
// https://www.desmos.com/calculator/bksgkihhj8
Expand Down Expand Up @@ -47,15 +49,17 @@ class PredictivePerf extends Audit {
const devtoolsLog = artifacts.devtoolsLogs[Audit.DEFAULT_PASS];
const URL = artifacts.URL;
/** @type {LH.Config.Settings} */
// @ts-expect-error - TODO(bckenny): allow optional `throttling` settings
const settings = {}; // Use default settings.
// TODO(bckenny): allow optional `throttling` settings
adamraine marked this conversation as resolved.
Show resolved Hide resolved
const settings = JSON.parse(JSON.stringify(defaultSettings)); // Use default settings.
const computationData = {trace, devtoolsLog, gatherContext, settings, URL};
const fcp = await LanternFirstContentfulPaint.request(computationData, context);
const fmp = await LanternFirstMeaningfulPaint.request(computationData, context);
const tti = await LanternInteractive.request(computationData, context);
const si = await LanternSpeedIndex.request(computationData, context);
const lcp = await LanternLargestContentfulPaint.request(computationData, context);

const timingSummary = await TimingSummary.request(computationData, context);

const values = {
roughEstimateOfFCP: fcp.timing,
optimisticFCP: fcp.optimisticEstimate.timeInMs,
Expand All @@ -76,6 +80,10 @@ class PredictivePerf extends Audit {
roughEstimateOfLCP: lcp.timing,
optimisticLCP: lcp.optimisticEstimate.timeInMs,
pessimisticLCP: lcp.pessimisticEstimate.timeInMs,

roughEstimateOfTTFB: timingSummary.metrics.timeToFirstByte,
roughEstimateOfLCPLoadStart: timingSummary.metrics.lcpLoadStart,
roughEstimateOfLCPLoadEnd: timingSummary.metrics.lcpLoadEnd,
};

const score = Audit.computeLogNormalScore(
Expand Down
58 changes: 3 additions & 55 deletions core/audits/prioritize-lcp-image.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,7 @@
import {LanternLargestContentfulPaint} from '../computed/metrics/lantern-largest-contentful-paint.js';
import {LoadSimulator} from '../computed/load-simulator.js';
import {ByteEfficiencyAudit} from './byte-efficiency/byte-efficiency-audit.js';
import {ProcessedNavigation} from '../computed/processed-navigation.js';
import {NetworkRecords} from '../computed/network-records.js';
import {LCPImageRecord} from '../computed/lcp-image-record.js';

const UIStrings = {
/** Title of a lighthouse audit that tells a user to preload an image in order to improve their LCP time. */
Expand Down Expand Up @@ -140,55 +139,6 @@
};
}

/**
* Match the LCP event with the paint event to get the request of the image actually painted.
* This could differ from the `ImageElement` associated with the nodeId if e.g. the LCP
* was a pseudo-element associated with a node containing a smaller background-image.
* @param {LH.Trace} trace
* @param {LH.Artifacts.ProcessedNavigation} processedNavigation
* @param {Array<NetworkRequest>} networkRecords
* @return {NetworkRequest|undefined}
*/
static getLcpRecord(trace, processedNavigation, networkRecords) {
// Use main-frame-only LCP to match the metric value.
const lcpEvent = processedNavigation.largestContentfulPaintEvt;
if (!lcpEvent) return;

const lcpImagePaintEvent = trace.traceEvents.filter(e => {
return e.name === 'LargestImagePaint::Candidate' &&
e.args.frame === lcpEvent.args.frame &&
e.args.data?.DOMNodeId === lcpEvent.args.data?.nodeId &&
e.args.data?.size === lcpEvent.args.data?.size;
// Get last candidate, in case there was more than one.
}).sort((a, b) => b.ts - a.ts)[0];

const lcpUrl = lcpImagePaintEvent?.args.data?.imageUrl;
if (!lcpUrl) return;

const candidates = networkRecords.filter(record => {
return record.url === lcpUrl &&
record.finished &&
// Same frame as LCP trace event.
record.frameId === lcpImagePaintEvent.args.frame &&
record.networkRequestTime < (processedNavigation.timestamps.largestContentfulPaint || 0);
}).map(record => {
// Follow any redirects to find the real image request.
while (record.redirectDestination) {
record = record.redirectDestination;
}
return record;
}).filter(record => {
// Don't select if also loaded by some other means (xhr, etc). `resourceType`
// isn't set on redirect _sources_, so have to check after following redirects.
return record.resourceType === 'Image';
});

// If there are still multiple candidates, at this point it appears the page
// simply made multiple requests for the image. The first loaded is the best
// guess of the request that made the image available for use.
return candidates.sort((a, b) => a.networkEndTime - b.networkEndTime)[0];
}

/**
* Computes the estimated effect of preloading the LCP image.
* @param {LH.Artifacts.TraceElement} lcpElement
Expand Down Expand Up @@ -297,17 +247,15 @@
return {score: null, notApplicable: true};
}

const networkRecords = await NetworkRecords.request(devtoolsLog, context);
const processedNavigation = await ProcessedNavigation.request(trace, context);
const mainResource = await MainResource.request({devtoolsLog, URL}, context);
const lanternLCP = await LanternLargestContentfulPaint.request(metricData, context);
const simulator = await LoadSimulator.request({devtoolsLog, settings}, context);

const lcpRecord = PrioritizeLcpImage.getLcpRecord(trace, processedNavigation, networkRecords);
const lcpImageRecord = await LCPImageRecord.request({trace, devtoolsLog}, context);

Check warning on line 254 in core/audits/prioritize-lcp-image.js

View check run for this annotation

Codecov / codecov/patch

core/audits/prioritize-lcp-image.js#L254

Added line #L254 was not covered by tests
const graph = lanternLCP.pessimisticGraph;
// Note: if moving to LCPAllFrames, mainResource would need to be the LCP frame's main resource.
const {lcpNodeToPreload, initiatorPath} = PrioritizeLcpImage.getLCPNodeToPreload(mainResource,
graph, lcpRecord);
graph, lcpImageRecord);

Check warning on line 258 in core/audits/prioritize-lcp-image.js

View check run for this annotation

Codecov / codecov/patch

core/audits/prioritize-lcp-image.js#L258

Added line #L258 was not covered by tests

const {results, wastedMs} =
PrioritizeLcpImage.computeWasteWithGraph(lcpElement, lcpNodeToPreload, graph, simulator);
Expand Down
53 changes: 53 additions & 0 deletions core/computed/document-urls.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* @license Copyright 2023 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/

import {NetworkAnalyzer} from '../lib/dependency-graph/simulator/network-analyzer.js';
import {makeComputedArtifact} from './computed-artifact.js';
import {NetworkRecords} from './network-records.js';
import {ProcessedTrace} from './processed-trace.js';

/**
* @fileoverview Compute the navigation specific URLs `requestedUrl` and `mainDocumentUrl` in situations where
* the `URL` artifact is not present. This is not a drop-in replacement for `URL` but can be helpful in situations
* where getting the `URL` artifact is difficult.
*/

class DocumentUrls {
/**
* @param {{trace: LH.Trace, devtoolsLog: LH.DevtoolsLog}} data
* @param {LH.Artifacts.ComputedContext} context
* @return {Promise<{requestedUrl: string, mainDocumentUrl: string}>}
*/
static async compute_(data, context) {
const processedTrace = await ProcessedTrace.request(data.trace, context);
const networkRecords = await NetworkRecords.request(data.devtoolsLog, context);

const mainFrameId = processedTrace.mainFrameInfo.frameId;

/** @type {string|undefined} */
let requestedUrl;
/** @type {string|undefined} */
let mainDocumentUrl;
for (const event of data.devtoolsLog) {
if (event.method === 'Page.frameNavigated' && event.params.frame.id === mainFrameId) {
const {url} = event.params.frame;
// Only set requestedUrl on the first main frame navigation.
if (!requestedUrl) requestedUrl = url;
mainDocumentUrl = url;
}
}
if (!requestedUrl || !mainDocumentUrl) throw new Error('No main frame navigations found');

const initialRequest = NetworkAnalyzer.findResourceForUrl(networkRecords, requestedUrl);
if (initialRequest?.redirects?.length) requestedUrl = initialRequest.redirects[0].url;

return {requestedUrl, mainDocumentUrl};
}
}

const DocumentUrlsComputed = makeComputedArtifact(DocumentUrls, ['devtoolsLog', 'trace']);
export {DocumentUrlsComputed as DocumentUrls};

74 changes: 74 additions & 0 deletions core/computed/lcp-image-record.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/**
* @license Copyright 2023 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/

import {makeComputedArtifact} from './computed-artifact.js';
import {NetworkRecords} from './network-records.js';
import {ProcessedNavigation} from './processed-navigation.js';
import {LighthouseError} from '../lib/lh-error.js';

/**
* @fileoverview Match the LCP event with the paint event to get the request of the image actually painted.
* This could differ from the `ImageElement` associated with the nodeId if e.g. the LCP
* was a pseudo-element associated with a node containing a smaller background-image.
*/

class LCPImageRecord {
/**
* @param {{trace: LH.Trace, devtoolsLog: LH.DevtoolsLog}} data
* @param {LH.Artifacts.ComputedContext} context
* @return {Promise<LH.Artifacts.NetworkRequest|undefined>}
*/
static async compute_(data, context) {
const {trace, devtoolsLog} = data;
const networkRecords = await NetworkRecords.request(devtoolsLog, context);
const processedNavigation = await ProcessedNavigation.request(trace, context);
if (processedNavigation.timings.largestContentfulPaint === undefined) {
throw new LighthouseError(LighthouseError.errors.NO_LCP);
}

Check warning on line 30 in core/computed/lcp-image-record.js

View check run for this annotation

Codecov / codecov/patch

core/computed/lcp-image-record.js#L29-L30

Added lines #L29 - L30 were not covered by tests

// Use main-frame-only LCP to match the metric value.
const lcpEvent = processedNavigation.largestContentfulPaintEvt;
if (!lcpEvent) return;

const lcpImagePaintEvent = trace.traceEvents.filter(e => {
return e.name === 'LargestImagePaint::Candidate' &&
e.args.frame === lcpEvent.args.frame &&
e.args.data?.DOMNodeId === lcpEvent.args.data?.nodeId &&
e.args.data?.size === lcpEvent.args.data?.size;
// Get last candidate, in case there was more than one.
}).sort((a, b) => b.ts - a.ts)[0];

const lcpUrl = lcpImagePaintEvent?.args.data?.imageUrl;
if (!lcpUrl) return;

const candidates = networkRecords.filter(record => {
return record.url === lcpUrl &&
record.finished &&
// Same frame as LCP trace event.
record.frameId === lcpImagePaintEvent.args.frame &&
record.networkRequestTime < (processedNavigation.timestamps.largestContentfulPaint || 0);
}).map(record => {
// Follow any redirects to find the real image request.
while (record.redirectDestination) {
record = record.redirectDestination;
}
return record;
}).filter(record => {
// Don't select if also loaded by some other means (xhr, etc). `resourceType`
// isn't set on redirect _sources_, so have to check after following redirects.
return record.resourceType === 'Image';
});

// If there are still multiple candidates, at this point it appears the page
// simply made multiple requests for the image. The first loaded is the best
// guess of the request that made the image available for use.
return candidates.sort((a, b) => a.networkEndTime - b.networkEndTime)[0];

Check warning on line 68 in core/computed/lcp-image-record.js

View check run for this annotation

Codecov / codecov/patch

core/computed/lcp-image-record.js#L46-L68

Added lines #L46 - L68 were not covered by tests
}
}

const LCPImageRecordComputed = makeComputedArtifact(LCPImageRecord, ['devtoolsLog', 'trace']);
export {LCPImageRecordComputed as LCPImageRecord};

58 changes: 58 additions & 0 deletions core/computed/metrics/lcp-breakdown.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* @license Copyright 2023 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/

import {makeComputedArtifact} from '../computed-artifact.js';
import {LighthouseError} from '../../lib/lh-error.js';
import {LargestContentfulPaint} from './largest-contentful-paint.js';
import {ProcessedNavigation} from '../processed-navigation.js';
import {TimeToFirstByte} from './time-to-first-byte.js';
import {LCPImageRecord} from '../lcp-image-record.js';

class LCPBreakdown {
/**
* @param {LH.Artifacts.MetricComputationDataInput} data
* @param {LH.Artifacts.ComputedContext} context
* @return {Promise<{ttfb: number, loadStart: number, loadEnd: number}>}
*/
static async compute_(data, context) {
const processedNavigation = await ProcessedNavigation.request(data.trace, context);
const observedLcp = processedNavigation.timings.largestContentfulPaint;
if (observedLcp === undefined) {
throw new LighthouseError(LighthouseError.errors.NO_LCP);
}

Check warning on line 25 in core/computed/metrics/lcp-breakdown.js

View check run for this annotation

Codecov / codecov/patch

core/computed/metrics/lcp-breakdown.js#L24-L25

Added lines #L24 - L25 were not covered by tests
const timeOrigin = processedNavigation.timestamps.timeOrigin / 1000;

const {timing: ttfb} = await TimeToFirstByte.request(data, context);

const lcpRecord = await LCPImageRecord.request(data, context);
if (!lcpRecord) {
return {ttfb, loadStart: ttfb, loadEnd: ttfb};
Copy link
Member
@brendankenny brendankenny Apr 25, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we might want to make loadStart/loadEnd undefined in these cases. The optimize-lcp article mentions making loadDelay/loadTime 0 (so setting these to ttfb), but that's technically only in the case where no resource outside the original HTML is needed for displaying the text. We don't track how fonts or dynamically added text were loaded, so we don't really know if there was another resource needed or not.

FWIW that would follow the approach taken in UKM, where loadStart/loadEnd are null/not recorded when the LCP is text/video (with implicit possible future TODOs for if/when video LCP happens and if text LCP timing is ever worth looking at)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was somewhat worried that this would mess with the lantern metric comparisons. This might be reason enough to just leave it out of the lantern tests for this PR. I will keep the test data of course.

Copy link
Member Author
@adamraine adamraine Apr 25, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update, looks like lantern testing handles this just fine, I think I might still remove the lantern test stuff from this PR since we might want to regenerate the WPT again, and should probably use a real device.

}

// Official LCP^tm. Will be lantern result if simulated, otherwise same as observedLcp.
const {timing: metricLcp} = await LargestContentfulPaint.request(data, context);
const throttleRatio = metricLcp / observedLcp;

const unclampedLoadStart = (lcpRecord.networkRequestTime - timeOrigin) * throttleRatio;
const loadStart = Math.max(ttfb, Math.min(unclampedLoadStart, metricLcp));

const unclampedLoadEnd = (lcpRecord.networkEndTime - timeOrigin) * throttleRatio;
const loadEnd = Math.max(loadStart, Math.min(unclampedLoadEnd, metricLcp));

return {
ttfb,
loadStart,
loadEnd,
};

Check warning on line 49 in core/computed/metrics/lcp-breakdown.js

View check run for this annotation

Codecov / codecov/patch

core/computed/metrics/lcp-breakdown.js#L34-L49

Added lines #L34 - L49 were not covered by tests
}
}

const LCPBreakdownComputed = makeComputedArtifact(
LCPBreakdown,
['devtoolsLog', 'gatherContext', 'settings', 'simulator', 'trace', 'URL']
);
export {LCPBreakdownComputed as LCPBreakdown};

2 changes: 1 addition & 1 deletion core/computed/metrics/metric.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ class Metric {
/**
* @param {LH.Artifacts.MetricComputationData} data
* @param {LH.Artifacts.ComputedContext} context
* @return {Promise<LH.Artifacts.LanternMetric>}
* @return {Promise<LH.Artifacts.LanternMetric|LH.Artifacts.Metric>}
*/
static computeSimulatedMetric(data, context) { // eslint-disable-line no-unused-vars
throw new Error('Unimplemented');
Expand Down
2 changes: 1 addition & 1 deletion core/computed/metrics/navigation-metric.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ class NavigationMetric extends Metric {
/**
* @param {LH.Artifacts.NavigationMetricComputationData} data
* @param {LH.Artifacts.ComputedContext} context
* @return {Promise<LH.Artifacts.LanternMetric>}
* @return {Promise<LH.Artifacts.LanternMetric|LH.Artifacts.Metric>}
*/
static computeSimulatedMetric(data, context) { // eslint-disable-line no-unused-vars
throw new Error('Unimplemented');
Expand Down
Loading
Loading