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(legacy-javascript): exclude header size for estimating wasted bytes #15640

Merged
merged 10 commits into from
Dec 2, 2023
60 changes: 60 additions & 0 deletions core/audits/byte-efficiency/byte-efficiency-audit.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import {LanternLargestContentfulPaint} from '../../computed/metrics/lantern-largest-contentful-paint.js';
import {LanternFirstContentfulPaint} from '../../computed/metrics/lantern-first-contentful-paint.js';
import {LCPImageRecord} from '../../computed/lcp-image-record.js';
import {NetworkRequest} from '../../lib/network-request.js';

const str_ = i18n.createIcuMessageFn(import.meta.url, {});

Expand Down Expand Up @@ -100,6 +101,65 @@
}
}

/**
* Estimates the number of bytes the content of this network record would have consumed on the network based on the
* uncompressed size (totalBytes). Uses the actual transfer size from the network record if applicable,
* minus the size of the response headers.
*
* This differs from `estimateTransferSize` only in that is subtracts the response headers from the estimate.
*
* @param {LH.Artifacts.NetworkRequest|undefined} networkRecord
* @param {number} totalBytes Uncompressed size of the resource
* @param {LH.Crdp.Network.ResourceType=} resourceType
* @return {number}
*/
static estimateCompressedContentSize(networkRecord, totalBytes, resourceType) {
if (!networkRecord) {
// We don't know how many bytes this asset used on the network, but we can guess it was
// roughly the size of the content gzipped.
// See https://developers.google.com/web/fundamentals/performance/optimizing-content-efficiency/optimize-encoding-and-transfer for specific CSS/Script examples
// See https://discuss.httparchive.org/t/file-size-and-compression-savings/145 for fallback multipliers
switch (resourceType) {
case 'Stylesheet':
// Stylesheets tend to compress extremely well.
return Math.round(totalBytes * 0.2);
case 'Script':
case 'Document':
// Scripts and HTML compress fairly well too.
return Math.round(totalBytes * 0.33);
default:
// Otherwise we'll just fallback to the average savings in HTTPArchive
return Math.round(totalBytes * 0.5);
}
}

Check warning on line 134 in core/audits/byte-efficiency/byte-efficiency-audit.js

View check run for this annotation

Codecov / codecov/patch

core/audits/byte-efficiency/byte-efficiency-audit.js#L118-L134

Added lines #L118 - L134 were not covered by tests
Copy link
Member

Choose a reason for hiding this comment

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

nit: could have a resusable function like estimateGzipSizeByType ore something


// Get the size of the response body on the network.
let contentTransferSize = networkRecord.transferSize || 0;
if (!NetworkRequest.isContentEncoded(networkRecord)) {
// This is not encoded, so we can use resourceSize directly.
// This would be equivalent to transfer size minus headers transfer size, but transfer size
// may also include bytes for SSL connection etc.
contentTransferSize = networkRecord.resourceSize;
} else if (networkRecord.responseHeadersTransferSize) {
// Subtract the size of the encoded headers.
contentTransferSize =
Math.max(0, contentTransferSize - networkRecord.responseHeadersTransferSize);
}

Check warning on line 147 in core/audits/byte-efficiency/byte-efficiency-audit.js

View check run for this annotation

Codecov / codecov/patch

core/audits/byte-efficiency/byte-efficiency-audit.js#L144-L147

Added lines #L144 - L147 were not covered by tests

if (networkRecord.resourceType === resourceType) {
// This was a regular standalone asset, just use the transfer size.
return contentTransferSize;
} else {
// This was an asset that was inlined in a different resource type (e.g. HTML document).
// Use the compression ratio of the resource to estimate the total transferred bytes.
const resourceSize = networkRecord.resourceSize || 0;
// Get the compression ratio, if it's an invalid number, assume no compression.
const compressionRatio = Number.isFinite(resourceSize) && resourceSize > 0 ?
(contentTransferSize / resourceSize) : 1;
return Math.round(totalBytes * compressionRatio);
}
}

/**
* @param {LH.Artifacts} artifacts
* @param {LH.Audit.Context} context
Expand Down
41 changes: 23 additions & 18 deletions core/audits/byte-efficiency/legacy-javascript.js
Original file line number Diff line number Diff line change
Expand Up @@ -391,34 +391,39 @@
}

/**
* Utility function to estimate transfer size and cache calculation.
* Utility function to estimate the ratio of the compression on the resource.
* This excludes the size of the response headers.
* Also caches the calculation.
*
* Note: duplicated-javascript does this exact thing. In the future, consider
* making a generic estimator on ByteEfficienyAudit.
* @param {Map<string, number>} transferRatioByUrl
* making a generic estimator on ByteEfficiencyAudit.
* @param {Map<string, number>} compressionRatioByUrl
* @param {string} url
* @param {LH.Artifacts} artifacts
* @param {Array<LH.Artifacts.NetworkRequest>} networkRecords
*/
static async estimateTransferRatioForScript(transferRatioByUrl, url, artifacts, networkRecords) {
let transferRatio = transferRatioByUrl.get(url);
if (transferRatio !== undefined) return transferRatio;
static async estimateCompressionRatioForContent(compressionRatioByUrl, url,
artifacts, networkRecords) {
let compressionRatio = compressionRatioByUrl.get(url);
if (compressionRatio !== undefined) return compressionRatio;

const script = artifacts.Scripts.find(script => script.url === url);

if (!script || script.content === null) {
if (!script) {
// Can't find content, so just use 1.
transferRatio = 1;
compressionRatio = 1;

Check warning on line 414 in core/audits/byte-efficiency/legacy-javascript.js

View check run for this annotation

Codecov / codecov/patch

core/audits/byte-efficiency/legacy-javascript.js#L414

Added line #L414 was not covered by tests
} else {
const networkRecord = getRequestForScript(networkRecords, script);
const contentLength = script.length || 0;
const transferSize =
ByteEfficiencyAudit.estimateTransferSize(networkRecord, contentLength, 'Script');
transferRatio = transferSize / contentLength;
const contentLength = networkRecord?.resourceSize ?
networkRecord.resourceSize :
script.length || 0;
connorjclark marked this conversation as resolved.
Show resolved Hide resolved
const compressedSize =
ByteEfficiencyAudit.estimateCompressedContentSize(networkRecord, contentLength, 'Script');
compressionRatio = compressedSize / contentLength;
}

transferRatioByUrl.set(url, transferRatio);
return transferRatio;
compressionRatioByUrl.set(url, compressionRatio);
return compressionRatio;
}

/**
Expand All @@ -443,14 +448,14 @@
]);

/** @type {Map<string, number>} */
const transferRatioByUrl = new Map();
const compressionRatioByUrl = new Map();

const scriptToMatchResults =
this.detectAcrossScripts(matcher, artifacts.Scripts, networkRecords, bundles);
for (const [script, matches] of scriptToMatchResults.entries()) {
const transferRatio = await this.estimateTransferRatioForScript(
transferRatioByUrl, script.url, artifacts, networkRecords);
const wastedBytes = Math.round(this.estimateWastedBytes(matches) * transferRatio);
const compressionRatio = await this.estimateCompressionRatioForContent(
compressionRatioByUrl, script.url, artifacts, networkRecords);
const wastedBytes = Math.round(this.estimateWastedBytes(matches) * compressionRatio);
/** @type {typeof items[number]} */
const item = {
url: script.url,
Expand Down
11 changes: 11 additions & 0 deletions core/lib/network-request.js
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ class NetworkRequest {

// Go read the comment on _updateTransferSizeForLightrider.
this.transferSize = 0;
this.responseHeadersTransferSize = 0;
this.resourceSize = 0;
this.fromDiskCache = false;
this.fromMemoryCache = false;
Expand Down Expand Up @@ -344,6 +345,7 @@ class NetworkRequest {
this.responseHeadersEndTime = timestamp * 1000;

this.transferSize = response.encodedDataLength;
this.responseHeadersTransferSize = response.encodedDataLength;
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

name is maybe not best, as this (I'm pretty sure) includes SSL/TSL transferred bytes...

Copy link
Contributor

Choose a reason for hiding this comment

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

It's descriptive, SGTM

if (typeof response.fromDiskCache === 'boolean') this.fromDiskCache = response.fromDiskCache;
if (typeof response.fromPrefetchCache === 'boolean') {
this.fromPrefetchCache = response.fromPrefetchCache;
Expand Down Expand Up @@ -600,6 +602,15 @@ class NetworkRequest {
return reason === 'HSTS' && NetworkRequest.isSecureRequest(destination);
}

/**
* Returns whether the network request was sent encoded.
* @param {NetworkRequest} record
* @return {boolean}
*/
static isContentEncoded(record) {
return record.responseHeaders.some(item => item.name === 'Content-Encoding');
}

/**
* Resource size is almost always the right one to be using because of the below:
* `transferSize = resourceSize + headers.length`.
Expand Down
51 changes: 51 additions & 0 deletions core/test/audits/byte-efficiency/byte-efficiency-audit-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,57 @@ describe('Byte efficiency base audit', () => {
});
});

describe('#estimateCompressedContentSize', () => {
const estimate = ByteEfficiencyAudit.estimateCompressedContentSize;
const encoding = [{name: 'Content-Encoding'}];

it('should estimate by resource type compression ratio when no network info available', () => {
assert.equal(estimate(undefined, 1000, 'Stylesheet'), 200);
assert.equal(estimate(undefined, 1000, 'Script'), 330);
assert.equal(estimate(undefined, 1000, 'Document'), 330);
assert.equal(estimate(undefined, 1000, ''), 500);
});

it('should return transferSize when asset matches and is encoded', () => {
const resourceType = 'Stylesheet';
const result = estimate(
{transferSize: 1234, resourceType, responseHeaders: encoding},
10000, 'Stylesheet');
assert.equal(result, 1234);
});

it('should return resourceSize when asset matches and is not encoded', () => {
const resourceType = 'Stylesheet';
const result = estimate(
{transferSize: 1235, resourceSize: 1234, resourceType, responseHeaders: []},
10000, 'Stylesheet');
assert.equal(result, 1234);
});

// Ex: JS script embedded in HTML response.
it('should estimate by network compression ratio when asset does not match', () => {
const resourceType = 'Other';
const result = estimate(
{resourceSize: 2000, transferSize: 1000, resourceType, responseHeaders: encoding},
100);
assert.equal(result, 50);
});

it('should not error when missing resource size', () => {
const resourceType = 'Other';
const result = estimate({transferSize: 1000, resourceType, responseHeaders: []}, 100);
assert.equal(result, 100);
});

it('should not error when resource size is 0', () => {
const resourceType = 'Other';
const result = estimate(
{transferSize: 1000, resourceSize: 0, resourceType, responseHeaders: []},
100);
assert.equal(result, 100);
});
});

it('should format details', async () => {
const result = await ByteEfficiencyAudit.createAuditProduct({
headings: baseHeadings,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const getResult = scripts => {
...scripts.map(({url}, index) => ({
requestId: String(index),
url,
responseHeaders: [],
})),
];
const artifacts = {
Expand Down
2 changes: 2 additions & 0 deletions core/test/computed/metrics/time-to-first-byte-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ function mockNetworkRecords() {
transferSize: 300,
url: requestedUrl,
frameId: 'ROOT_FRAME',
responseHeaders: [{name: 'Content-Encoding'}],
},
{
requestId: '2:redirect',
Expand All @@ -57,6 +58,7 @@ function mockNetworkRecords() {
transferSize: 16_000,
url: mainDocumentUrl,
frameId: 'ROOT_FRAME',
responseHeaders: [{name: 'Content-Encoding'}],
}];
}

Expand Down
18 changes: 12 additions & 6 deletions core/test/fixtures/user-flows/reports/sample-flow-result.json
Original file line number Diff line number Diff line change
Expand Up @@ -3286,7 +3286,7 @@
"items": [
{
"url": "https://www.mikescerealshack.co/_next/static/chunks/commons.49455e4fa8cc3f51203f.js",
"wastedBytes": 57,
"wastedBytes": 167,
"subItems": {
"type": "subitems",
"items": [
Expand All @@ -3306,7 +3306,7 @@
}
],
"overallSavingsMs": 0,
"overallSavingsBytes": 57,
"overallSavingsBytes": 167,
"sortedBy": [
"wastedBytes"
],
Expand Down Expand Up @@ -8034,7 +8034,7 @@
"core/lib/i18n/i18n.js | displayValueByteSavings": [
{
"values": {
"wastedBytes": 57
"wastedBytes": 167
},
"path": "audits[legacy-javascript].displayValue"
}
Expand Down Expand Up @@ -20850,7 +20850,7 @@
"scoreDisplayMode": "metricSavings",
"numericValue": 0,
"numericUnit": "millisecond",
"displayValue": "",
"displayValue": "Potential savings of 0 KiB",
"metricSavings": {
"FCP": 0,
"LCP": 0
Expand Down Expand Up @@ -20884,7 +20884,7 @@
"items": [
{
"url": "https://www.mikescerealshack.co/_next/static/chunks/commons.49455e4fa8cc3f51203f.js",
"wastedBytes": 0,
"wastedBytes": 167,
"subItems": {
"type": "subitems",
"items": [
Expand All @@ -20904,7 +20904,7 @@
}
],
"overallSavingsMs": 0,
"overallSavingsBytes": 0,
"overallSavingsBytes": 167,
"sortedBy": [
"wastedBytes"
],
Expand Down Expand Up @@ -25629,6 +25629,12 @@
"wastedBytes": 52102
},
"path": "audits[uses-responsive-images].displayValue"
},
{
"values": {
"wastedBytes": 167
},
"path": "audits[legacy-javascript].displayValue"
}
],
"core/lib/i18n/i18n.js | columnResourceSize": [
Expand Down
7 changes: 6 additions & 1 deletion core/test/network-records-to-devtools-log-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,12 @@ describe('networkRecordsToDevtoolsLog', () => {
const roundTripLogs = networkRecordsToDevtoolsLog(records, {skipVerification: true});
const roundTripRecords = NetworkRecorder.recordsFromLogs(roundTripLogs);

expect(roundTripRecords).toEqual(records);
// First compare element-wise, as doing all at once results in too verbose an error message.
const len = Math.min(roundTripRecords.length, records.length);
for (let i = 0; i < len; i++) {
expect(roundTripRecords[i]).toEqual(records[i]);
}
expect(roundTripRecords.length).toEqual(records.length);
});

it('should roundtrip fake network records multiple times', () => {
Expand Down
3 changes: 1 addition & 2 deletions core/test/network-records-to-devtools-log.js
Original file line number Diff line number Diff line change
Expand Up @@ -288,8 +288,7 @@ function getResponseReceivedEvent(networkRecord, index, normalizedTiming) {
connectionId: networkRecord.connectionId || 140,
fromDiskCache: networkRecord.fromDiskCache || false,
fromServiceWorker: networkRecord.fetchedViaServiceWorker || false,
encodedDataLength: networkRecord.transferSize === undefined ?
0 : networkRecord.transferSize,
encodedDataLength: networkRecord.responseHeadersTransferSize || networkRecord.transferSize,
timing: {...normalizedTiming.timing},
protocol: networkRecord.protocol || 'http/1.1',
},
Expand Down
6 changes: 3 additions & 3 deletions core/test/results/sample_v2.json
Original file line number Diff line number Diff line change
Expand Up @@ -4975,7 +4975,7 @@
"items": [
{
"url": "http://localhost:10200/dobetterweb/third_party/aggressive-promise-polyfill.js",
"wastedBytes": 26622,
"wastedBytes": 26585,
"subItems": {
"type": "subitems",
"items": [
Expand Down Expand Up @@ -5005,7 +5005,7 @@
}
],
"overallSavingsMs": 450,
"overallSavingsBytes": 26622,
"overallSavingsBytes": 26585,
"sortedBy": [
"wastedBytes"
],
Expand Down Expand Up @@ -10473,7 +10473,7 @@
},
{
"values": {
"wastedBytes": 26622
"wastedBytes": 26585
},
"path": "audits[legacy-javascript].displayValue"
}
Expand Down
Loading