Skip to content

Commit 05b6ebf

Browse files
Connor ClarkDevtools-frontend LUCI CQ
authored andcommitted
[RPP] Compute estimated savings for Duplicated JavaScript insight
- Ported Lighthouse's estimateCompressedContentSize - Ported Lightouse's isRequestCompressed - this was inlined within the DocumentLatency insight, except it did not handle Lightrider/PSI. I changed that insight to call the new helper function. - Moved the part of Trace Processor that deferred resolving cached maps to the provided `resolveSourceMap` option, to instead be handled internally within ScriptsHandler. In these cases, resolveSourceMap is still called w/ the cached raw source map, but all it does is create an SDK.SourceMap. - The testing/TraceLoader was not resolving cached source maps stored in the metadata. Now it is. - Fixed a crash when loading an enhanced trace containing a source map that cannot be encoded via btoa. For now, just ignore the error. Bug: 394373632 Change-Id: I49104aee7fbe5f4b9dfc5061973b7e941f3a2911 Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/6404895 Auto-Submit: Connor Clark <[email protected]> Reviewed-by: Paul Irish <[email protected]> Commit-Queue: Connor Clark <[email protected]> Commit-Queue: Paul Irish <[email protected]>
1 parent 75529f1 commit 05b6ebf

File tree

15 files changed

+294
-13
lines changed

15 files changed

+294
-13
lines changed

front_end/core/sdk/EnhancedTracesParser.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,12 @@ export class EnhancedTracesParser {
234234
return;
235235
}
236236

237-
return `data:text/plain;base64,${btoa(JSON.stringify(sourceMap))}`;
237+
try {
238+
return `data:text/plain;base64,${btoa(JSON.stringify(sourceMap))}`;
239+
} catch {
240+
// TODO(cjamcl): getting InvalidCharacterError (try loading dupe-js.json.gz).
241+
return;
242+
}
238243
}
239244

240245
private getSourceMapFromMetadata(script: RehydratingScript): SourceMapV3|undefined {

front_end/models/trace/handlers/ScriptsHandler.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,25 @@ export function getScriptGeneratedSizes(script: Script): GeneratedFileSizes|null
200200
return script.sizes ?? null;
201201
}
202202

203+
function findCachedRawSourceMap(
204+
sourceMapUrl: string, options: Types.Configuration.ParseOptions): SDK.SourceMap.SourceMapV3|undefined {
205+
if (!sourceMapUrl) {
206+
return;
207+
}
208+
209+
// If loading from disk, check the metadata for source maps.
210+
// The metadata doesn't store data url source maps.
211+
const isDataUrl = sourceMapUrl.startsWith('data:');
212+
if (!options.isFreshRecording && options.metadata?.sourceMaps && !isDataUrl) {
213+
const cachedSourceMap = options.metadata.sourceMaps.find(m => m.sourceMapUrl === sourceMapUrl);
214+
if (cachedSourceMap) {
215+
return cachedSourceMap.sourceMap;
216+
}
217+
}
218+
219+
return;
220+
}
221+
203222
export async function finalize(options: Types.Configuration.ParseOptions): Promise<void> {
204223
const networkRequests = [...networkRequestsHandlerData().byId.values()];
205224
for (const script of scriptById.values()) {
@@ -248,6 +267,7 @@ export async function finalize(options: Types.Configuration.ParseOptions): Promi
248267
scriptUrl: sourceUrl as Platform.DevToolsPath.UrlString,
249268
sourceMapUrl: sourceMapUrl as Platform.DevToolsPath.UrlString,
250269
frame: script.frame as Protocol.Page.FrameId,
270+
cachedRawSourceMap: findCachedRawSourceMap(sourceMapUrl, options),
251271
};
252272
const promise = options.resolveSourceMap(params).then(sourceMap => {
253273
if (sourceMap) {

front_end/models/trace/insights/BUILD.gn

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ ts_library("unittests") {
5959
"Common.test.ts",
6060
"DOMSize.test.ts",
6161
"DocumentLatency.test.ts",
62+
"DuplicatedJavaScript.test.ts",
6263
"FontDisplay.test.ts",
6364
"ForcedReflow.test.ts",
6465
"ImageDelivery.test.ts",

front_end/models/trace/insights/Common.test.ts

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,15 @@
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 {RecursivePartial} from '../../../core/platform/TypescriptUtilities.js';
6+
import * as Protocol from '../../../generated/protocol.js';
57
import {describeWithEnvironment} from '../../../testing/EnvironmentHelpers.js';
68
import {getFirstOrError, processTrace} from '../../../testing/InsightHelpers.js';
9+
import type * as Types from '../types/types.js';
710

811
import * as Insights from './insights.js';
912

10-
const {calculateMetricWeightsForSorting} = Insights.Common;
13+
const {calculateMetricWeightsForSorting, estimateCompressedContentSize} = Insights.Common;
1114

1215
describeWithEnvironment('Common', function() {
1316
describe('calculateMetricWeightsForSorting', () => {
@@ -53,4 +56,69 @@ describeWithEnvironment('Common', function() {
5356
assert.deepEqual(weights, {lcp: 0.48649783990559314, inp: 0.48649783990559314, cls: 0.027004320188813675});
5457
});
5558
});
59+
60+
describe('#estimateCompressedContentSize', () => {
61+
const estimate = estimateCompressedContentSize;
62+
const encoding = [{name: 'Content-Encoding', value: 'gzip'}];
63+
const makeRequest = (partial: {
64+
transferSize?: number,
65+
resourceSize?: number, resourceType: Protocol.Network.ResourceType,
66+
responseHeaders?: Array<{name: string, value: string}>,
67+
}): Types.Events.SyntheticNetworkRequest => {
68+
const request: RecursivePartial<Types.Events.SyntheticNetworkRequest> = {
69+
args: {
70+
data: {
71+
encodedDataLength: partial.transferSize ?? 0,
72+
decodedBodyLength: partial.resourceSize ?? 0,
73+
resourceType: partial.resourceType,
74+
responseHeaders: partial.responseHeaders ?? [],
75+
}
76+
},
77+
};
78+
return request as Types.Events.SyntheticNetworkRequest;
79+
};
80+
81+
it('should estimate by resource type compression ratio when no network info available', () => {
82+
assert.strictEqual(estimate(undefined, 1000, Protocol.Network.ResourceType.Stylesheet), 200);
83+
assert.strictEqual(estimate(undefined, 1000, Protocol.Network.ResourceType.Script), 330);
84+
assert.strictEqual(estimate(undefined, 1000, Protocol.Network.ResourceType.Document), 330);
85+
assert.strictEqual(estimate(undefined, 1000, Protocol.Network.ResourceType.Other), 500);
86+
});
87+
88+
it('should return transferSize when asset matches and is encoded', () => {
89+
const resourceType = Protocol.Network.ResourceType.Stylesheet;
90+
const request = makeRequest({transferSize: 1234, resourceType, responseHeaders: encoding});
91+
const result = estimate(request, 10000, resourceType);
92+
assert.strictEqual(result, 1234);
93+
});
94+
95+
it('should return resourceSize when asset matches and is not encoded', () => {
96+
const resourceType = Protocol.Network.ResourceType.Stylesheet;
97+
const request = makeRequest({transferSize: 1235, resourceSize: 1234, resourceType, responseHeaders: []});
98+
const result = estimate(request, 10000, resourceType);
99+
assert.strictEqual(result, 1234);
100+
});
101+
102+
// Ex: JS script embedded in HTML response.
103+
it('should estimate by network compression ratio when asset does not match', () => {
104+
const resourceType = Protocol.Network.ResourceType.Other;
105+
const request = makeRequest({resourceSize: 2000, transferSize: 1000, resourceType, responseHeaders: encoding});
106+
const result = estimate(request, 100, Protocol.Network.ResourceType.Script);
107+
assert.strictEqual(result, 50);
108+
});
109+
110+
it('should not error when missing resource size', () => {
111+
const resourceType = Protocol.Network.ResourceType.Other;
112+
const request = makeRequest({transferSize: 1000, resourceType, responseHeaders: []});
113+
const result = estimate(request, 100, Protocol.Network.ResourceType.Script);
114+
assert.strictEqual(result, 100);
115+
});
116+
117+
it('should not error when resource size is 0', () => {
118+
const resourceType = Protocol.Network.ResourceType.Other;
119+
const request = makeRequest({transferSize: 1000, resourceSize: 0, resourceType, responseHeaders: []});
120+
const result = estimate(request, 100, Protocol.Network.ResourceType.Script);
121+
assert.strictEqual(result, 100);
122+
});
123+
});
56124
});

front_end/models/trace/insights/Common.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
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 * as Protocol from '../../../generated/protocol.js';
56
import type * as CrUXManager from '../../crux-manager/crux-manager.js';
7+
import type * as Handlers from '../handlers/handlers.js';
68
import * as Helpers from '../helpers/helpers.js';
79
import type * as Lantern from '../lantern/lantern.js';
810
import * as Types from '../types/types.js';
@@ -293,3 +295,98 @@ export function metricSavingsForWastedBytes(
293295
LCP: estimateSavingsWithGraphs(wastedBytesByRequestId, simulator, lcpGraph),
294296
};
295297
}
298+
299+
/**
300+
* Returns whether the network request was sent encoded.
301+
*/
302+
export function isRequestCompressed(request: Types.Events.SyntheticNetworkRequest): boolean {
303+
// FYI: In Lighthouse, older devtools logs (like our test fixtures) seems to be
304+
// lower case, while modern logs are Cased-Like-This.
305+
const patterns = [
306+
/^content-encoding$/i, /^x-content-encoding-over-network$/i,
307+
/^x-original-content-encoding$/i, // Lightrider.
308+
];
309+
const compressionTypes = ['gzip', 'br', 'deflate', 'zstd'];
310+
return request.args.data.responseHeaders.some(
311+
header => patterns.some(p => header.name.match(p)) && compressionTypes.includes(header.value));
312+
}
313+
314+
function getRequestSizes(request: Types.Events.SyntheticNetworkRequest): {resourceSize: number, transferSize: number} {
315+
const resourceSize = request.args.data.decodedBodyLength;
316+
const transferSize = request.args.data.encodedDataLength;
317+
return {resourceSize, transferSize};
318+
}
319+
320+
/**
321+
* Estimates the number of bytes the content of this network record would have consumed on the network based on the
322+
* uncompressed size (totalBytes). Uses the actual transfer size from the network record if applicable,
323+
* minus the size of the response headers.
324+
*
325+
* @param totalBytes Uncompressed size of the resource
326+
*/
327+
export function estimateCompressedContentSize(
328+
request: Types.Events.SyntheticNetworkRequest|undefined, totalBytes: number,
329+
resourceType: Protocol.Network.ResourceType): number {
330+
if (!request) {
331+
// We don't know how many bytes this asset used on the network, but we can guess it was
332+
// roughly the size of the content gzipped.
333+
// See https://developers.google.com/web/fundamentals/performance/optimizing-content-efficiency/optimize-encoding-and-transfer for specific CSS/Script examples
334+
// See https://discuss.httparchive.org/t/file-size-and-compression-savings/145 for fallback multipliers
335+
switch (resourceType) {
336+
case 'Stylesheet':
337+
// Stylesheets tend to compress extremely well.
338+
return Math.round(totalBytes * 0.2);
339+
case 'Script':
340+
case 'Document':
341+
// Scripts and HTML compress fairly well too.
342+
return Math.round(totalBytes * 0.33);
343+
default:
344+
// Otherwise we'll just fallback to the average savings in HTTPArchive
345+
return Math.round(totalBytes * 0.5);
346+
}
347+
}
348+
349+
// Get the size of the response body on the network.
350+
const {transferSize, resourceSize} = getRequestSizes(request);
351+
let contentTransferSize = transferSize;
352+
if (!isRequestCompressed(request)) {
353+
// This is not compressed, so we can use resourceSize directly.
354+
// This would be equivalent to transfer size minus headers transfer size, but transfer size
355+
// may also include bytes for SSL connection etc.
356+
contentTransferSize = resourceSize;
357+
}
358+
// TODO(cjamcl): Get "responseHeadersTransferSize" in Network handler.
359+
// else if (request.responseHeadersTransferSize) {
360+
// // Subtract the size of the encoded headers.
361+
// contentTransferSize =
362+
// Math.max(0, contentTransferSize - request.responseHeadersTransferSize);
363+
// }
364+
365+
if (request.args.data.resourceType === resourceType) {
366+
// This was a regular standalone asset, just use the transfer size.
367+
return contentTransferSize;
368+
}
369+
370+
// This was an asset that was inlined in a different resource type (e.g. HTML document).
371+
// Use the compression ratio of the resource to estimate the total transferred bytes.
372+
// Get the compression ratio, if it's an invalid number, assume no compression.
373+
const compressionRatio = Number.isFinite(resourceSize) && resourceSize > 0 ? (contentTransferSize / resourceSize) : 1;
374+
return Math.round(totalBytes * compressionRatio);
375+
}
376+
377+
/**
378+
* Utility function to estimate the ratio of the compression of a script.
379+
* This excludes the size of the response headers.
380+
*/
381+
export function estimateCompressionRatioForScript(script: Handlers.ModelHandlers.Scripts.Script): number {
382+
if (!script.request) {
383+
// Can't find request, so just use 1.
384+
return 1;
385+
}
386+
387+
const request = script.request;
388+
const contentLength = request.args.data.decodedBodyLength ?? script.content?.length ?? 0;
389+
const compressedSize = estimateCompressedContentSize(request, contentLength, Protocol.Network.ResourceType.Script);
390+
const compressionRatio = compressedSize / contentLength;
391+
return compressionRatio;
392+
}

front_end/models/trace/insights/DocumentLatency.ts

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type * as Handlers from '../handlers/handlers.js';
77
import * as Helpers from '../helpers/helpers.js';
88
import * as Types from '../types/types.js';
99

10+
import {isRequestCompressed} from './Common.js';
1011
import {
1112
type Checklist,
1213
InsightCategory,
@@ -101,15 +102,7 @@ function getServerResponseTime(request: Types.Events.SyntheticNetworkRequest): T
101102
}
102103

103104
function getCompressionSavings(request: Types.Events.SyntheticNetworkRequest): number {
104-
// Check from headers if compression was already applied.
105-
// Older devtools logs are lower case, while modern logs are Cased-Like-This.
106-
const patterns = [
107-
/^content-encoding$/i,
108-
/^x-content-encoding-over-network$/i,
109-
];
110-
const compressionTypes = ['gzip', 'br', 'deflate', 'zstd'];
111-
const isCompressed = request.args.data.responseHeaders.some(
112-
header => patterns.some(p => header.name.match(p)) && compressionTypes.includes(header.value));
105+
const isCompressed = isRequestCompressed(request);
113106
if (isCompressed) {
114107
return 0;
115108
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// Copyright 2025 The Chromium Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import {describeWithEnvironment} from '../../../testing/EnvironmentHelpers.js';
6+
import {getFirstOrError, getInsightOrError, processTrace} from '../../../testing/InsightHelpers.js';
7+
import type * as Trace from '../trace.js';
8+
9+
describeWithEnvironment('DuplicatedJavaScript', function() {
10+
it('works', async () => {
11+
const {data, insights} = await processTrace(this, 'dupe-js.json.gz');
12+
assert.strictEqual(insights.size, 1);
13+
const insight = getInsightOrError(
14+
'DuplicatedJavaScript', insights, getFirstOrError(data.Meta.navigationsByNavigationId.values()));
15+
16+
const duplication = insight.duplicationGroupedByNodeModules;
17+
const results = Object.fromEntries(
18+
[...duplication.entries()].filter(v => v[1].estimatedDuplicateBytes > 1000 * 100).map(([key, data]) => {
19+
return [key, data.duplicates.map(v => ({url: v.script.url, resourceSize: v.attributedSize}))];
20+
}));
21+
const url1 = 'https://dupe-modules-lh-2.surge.sh/bundle.js?v1';
22+
const url2 = 'https://dupe-modules-lh-2.surge.sh/bundle.js?v2';
23+
const url3 = 'https://dupe-modules-lh-2.surge.sh/bundle.js?v3';
24+
const url4 = 'https://dupe-modules-lh-2.surge.sh/bundle.js?v4';
25+
26+
assert.deepEqual(results, {
27+
'node_modules/@headlessui/react': [
28+
{url: url1, resourceSize: 56331},
29+
{url: url2, resourceSize: 56331},
30+
{url: url3, resourceSize: 56331},
31+
{url: url4, resourceSize: 56331},
32+
],
33+
'node_modules/filestack-js': [
34+
{url: url1, resourceSize: 423206},
35+
{url: url2, resourceSize: 423206},
36+
{url: url3, resourceSize: 423206},
37+
{url: url4, resourceSize: 423206},
38+
],
39+
'node_modules/react-query': [
40+
{url: url1, resourceSize: 40357},
41+
{url: url2, resourceSize: 40357},
42+
{url: url3, resourceSize: 40357},
43+
{url: url4, resourceSize: 40357},
44+
],
45+
});
46+
47+
assert.deepEqual(insight.metricSavings, {FCP: 100, LCP: 100} as Trace.Insights.Types.MetricSavings);
48+
});
49+
});

front_end/models/trace/insights/DuplicatedJavaScript.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import * as Extras from '../extras/extras.js';
77
import type * as Handlers from '../handlers/handlers.js';
88
import * as Helpers from '../helpers/helpers.js';
99

10+
import {estimateCompressionRatioForScript, metricSavingsForWastedBytes} from './Common.js';
1011
import {
1112
InsightCategory,
1213
InsightKeys,
@@ -77,11 +78,28 @@ export function generateInsight(
7778

7879
const {duplication, duplicationGroupedByNodeModules} = Extras.ScriptDuplication.computeScriptDuplication({scripts});
7980
const scriptsWithDuplication = [...duplication.values().flatMap(data => data.duplicates.map(d => d.script))];
81+
82+
const wastedBytesByRequestId = new Map<string, number>();
83+
for (const {duplicates} of duplication.values()) {
84+
for (let i = 1; i < duplicates.length; i++) {
85+
const sourceData = duplicates[i];
86+
if (!sourceData.script.request) {
87+
continue;
88+
}
89+
90+
const compressionRatio = estimateCompressionRatioForScript(sourceData.script);
91+
const transferSize = Math.round(sourceData.attributedSize * compressionRatio);
92+
const requestId = sourceData.script.request.args.data.requestId;
93+
wastedBytesByRequestId.set(requestId, (wastedBytesByRequestId.get(requestId) || 0) + transferSize);
94+
}
95+
}
96+
8097
return finalize({
8198
duplication,
8299
duplicationGroupedByNodeModules,
83100
scriptsWithDuplication: [...new Set(scriptsWithDuplication)],
84101
scripts,
85102
mainDocumentUrl: context.navigation?.args.data?.url ?? parsedTrace.Meta.mainFrameURL,
103+
metricSavings: metricSavingsForWastedBytes(wastedBytesByRequestId, context),
86104
});
87105
}

front_end/models/trace/types/Configuration.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,4 +82,6 @@ export interface ResolveSourceMapParams {
8282
scriptUrl: Platform.DevToolsPath.UrlString;
8383
sourceMapUrl: Platform.DevToolsPath.UrlString;
8484
frame: Protocol.Page.FrameId;
85+
/** Set only if the raw source map was found on the provided metadata. Never set for source maps from data urls. */
86+
cachedRawSourceMap?: SDK.SourceMap.SourceMapV3;
8587
}

front_end/panels/timeline/TimelinePanel.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2486,7 +2486,11 @@ export class TimelinePanel extends UI.Panel.Panel implements Client, TimelineMod
24862486
}
24872487

24882488
return async function resolveSourceMap(params: Trace.Types.Configuration.ResolveSourceMapParams) {
2489-
const {scriptId, scriptUrl, sourceMapUrl, frame} = params;
2489+
const {scriptId, scriptUrl, sourceMapUrl, frame, cachedRawSourceMap} = params;
2490+
2491+
if (cachedRawSourceMap) {
2492+
return new SDK.SourceMap.SourceMap(scriptUrl, sourceMapUrl, cachedRawSourceMap);
2493+
}
24902494

24912495
// For still-active frames, the source map is likely already fetched or at least in-flight.
24922496
if (isFreshRecording) {

0 commit comments

Comments
 (0)