diff --git a/lighthouse-core/audits/byte-efficiency/uses-long-cache-ttl.js b/lighthouse-core/audits/byte-efficiency/uses-long-cache-ttl.js new file mode 100644 index 000000000000..c266a3d6b828 --- /dev/null +++ b/lighthouse-core/audits/byte-efficiency/uses-long-cache-ttl.js @@ -0,0 +1,247 @@ +/** + * @license Copyright 2017 Google Inc. 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. + */ +'use strict'; + +const assert = require('assert'); +const parseCacheControl = require('parse-cache-control'); +const ByteEfficiencyAudit = require('./byte-efficiency-audit'); +const formatDuration = require('../../report/v2/renderer/util.js').formatDuration; +const WebInspector = require('../../lib/web-inspector'); +const URL = require('../../lib/url-shim'); + +// Ignore assets that have very high likelihood of cache hit +const IGNORE_THRESHOLD_IN_PERCENT = 0.925; + +// Scoring curve: https://www.desmos.com/calculator/zokzso8umm +const SCORING_POINT_OF_DIMINISHING_RETURNS = 4; // 4 KB +const SCORING_MEDIAN = 768; // 768 KB + +class CacheHeaders extends ByteEfficiencyAudit { + /** + * @return {!AuditMeta} + */ + static get meta() { + return { + category: 'Caching', + name: 'uses-long-cache-ttl', + description: 'Uses efficient cache policy on static assets', + failureDescription: 'Uses inefficient cache policy on static assets', + helpText: + 'A long cache lifetime can speed up repeat visits to your page. ' + + '[Learn more](https://developers.google.com/web/fundamentals/performance/optimizing-content-efficiency/http-caching#cache-control).', + scoringMode: ByteEfficiencyAudit.SCORING_MODES.NUMERIC, + requiredArtifacts: ['devtoolsLogs'], + }; + } + + /** + * Interpolates the y value at a point x on the line defined by (x0, y0) and (x1, y1) + * @param {number} x0 + * @param {number} y0 + * @param {number} x1 + * @param {number} y1 + * @param {number} x + * @return {number} + */ + static linearInterpolation(x0, y0, x1, y1, x) { + const slope = (y1 - y0) / (x1 - x0); + return y0 + (x - x0) * slope; + } + + /** + * Computes the percent likelihood that a return visit will be within the cache lifetime, based on + * Chrome UMA stats see the note below. + * @param {number} maxAgeInSeconds + * @return {number} + */ + static getCacheHitProbability(maxAgeInSeconds) { + // This array contains the hand wavy distribution of the age of a resource in hours at the time of + // cache hit at 0th, 10th, 20th, 30th, etc percentiles. This is used to compute `wastedMs` since there + // are clearly diminishing returns to cache duration i.e. 6 months is not 2x better than 3 months. + // Based on UMA stats for HttpCache.StaleEntry.Validated.Age, see https://www.desmos.com/calculator/7v0qh1nzvh + // Example: a max-age of 12 hours already covers ~50% of cases, doubling to 24 hours covers ~10% more. + const RESOURCE_AGE_IN_HOURS_DECILES = [0, 0.2, 1, 3, 8, 12, 24, 48, 72, 168, 8760, Infinity]; + assert.ok(RESOURCE_AGE_IN_HOURS_DECILES.length === 12, 'deciles 0-10 and 1 for overflow'); + + const maxAgeInHours = maxAgeInSeconds / 3600; + const upperDecileIndex = RESOURCE_AGE_IN_HOURS_DECILES.findIndex( + decile => decile >= maxAgeInHours + ); + + // Clip the likelihood between 0 and 1 + if (upperDecileIndex === RESOURCE_AGE_IN_HOURS_DECILES.length - 1) return 1; + if (upperDecileIndex === 0) return 0; + + // Use the two closest decile points as control points + const upperDecileValue = RESOURCE_AGE_IN_HOURS_DECILES[upperDecileIndex]; + const lowerDecileValue = RESOURCE_AGE_IN_HOURS_DECILES[upperDecileIndex - 1]; + const upperDecile = upperDecileIndex / 10; + const lowerDecile = (upperDecileIndex - 1) / 10; + + // Approximate the real likelihood with linear interpolation + return CacheHeaders.linearInterpolation( + lowerDecileValue, + lowerDecile, + upperDecileValue, + upperDecile, + maxAgeInHours + ); + } + + /** + * Computes the user-specified cache lifetime, 0 if explicit no-cache policy is in effect, and null if not + * user-specified. See https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html + * + * @param {!Map} headers + * @param {!Object} cacheControl Follows the potential settings of cache-control, see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control + * @return {?number} + */ + static computeCacheLifetimeInSeconds(headers, cacheControl) { + if (cacheControl) { + // Cache-Control takes precendence over expires + if (cacheControl['no-cache'] || cacheControl['no-store']) return 0; + if (Number.isFinite(cacheControl['max-age'])) return Math.max(cacheControl['max-age'], 0); + } else if ((headers.get('pragma') || '').includes('no-cache')) { + // The HTTP/1.0 Pragma header can disable caching if cache-control is not set, see https://tools.ietf.org/html/rfc7234#section-5.4 + return 0; + } + + if (headers.has('expires')) { + const expires = new Date(headers.get('expires')).getTime(); + // Invalid expires values MUST be treated as already expired + if (!expires) return 0; + return Math.max(0, Math.ceil((expires - Date.now()) / 1000)); + } + + return null; + } + + /** + * Given a network record, returns whether we believe the asset is cacheable, i.e. it was a network + * request that satisifed the conditions: + * + * 1. Has a cacheable status code + * 2. Has a resource type that corresponds to static assets (image, script, stylesheet, etc). + * + * Allowing assets with a query string is debatable, PSI considered them non-cacheable with a similar + * caveat. + * + * TODO: Investigate impact in HTTPArchive, experiment with this policy to see what changes. + * + * @param {!WebInspector.NetworkRequest} record + * @return {boolean} + */ + static isCacheableAsset(record) { + const CACHEABLE_STATUS_CODES = new Set([200, 203, 206]); + + const STATIC_RESOURCE_TYPES = new Set([ + WebInspector.resourceTypes.Font, + WebInspector.resourceTypes.Image, + WebInspector.resourceTypes.Media, + WebInspector.resourceTypes.Script, + WebInspector.resourceTypes.Stylesheet, + ]); + + const resourceUrl = record._url; + return ( + CACHEABLE_STATUS_CODES.has(record.statusCode) && + STATIC_RESOURCE_TYPES.has(record._resourceType) && + !resourceUrl.includes('data:') + ); + } + + /** + * @param {!Artifacts} artifacts + * @return {!AuditResult} + */ + static audit(artifacts) { + const devtoolsLogs = artifacts.devtoolsLogs[ByteEfficiencyAudit.DEFAULT_PASS]; + return artifacts.requestNetworkRecords(devtoolsLogs).then(records => { + const results = []; + let queryStringCount = 0; + let totalWastedBytes = 0; + + for (const record of records) { + if (!CacheHeaders.isCacheableAsset(record)) continue; + + const headers = new Map(); + for (const header of record._responseHeaders) { + headers.set(header.name.toLowerCase(), header.value); + } + + const cacheControl = parseCacheControl(headers.get('cache-control')); + let cacheLifetimeInSeconds = CacheHeaders.computeCacheLifetimeInSeconds( + headers, + cacheControl + ); + + // Ignore assets with an explicit no-cache policy + if (cacheLifetimeInSeconds === 0) continue; + cacheLifetimeInSeconds = cacheLifetimeInSeconds || 0; + + const cacheHitProbability = CacheHeaders.getCacheHitProbability(cacheLifetimeInSeconds); + if (cacheHitProbability > IGNORE_THRESHOLD_IN_PERCENT) continue; + + const url = URL.elideDataURI(record._url); + const totalBytes = record._transferSize; + const totalKb = ByteEfficiencyAudit.bytesToKbString(totalBytes); + const wastedBytes = (1 - cacheHitProbability) * totalBytes; + const cacheLifetimeDisplay = formatDuration(cacheLifetimeInSeconds); + + totalWastedBytes += wastedBytes; + if (url.includes('?')) queryStringCount++; + + results.push({ + url, + cacheControl, + cacheLifetimeInSeconds, + cacheLifetimeDisplay, + cacheHitProbability, + totalKb, + totalBytes, + wastedBytes, + }); + } + + results.sort( + (a, b) => a.cacheLifetimeInSeconds - b.cacheLifetimeInSeconds || b.totalBytes - a.totalBytes + ); + + // Use the CDF of a log-normal distribution for scoring. + // <= 4KB: score≈100 + // 768KB: score=50 + // >= 4600KB: score≈5 + const score = ByteEfficiencyAudit.computeLogNormalScore( + totalWastedBytes / 1024, + SCORING_POINT_OF_DIMINISHING_RETURNS, + SCORING_MEDIAN + ); + + const headings = [ + {key: 'url', itemType: 'url', text: 'URL'}, + {key: 'cacheLifetimeDisplay', itemType: 'text', text: 'Cache TTL'}, + {key: 'totalKb', itemType: 'text', text: 'Size (KB)'}, + ]; + + const tableDetails = ByteEfficiencyAudit.makeTableDetails(headings, results); + + return { + score, + rawValue: totalWastedBytes, + displayValue: `${results.length} asset${results.length !== 1 ? 's' : ''} found`, + extendedInfo: { + value: { + results, + queryStringCount, + }, + }, + details: tableDetails, + }; + }); + } +} + +module.exports = CacheHeaders; diff --git a/lighthouse-core/config/default.js b/lighthouse-core/config/default.js index 191f38b94d8d..4677e4cece6c 100644 --- a/lighthouse-core/config/default.js +++ b/lighthouse-core/config/default.js @@ -130,6 +130,7 @@ module.exports = { 'accessibility/valid-lang', 'accessibility/video-caption', 'accessibility/video-description', + 'byte-efficiency/uses-long-cache-ttl', 'byte-efficiency/total-byte-weight', 'byte-efficiency/offscreen-images', 'byte-efficiency/uses-webp-images', @@ -249,6 +250,7 @@ module.exports = { {id: 'time-to-first-byte', weight: 0, group: 'perf-hint'}, {id: 'redirects', weight: 0, group: 'perf-hint'}, {id: 'total-byte-weight', weight: 0, group: 'perf-info'}, + {id: 'uses-long-cache-ttl', weight: 0, group: 'perf-info'}, {id: 'dom-size', weight: 0, group: 'perf-info'}, {id: 'critical-request-chains', weight: 0, group: 'perf-info'}, {id: 'user-timings', weight: 0, group: 'perf-info'}, diff --git a/lighthouse-core/report/v2/renderer/util.js b/lighthouse-core/report/v2/renderer/util.js index faf34a7a5038..9cc82822c203 100644 --- a/lighthouse-core/report/v2/renderer/util.js +++ b/lighthouse-core/report/v2/renderer/util.js @@ -83,6 +83,36 @@ class Util { } return formatter.format(new Date(date)); } + /** + * Converts a time in seconds into a duration string, i.e. `1d 2h 13m 52s` + * @param {number} timeInSeconds + * @param {string=} zeroLabel + * @return {string} + */ + static formatDuration(timeInSeconds, zeroLabel = 'None') { + if (timeInSeconds === 0) { + return zeroLabel; + } + + const parts = []; + const unitLabels = /** @type {!Object} */ ({ + d: 60 * 60 * 24, + h: 60 * 60, + m: 60, + s: 1, + }); + + Object.keys(unitLabels).forEach(label => { + const unit = unitLabels[label]; + const numberOfUnits = Math.floor(timeInSeconds / unit); + if (numberOfUnits > 0) { + timeInSeconds -= numberOfUnits * unit; + parts.push(`${numberOfUnits}\xa0${label}`); + } + }); + + return parts.join(' '); + } /** * @param {!URL} parsedUrl diff --git a/lighthouse-core/test/audits/byte-efficiency/uses-long-cache-ttl-test.js b/lighthouse-core/test/audits/byte-efficiency/uses-long-cache-ttl-test.js new file mode 100644 index 000000000000..65e3b92f3762 --- /dev/null +++ b/lighthouse-core/test/audits/byte-efficiency/uses-long-cache-ttl-test.js @@ -0,0 +1,181 @@ +/** + * @license Copyright 2017 Google Inc. 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. + */ +'use strict'; + +const CacheHeadersAudit = require('../../../audits/byte-efficiency/uses-long-cache-ttl.js'); +const assert = require('assert'); +const WebInspector = require('../../../lib/web-inspector'); + +/* eslint-env mocha */ + +function networkRecord(options = {}) { + const headers = []; + Object.keys(options.headers || {}).forEach(name => { + headers.push({name, value: options.headers[name]}); + }); + + return { + _url: options.url || 'https://example.com/asset', + statusCode: options.statusCode || 200, + _resourceType: options.resourceType || WebInspector.resourceTypes.Script, + _transferSize: options.transferSize || 10000, + _responseHeaders: headers, + }; +} + +describe('Cache headers audit', () => { + let artifacts; + let networkRecords; + + beforeEach(() => { + artifacts = { + devtoolsLogs: {}, + requestNetworkRecords: () => Promise.resolve(networkRecords), + requestNetworkThroughput: () => Promise.resolve(1000), + }; + }); + + describe('#linearInterpolation', () => { + it('correctly interpolates when slope is 2', () => { + const slopeOf2 = x => CacheHeadersAudit.linearInterpolation(0, 0, 10, 20, x); + assert.equal(slopeOf2(-10), -20); + assert.equal(slopeOf2(5), 10); + assert.equal(slopeOf2(10), 20); + }); + + it('correctly interpolates when slope is 0', () => { + const slopeOf0 = x => CacheHeadersAudit.linearInterpolation(0, 0, 10, 0, x); + assert.equal(slopeOf0(-10), 0); + assert.equal(slopeOf0(5), 0); + assert.equal(slopeOf0(10), 0); + }); + }); + + it('detects missing cache headers', () => { + networkRecords = [networkRecord()]; + return CacheHeadersAudit.audit(artifacts).then(result => { + const items = result.extendedInfo.value.results; + assert.equal(items.length, 1); + assert.equal(items[0].cacheLifetimeInSeconds, 0); + assert.equal(items[0].wastedBytes, 10000); + assert.equal(result.displayValue, '1 asset found'); + }); + }); + + it('detects low value max-age headers', () => { + networkRecords = [ + networkRecord({headers: {'cache-control': 'max-age=3600'}}), // an hour + networkRecord({headers: {'cache-control': 'max-age=3600'}, transferSize: 100000}), // an hour + networkRecord({headers: {'cache-control': 'max-age=86400'}}), // a day + networkRecord({headers: {'cache-control': 'max-age=31536000'}}), // a year + ]; + + return CacheHeadersAudit.audit(artifacts).then(result => { + const items = result.extendedInfo.value.results; + assert.equal(items.length, 3); + assert.equal(items[0].cacheLifetimeInSeconds, 3600); + assert.equal(items[0].cacheLifetimeDisplay, '1\xa0h'); + assert.equal(items[0].cacheHitProbability, .2); + assert.equal(Math.round(items[0].wastedBytes), 80000); + assert.equal(items[1].cacheLifetimeInSeconds, 3600); + assert.equal(Math.round(items[1].wastedBytes), 8000); + assert.equal(items[2].cacheLifetimeDisplay, '1\xa0d'); + assert.equal(Math.round(items[2].wastedBytes), 4000); + assert.equal(result.displayValue, '3 assets found'); + }); + }); + + it('detects low value expires headers', () => { + const expiresIn = seconds => new Date(Date.now() + seconds * 1000).toGMTString(); + const closeEnough = (actual, exp) => assert.ok(Math.abs(actual - exp) <= 1, 'invalid expires'); + + networkRecords = [ + networkRecord({headers: {expires: expiresIn(86400 * 365)}}), // a year + networkRecord({headers: {expires: expiresIn(86400 * 90)}}), // 3 months + networkRecord({headers: {expires: expiresIn(86400)}}), // a day + networkRecord({headers: {expires: expiresIn(3600)}}), // an hour + ]; + + return CacheHeadersAudit.audit(artifacts).then(result => { + const items = result.extendedInfo.value.results; + assert.equal(items.length, 3); + closeEnough(items[0].cacheLifetimeInSeconds, 3600); + assert.equal(Math.round(items[0].wastedBytes), 8000); + closeEnough(items[1].cacheLifetimeInSeconds, 86400); + assert.equal(Math.round(items[1].wastedBytes), 4000); + closeEnough(items[2].cacheLifetimeInSeconds, 86400 * 90); + assert.equal(Math.round(items[2].wastedBytes), 768); + }); + }); + + it('respects expires/cache-control priority', () => { + const expiresIn = seconds => new Date(Date.now() + seconds * 1000).toGMTString(); + + networkRecords = [ + networkRecord({headers: { + 'cache-control': 'must-revalidate,max-age=3600', + 'expires': expiresIn(86400), + }}), + networkRecord({headers: { + 'cache-control': 'private,must-revalidate', + 'expires': expiresIn(86400), + }}), + ]; + + return CacheHeadersAudit.audit(artifacts).then(result => { + const items = result.extendedInfo.value.results; + assert.equal(items.length, 2); + assert.ok(Math.abs(items[0].cacheLifetimeInSeconds - 3600) <= 1, 'invalid expires parsing'); + assert.equal(Math.round(items[0].wastedBytes), 8000); + assert.ok(Math.abs(items[1].cacheLifetimeInSeconds - 86400) <= 1, 'invalid expires parsing'); + assert.equal(Math.round(items[1].wastedBytes), 4000); + }); + }); + + it('catches records with Etags', () => { + networkRecords = [ + networkRecord({headers: {etag: 'md5hashhere'}}), + networkRecord({headers: {'etag': 'md5hashhere', 'cache-control': 'max-age=60'}}), + ]; + + return CacheHeadersAudit.audit(artifacts).then(result => { + const items = result.extendedInfo.value.results; + assert.equal(items.length, 2); + }); + }); + + it('ignores explicit no-cache policies', () => { + networkRecords = [ + networkRecord({headers: {expires: '-1'}}), + networkRecord({headers: {'cache-control': 'no-store'}}), + networkRecord({headers: {'cache-control': 'no-cache'}}), + networkRecord({headers: {'cache-control': 'max-age=0'}}), + networkRecord({headers: {pragma: 'no-cache'}}), + ]; + + return CacheHeadersAudit.audit(artifacts).then(result => { + const items = result.extendedInfo.value.results; + assert.equal(result.score, 100); + assert.equal(items.length, 0); + }); + }); + + it('ignores potentially uncacheable records', () => { + networkRecords = [ + networkRecord({statusCode: 500}), + networkRecord({url: 'https://example.com/dynamic.js?userId=crazy'}), + networkRecord({url: 'data:image/jpeg;base64,what'}), + networkRecord({resourceType: WebInspector.resourceTypes.XHR}), + ]; + + return CacheHeadersAudit.audit(artifacts).then(result => { + assert.equal(result.score, 100); + const items = result.extendedInfo.value.results; + assert.equal(items.length, 1); + assert.equal(result.extendedInfo.value.queryStringCount, 1); + }); + }); +}); diff --git a/package.json b/package.json index 26d0ab291825..156e6f1735cf 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,7 @@ "metaviewport-parser": "0.1.0", "mkdirp": "0.5.1", "opn": "4.0.2", + "parse-cache-control": "1.0.1", "raven": "^2.2.1", "rimraf": "^2.6.1", "semver": "^5.3.0", diff --git a/yarn.lock b/yarn.lock index cf89b1cc9385..7c0c1412b8ec 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3094,6 +3094,10 @@ pad-right@^0.2.2: dependencies: repeat-string "^1.5.2" +parse-cache-control@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/parse-cache-control/-/parse-cache-control-1.0.1.tgz#8eeab3e54fa56920fe16ba38f77fa21aacc2d74e" + parse-filepath@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/parse-filepath/-/parse-filepath-1.0.1.tgz#159d6155d43904d16c10ef698911da1e91969b73"