Skip to content

Commit 5c03c3e

Browse files
Adriana IxbaDevtools-frontend LUCI CQ
authored andcommitted
[RPP] Create warm load cache insight
https://screenshot.googleplex.com/8aLKQ4ei4fZqUmq Bug:394357486 Change-Id: I62bd68e87e99826e58b2e4a9b1cc4f9642b04213 Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/6281901 Reviewed-by: Adam Raine <[email protected]> Commit-Queue: Adriana Ixba <[email protected]>
1 parent 51a0dce commit 5c03c3e

File tree

17 files changed

+660
-1
lines changed

17 files changed

+660
-1
lines changed

config/gni/devtools_grd_files.gni

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1160,6 +1160,7 @@ grd_files_debug_sources = [
11601160
"front_end/models/trace/insights/SlowCSSSelector.js",
11611161
"front_end/models/trace/insights/Statistics.js",
11621162
"front_end/models/trace/insights/ThirdParties.js",
1163+
"front_end/models/trace/insights/UseCache.js",
11631164
"front_end/models/trace/insights/Viewport.js",
11641165
"front_end/models/trace/insights/types.js",
11651166
"front_end/models/trace/lantern/core/LanternError.js",
@@ -1934,6 +1935,7 @@ grd_files_debug_sources = [
19341935
"front_end/panels/timeline/components/insights/SlowCSSSelector.js",
19351936
"front_end/panels/timeline/components/insights/Table.js",
19361937
"front_end/panels/timeline/components/insights/ThirdParties.js",
1938+
"front_end/panels/timeline/components/insights/UseCache.js",
19371939
"front_end/panels/timeline/components/insights/Viewport.js",
19381940
"front_end/panels/timeline/components/insights/baseInsightComponent.css.js",
19391941
"front_end/panels/timeline/components/insights/checklist.css.js",

front_end/models/trace/Processor.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,7 @@ describeWithEnvironment('TraceProcessor', function() {
396396
'DuplicateJavaScript',
397397
'SlowCSSSelector',
398398
'ForcedReflow',
399+
'UseCache',
399400
]);
400401

401402
const orderWithMetadata = await getInsightOrder(true);
@@ -416,6 +417,7 @@ describeWithEnvironment('TraceProcessor', function() {
416417
'DuplicateJavaScript',
417418
'SlowCSSSelector',
418419
'ForcedReflow',
420+
'UseCache',
419421
]);
420422
});
421423
});

front_end/models/trace/Processor.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,7 @@ export class TraceProcessor extends EventTarget {
371371
DuplicateJavaScript: null,
372372
SlowCSSSelector: null,
373373
ForcedReflow: null,
374+
UseCache: null,
374375
};
375376

376377
// Determine the weights for each metric based on field data, utilizing the same scoring curve that Lighthouse uses.

front_end/models/trace/helpers/Network.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,77 @@ const HIGH_NETWORK_PRIORITIES = new Set<Protocol.Network.ResourcePriority>([
3535
export function isSyntheticNetworkRequestHighPriority(event: SyntheticNetworkRequest): boolean {
3636
return HIGH_NETWORK_PRIORITIES.has(event.args.data.priority);
3737
}
38+
39+
export interface CacheControl {
40+
'max-age'?: number;
41+
'no-cache'?: boolean;
42+
'no-store'?: boolean;
43+
'must-revalidate'?: boolean;
44+
// eslint-disable-next-line @stylistic/quote-props
45+
'private'?: boolean;
46+
}
47+
48+
export const CACHEABLE_STATUS_CODES = new Set([200, 203, 206]);
49+
50+
/** @type {Set<LH.Crdp.Network.ResourceType>} */
51+
export const STATIC_RESOURCE_TYPES = new Set([
52+
Protocol.Network.ResourceType.Font,
53+
Protocol.Network.ResourceType.Image,
54+
Protocol.Network.ResourceType.Media,
55+
Protocol.Network.ResourceType.Script,
56+
Protocol.Network.ResourceType.Stylesheet,
57+
]);
58+
59+
export const NON_NETWORK_SCHEMES = [
60+
'blob', // @see https://developer.mozilla.org/en-US/docs/Web/API/URL/createObjectURL
61+
'data', // @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs
62+
'intent', // @see https://developer.chrome.com/docs/multidevice/android/intents/
63+
'file', // @see https://en.wikipedia.org/wiki/File_URI_scheme
64+
'filesystem', // @see https://developer.mozilla.org/en-US/docs/Web/API/FileSystem
65+
'chrome-extension',
66+
];
67+
68+
/**
69+
* Parses Cache-Control directives based on https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control
70+
* eg. 'no-cache, no-store, max-age=0, no-transform, private' will return
71+
* {no-cache: true, no-store: true, max-age: 0, no-transform: true, private: true}
72+
*/
73+
export function parseCacheControl(header: string|null): CacheControl|null {
74+
if (!header) {
75+
return null;
76+
}
77+
78+
const directives = header.split(',').map(directive => directive.trim());
79+
const cacheControlOptions: CacheControl = {};
80+
81+
for (const directive of directives) {
82+
const [key, value] = directive.split('=').map(part => part.trim());
83+
84+
switch (key) {
85+
case 'max-age': {
86+
const maxAge = parseInt(value, 10);
87+
if (!isNaN(maxAge)) {
88+
cacheControlOptions['max-age'] = maxAge;
89+
}
90+
break;
91+
}
92+
case 'no-cache':
93+
cacheControlOptions['no-cache'] = true;
94+
break;
95+
case 'no-store':
96+
cacheControlOptions['no-store'] = true;
97+
break;
98+
case 'must-revalidate':
99+
cacheControlOptions['must-revalidate'] = true;
100+
break;
101+
case 'private':
102+
cacheControlOptions['private'] = true;
103+
break;
104+
default:
105+
// Ignore unknown directives
106+
break;
107+
}
108+
}
109+
110+
return cacheControlOptions;
111+
}

front_end/models/trace/insights/BUILD.gn

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ devtools_module("insights") {
2525
"SlowCSSSelector.ts",
2626
"Statistics.ts",
2727
"ThirdParties.ts",
28+
"UseCache.ts",
2829
"Viewport.ts",
2930
"types.ts",
3031
]
@@ -67,6 +68,7 @@ ts_library("unittests") {
6768
"SlowCSSSelector.test.ts",
6869
"Statistics.test.ts",
6970
"ThirdParties.test.ts",
71+
"UseCache.test.ts",
7072
"Viewport.test.ts",
7173
]
7274

front_end/models/trace/insights/Models.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,5 @@ export * as NetworkDependencyTree from './NetworkDependencyTree.js';
1616
export * as RenderBlocking from './RenderBlocking.js';
1717
export * as SlowCSSSelector from './SlowCSSSelector.js';
1818
export * as ThirdParties from './ThirdParties.js';
19+
export * as UseCache from './UseCache.js';
1920
export * as Viewport from './Viewport.js';

front_end/models/trace/insights/Statistics.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,3 +91,11 @@ export function getLogNormalScore({median, p10}: {median: number, p10: number},
9191
}
9292
return score;
9393
}
94+
95+
/**
96+
* Interpolates the y value at a point x on the line defined by (x0, y0) and (x1, y1)
97+
*/
98+
export function linearInterpolation(x0: number, y0: number, x1: number, y1: number, x: number): number {
99+
const slope = (y1 - y0) / (x1 - x0);
100+
return y0 + (x - x0) * slope;
101+
}
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
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 * as Protocol from '../../../generated/protocol.js';
6+
import {describeWithEnvironment} from '../../../testing/EnvironmentHelpers.js';
7+
import {getFirstOrError, getInsightOrError, processTrace} from '../../../testing/InsightHelpers.js';
8+
import type * as Trace from '../../trace/trace.js';
9+
10+
import * as UseCache from './UseCache.js';
11+
12+
describeWithEnvironment('UseCache', function() {
13+
describe('isCacheable', () => {
14+
it('should return true for cacheable requests', () => {
15+
const cacheableRequest = {
16+
args: {
17+
data: {
18+
protocol: 'https',
19+
resourceType: Protocol.Network.ResourceType.Script,
20+
statusCode: 200,
21+
},
22+
},
23+
} as unknown as Trace.Types.Events.SyntheticNetworkRequest;
24+
assert.isTrue(UseCache.isCacheable(cacheableRequest));
25+
});
26+
27+
it('should return false for non-network scheme: file', () => {
28+
const nonNetworkRequest = {
29+
args: {
30+
data: {
31+
protocol: 'file',
32+
resourceType: Protocol.Network.ResourceType.Script,
33+
statusCode: 200,
34+
},
35+
},
36+
} as unknown as Trace.Types.Events.SyntheticNetworkRequest;
37+
assert.isFalse(UseCache.isCacheable(nonNetworkRequest));
38+
});
39+
40+
it('should return false for non-cacheable status codes', () => {
41+
const nonCacheableRequest = {
42+
args: {
43+
data: {
44+
protocol: 'https',
45+
resourceType: Protocol.Network.ResourceType.Script,
46+
statusCode: 404,
47+
},
48+
},
49+
} as unknown as Trace.Types.Events.SyntheticNetworkRequest;
50+
assert.isFalse(UseCache.isCacheable(nonCacheableRequest));
51+
});
52+
53+
it('should return false for non-static resource types', () => {
54+
const nonCacheableRequest = {
55+
args: {
56+
data: {
57+
protocol: 'https',
58+
resourceType: Protocol.Network.ResourceType.XHR,
59+
statusCode: 200,
60+
},
61+
},
62+
} as unknown as Trace.Types.Events.SyntheticNetworkRequest;
63+
assert.isFalse(UseCache.isCacheable(nonCacheableRequest));
64+
});
65+
});
66+
67+
describe('computeCacheLifetimeInSeconds', () => {
68+
it('should return max-age if defined', () => {
69+
const headers = [{name: 'cache-control', value: 'max-age=3600'}];
70+
const cacheControl = {
71+
'max-age': 3600,
72+
};
73+
assert.strictEqual(UseCache.computeCacheLifetimeInSeconds(headers, cacheControl), 3600);
74+
});
75+
76+
it('should return expires header if defined and max-age is not defined', () => {
77+
const now = Date.now();
78+
const future = new Date(now + 5000).toUTCString();
79+
const headers = [{name: 'expires', value: future}];
80+
const cacheControl = null;
81+
assert.strictEqual(UseCache.computeCacheLifetimeInSeconds(headers, cacheControl), 5);
82+
});
83+
84+
it('should handle negative max-age', () => {
85+
const past = new Date(Date.now() - 5000).toUTCString();
86+
const headers = [{name: 'expires', value: past}];
87+
const cacheControl = null;
88+
assert.strictEqual(UseCache.computeCacheLifetimeInSeconds(headers, cacheControl), -5);
89+
});
90+
91+
it('should return null if neither max-age nor expires is defined', () => {
92+
const headers: Array<{name: string, value: string}> = [];
93+
const cacheControl = null;
94+
assert.isNull(UseCache.computeCacheLifetimeInSeconds(headers, cacheControl));
95+
});
96+
97+
it('should return 0 if expires is invalid', () => {
98+
const headers = [{name: 'expires', value: 'invalid date'}];
99+
const cacheControl = null;
100+
assert.strictEqual(UseCache.computeCacheLifetimeInSeconds(headers, cacheControl), 0);
101+
});
102+
});
103+
104+
describe('cachingDisabled', () => {
105+
it('should return true if cache-control contains no-cache', () => {
106+
const headers = new Map([['cache-control', 'no-cache']]);
107+
const parsedCacheControl = {
108+
'no-cache': true,
109+
};
110+
assert.isTrue(UseCache.cachingDisabled(headers, parsedCacheControl));
111+
});
112+
113+
it('should return true if cache-control contains no-store', () => {
114+
const headers = new Map([['cache-control', 'no-store']]);
115+
const parsedCacheControl = {
116+
'no-store': true,
117+
};
118+
assert.isTrue(UseCache.cachingDisabled(headers, parsedCacheControl));
119+
});
120+
121+
it('should return true if cache-control contains must-revalidate', () => {
122+
const headers = new Map([['cache-control', 'must-revalidate']]);
123+
const parsedCacheControl = {
124+
'must-revalidate': true,
125+
};
126+
assert.isTrue(UseCache.cachingDisabled(headers, parsedCacheControl));
127+
});
128+
129+
it('should return true if cache-control contains private', () => {
130+
const headers = new Map([['cache-control', 'private']]);
131+
132+
const parsedCacheControl = {
133+
private: true,
134+
};
135+
assert.isTrue(UseCache.cachingDisabled(headers, parsedCacheControl));
136+
});
137+
138+
it('should return true if pragma contains no-cache and no cache-control', () => {
139+
const headers = new Map([['pragma', 'no-cache']]);
140+
const parsedCacheControl = null;
141+
assert.isTrue(UseCache.cachingDisabled(headers, parsedCacheControl));
142+
});
143+
144+
it('should return false if no disabling headers are present', () => {
145+
const headers = new Map([['cache-control', 'max-age=3600']]);
146+
const parsedCacheControl = {
147+
'max-age': 3600,
148+
};
149+
assert.isFalse(UseCache.cachingDisabled(headers, parsedCacheControl));
150+
});
151+
152+
it('should return false if pragma contains no-cache but cache-control is present', () => {
153+
const headers = new Map([['cache-control', 'max-age=3600'], ['pragma', 'no-cache']]);
154+
const parsedCacheControl = {
155+
'max-age': 3600,
156+
};
157+
assert.isFalse(UseCache.cachingDisabled(headers, parsedCacheControl));
158+
});
159+
});
160+
describe('getHeaders', () => {
161+
it('should handle multiple headers with the same name', () => {
162+
const responseHeaders = [
163+
{name: 'cache-control', value: 'max-age=3600'},
164+
{name: 'cache-control', value: 'public'},
165+
{name: 'content-type', value: 'text/css'},
166+
];
167+
const headers = UseCache.getCombinedHeaders(responseHeaders);
168+
assert.strictEqual(headers.get('cache-control'), 'max-age=3600, public');
169+
assert.strictEqual(headers.get('content-type'), 'text/css');
170+
});
171+
});
172+
173+
describe('generateInsight', () => {
174+
it('generateInsight - no cacheable requests', async () => {
175+
const {data, insights} = await processTrace(this, 'load-simple.json.gz');
176+
const insight =
177+
getInsightOrError('UseCache', insights, getFirstOrError(data.Meta.navigationsByNavigationId.values()));
178+
179+
const relatedEvents = insight.relatedEvents as Trace.Types.Events.SyntheticNetworkRequest[];
180+
assert.strictEqual(insight.insightKey, 'UseCache');
181+
assert.strictEqual(insight.state, 'pass');
182+
assert.deepEqual(insight.strings, UseCache.UIStrings);
183+
assert.strictEqual(insight.requests?.length, 0);
184+
assert.deepEqual(insight.totalWastedBytes, 0);
185+
assert.strictEqual(relatedEvents?.length, 0);
186+
});
187+
188+
it('generateInsight - cacheable requests', async () => {
189+
/**
190+
* Contains 4 network requests:
191+
* (1) page html request: not cacheable, Document resource type
192+
* (2) stylesheet Google Fonts css2: caching is disabled
193+
* (3) stylesheet app.css: ~ should recommend caching ~
194+
* (4) via.placeholder img jpg: maxAgeInHours 8766, has high enough cache probability
195+
* (5) via.placeholder img jpeg: maxAgeInHours 8766, has high enough cache probability
196+
*/
197+
const {data, insights} = await processTrace(this, 'lcp-images.json.gz');
198+
const insight =
199+
getInsightOrError('UseCache', insights, getFirstOrError(data.Meta.navigationsByNavigationId.values()));
200+
201+
const relatedEvents = insight.relatedEvents as Trace.Types.Events.SyntheticNetworkRequest[] ?? [];
202+
assert.strictEqual(insight.insightKey, 'UseCache');
203+
assert.strictEqual(insight.state, 'fail');
204+
assert.deepEqual(insight.strings, UseCache.UIStrings);
205+
206+
const gotCacheable = insight.requests;
207+
// Should have the 1 cacheable request.
208+
assert.deepEqual(gotCacheable?.length, 1);
209+
assert.deepEqual(
210+
gotCacheable[0].request.args.data.url,
211+
'https://chromedevtools.github.io/performance-stories/lcp-large-image/app.css');
212+
// 10min
213+
assert.deepEqual(gotCacheable[0].ttl, 600);
214+
// request's transfer size is 0.
215+
assert.deepEqual(gotCacheable[0].wastedBytes, 0);
216+
217+
assert.deepEqual(relatedEvents?.length, 1);
218+
assert.deepEqual(insight.totalWastedBytes, 0);
219+
});
220+
});
221+
});

0 commit comments

Comments
 (0)