-
Notifications
You must be signed in to change notification settings - Fork 9.3k
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
feat: add consistently interactive audit #2023
Changes from 5 commits
0a2fe77
3cce886
19f1aef
464df61
62e105d
842b76a
7dc9123
eb10cb9
a5d62b8
29c67f9
16b22a9
1feccc7
5f077ae
9505527
1f51140
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 Audit = require('./audit'); | ||
const TracingProcessor = require('../lib/traces/tracing-processor'); | ||
const Formatter = require('../report/formatter'); | ||
|
||
// Parameters (in ms) for log-normal CDF scoring. To see the curve: | ||
// https://www.desmos.com/calculator/uti67afozh | ||
const SCORING_POINT_OF_DIMINISHING_RETURNS = 1700; | ||
const SCORING_MEDIAN = 10000; | ||
const SCORING_TARGET = 5000; | ||
|
||
const REQUIRED_QUIET_WINDOW = 5000; | ||
const ALLOWED_CONCURRENT_REQUESTS = 2; | ||
|
||
const distribution = TracingProcessor.getLogNormalDistribution( | ||
SCORING_MEDIAN, | ||
SCORING_POINT_OF_DIMINISHING_RETURNS | ||
); | ||
|
||
/** | ||
* @fileoverview This audit identifies the time the page is "consistently interactive". | ||
* Looks for the first period of at least 5 seconds after FMP where both CPU and network were quiet, | ||
* and returns the timestamp of the beginning of the CPU quiet period. | ||
* @see https://docs.google.com/document/d/1GGiI9-7KeY3TPqS3YT271upUVimo-XiL5mwWorDUD4c/edit# | ||
*/ | ||
class ConsistentlyInteractiveMetric extends Audit { | ||
/** | ||
* @return {!AuditMeta} | ||
*/ | ||
static get meta() { | ||
return { | ||
category: 'Performance', | ||
name: 'consistently-interactive', | ||
description: 'Consistently Interactive (beta)', | ||
helpText: 'The point at which most network resources have finished loading and the ' + | ||
'CPU is idle for a prolonged period.', | ||
optimalValue: SCORING_TARGET.toLocaleString() + 'ms', | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. remove There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. done |
||
scoringMode: Audit.SCORING_MODES.NUMERIC, | ||
requiredArtifacts: ['traces', 'networkRecords'] | ||
}; | ||
} | ||
|
||
/** | ||
* @param {!Array} networkRecords | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. add a jsdoc method description? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. done |
||
* @param {{timestamps: {traceEnd: number}}} traceOfTab | ||
* @return {!Array<{start: number, end: number}>} | ||
*/ | ||
static _findNetworkQuietPeriods(networkRecords, traceOfTab) { | ||
const traceEnd = traceOfTab.timestamps.traceEnd; | ||
|
||
// First collect the timestamps of when requests start and end | ||
const timeBoundaries = []; | ||
networkRecords.forEach(record => { | ||
const scheme = record.parsedURL && record.parsedURL.scheme; | ||
if (scheme === 'data' || scheme === 'ws') { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. might be overkill, but should we convert this to a There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. sure, done |
||
return; | ||
} | ||
|
||
// convert the network record timestamp to ms to line-up with traceOfTab | ||
timeBoundaries.push({time: record.startTime * 1000, isStart: true}); | ||
timeBoundaries.push({time: record.endTime * 1000, isStart: false}); | ||
}); | ||
|
||
timeBoundaries.sort((a, b) => a.time - b.time); | ||
|
||
let numInflightRequests = 0; | ||
let quietPeriodStart = 0; | ||
const quietPeriods = []; | ||
timeBoundaries.forEach(boundary => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. hey a forEach! 😁 😍 |
||
if (boundary.isStart) { | ||
// we've just started a new request. are we exiting a quiet period? | ||
if (numInflightRequests === ALLOWED_CONCURRENT_REQUESTS) { | ||
quietPeriods.push({start: quietPeriodStart, end: boundary.time}); | ||
quietPeriodStart = Infinity; | ||
} | ||
numInflightRequests++; | ||
} else { | ||
numInflightRequests--; | ||
// we've just completed a request. are we entering a quiet period? | ||
if (numInflightRequests <= ALLOWED_CONCURRENT_REQUESTS) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. any reason not to use There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. probably not? I can't think of one, so done :) |
||
quietPeriodStart = Math.min(boundary.time, quietPeriodStart); | ||
} | ||
} | ||
}); | ||
|
||
// Check if the trace ended in a quiet period | ||
if (quietPeriodStart !== Infinity) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. would it be clearer to check There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yep sgtm, this func was mostly just the network throughput computation modified to line up with deep's implementation, but it could be clearer 👍 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. come to think of it, in this version the condition will never be true anyway...I'll have to look into how end times are handled There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yikes, we've actually got a problem with network recorder :/ it only saves records on finish There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. hmm, good catch. That was for expedience at the time, but that was a very long time ago and we could probably do with some more nuanced data. A quick fix would be to just look directly at the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. just fixed, adding the request on start instead and checking There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
this is the right solution, but nothing else that depends on network records is checking for this as they all could assume that all requests were finished when they were written. They're often just taking all of them and filtering on whatever they need to look for. We need to do a survey of all the uses, fix where it makes sense, add unit tests like you added in a5d62b8 for CI, etc There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think I've addressed all the usages here now, some places were already checking for it |
||
quietPeriods.push({start: quietPeriodStart, end: traceEnd}); | ||
} | ||
|
||
return quietPeriods; | ||
} | ||
|
||
/** | ||
* @param {!Array<{start: number, end: number}>} longTasks | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. jsdoc description There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. done |
||
* @param {{timestamps: {navigationStart: number, traceEnd: number}}} traceOfTab | ||
* @return {!Array<{start: number, end: number}>} | ||
*/ | ||
static _findCPUQuietPeriods(longTasks, traceOfTab) { | ||
const navStartTsInMs = traceOfTab.timestamps.navigationStart; | ||
const traceEndTsInMs = traceOfTab.timestamps.traceEnd; | ||
if (longTasks.length === 0) { | ||
return [{start: 0, end: traceEndTsInMs}]; | ||
} | ||
|
||
const quietPeriods = []; | ||
longTasks.forEach((task, index) => { | ||
if (index === 0) { | ||
quietPeriods.push({ | ||
start: 0, | ||
end: task.start + navStartTsInMs, | ||
}); | ||
} | ||
|
||
if (index === longTasks.length - 1) { | ||
quietPeriods.push({ | ||
start: task.end + navStartTsInMs, | ||
end: traceEndTsInMs, | ||
}); | ||
} else { | ||
quietPeriods.push({ | ||
start: task.end + navStartTsInMs, | ||
end: longTasks[index + 1].start + navStartTsInMs, | ||
}); | ||
} | ||
}); | ||
|
||
return quietPeriods; | ||
} | ||
|
||
/** | ||
* @param {!Array<{start: number, end: number}>} longTasks | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. jsdoc description :) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. done |
||
* @param {{timestamps: {navigationStart: number, firstMeaningfulPaint: number, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. done |
||
* traceEnd: number}}} traceOfTab | ||
* @return {!Object} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. would be great to have a type here :) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. done |
||
*/ | ||
static findOverlappingQuietPeriods(longTasks, networkRecords, traceOfTab) { | ||
const FMPTsInMs = traceOfTab.timestamps.firstMeaningfulPaint; | ||
|
||
const isLongEnoughQuietPeriod = period => | ||
period.end > FMPTsInMs + REQUIRED_QUIET_WINDOW && | ||
period.end - period.start >= REQUIRED_QUIET_WINDOW; | ||
const networkQuietPeriods = this._findNetworkQuietPeriods(networkRecords, traceOfTab) | ||
.filter(isLongEnoughQuietPeriod); | ||
const cpuQuietPeriods = this._findCPUQuietPeriods(longTasks, traceOfTab) | ||
.filter(isLongEnoughQuietPeriod); | ||
|
||
const cpuQueue = cpuQuietPeriods.slice(); | ||
const networkQueue = networkQuietPeriods.slice(); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why the slices? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
jk, they get passed back from There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. they're also returned in extended info |
||
|
||
// We will check for a CPU quiet period contained within a Network quiet period or vice-versa | ||
let cpuCandidate = cpuQueue.shift(); | ||
let networkCandidate = networkQueue.shift(); | ||
while (cpuCandidate && networkCandidate) { | ||
if (cpuCandidate.start >= networkCandidate.start) { | ||
// CPU starts later than network, it must be contained by network or we check the next network | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. maybe There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. done |
||
if (networkCandidate.end >= cpuCandidate.start + REQUIRED_QUIET_WINDOW) { | ||
return { | ||
cpuQuietPeriod: cpuCandidate, | ||
networkQuietPeriod: networkCandidate, | ||
cpuQuietPeriods, | ||
networkQuietPeriods, | ||
}; | ||
} else { | ||
networkCandidate = networkQueue.shift(); | ||
} | ||
} else { | ||
// Network starts later than CPU, it must be contained by CPU or we check the next CPU | ||
if (cpuCandidate.end >= networkCandidate.start + REQUIRED_QUIET_WINDOW) { | ||
return { | ||
cpuQuietPeriod: cpuCandidate, | ||
networkQuietPeriod: networkCandidate, | ||
cpuQuietPeriods, | ||
networkQuietPeriods, | ||
}; | ||
} else { | ||
cpuCandidate = cpuQueue.shift(); | ||
} | ||
} | ||
} | ||
|
||
const culprit = cpuCandidate ? 'Network' : 'CPU'; | ||
throw new Error(`${culprit} did not quiet for at least 5s before the end of the trace.`); | ||
} | ||
|
||
/** | ||
* @param {!Artifacts} artifacts | ||
* @return {!Promise<!AuditResult>} | ||
*/ | ||
static audit(artifacts) { | ||
const trace = artifacts.traces[Audit.DEFAULT_PASS]; | ||
const networkRecords = artifacts.networkRecords[Audit.DEFAULT_PASS]; | ||
const computedTraceArtifacts = [ | ||
artifacts.requestTracingModel(trace), | ||
artifacts.requestTraceOfTab(trace), | ||
]; | ||
|
||
return Promise.all(computedTraceArtifacts) | ||
.then(([traceModel, traceOfTab]) => { | ||
if (!traceOfTab.timestamps.firstMeaningfulPaint) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. should probably throw on lack of There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. done There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. (though in the spec I'm not actually sure this is true, it just seemed like the proper thing to do. I should raise it) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
wouldn't no FMP be a bug in Chrome/Lighthouse? Unless you mean something else There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I was talking about the fact that we're doing the max of DCL and the value. I wasn't sure that's in the spec or if I added that to be sure it doesn't happen before TTFI |
||
throw new Error('No firstMeaningfulPaint found in trace.'); | ||
} | ||
|
||
const longTasks = TracingProcessor.getMainThreadTopLevelEvents(traceModel, trace) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. pass in There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. though I guess There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. wouldn't really be able to remove anything since we still need to check that the period is 5s after FMP (even if long tasks are ignored before FMP) so it's just the saved long tasks we're iterating through, and IMO it makes the logic harder to parse that FMP has already been dealt with in CPU but we still have to deal with it in network, fine leaving? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
yeah, I guess it's just awkward one way or the other. SG |
||
.filter(event => event.end - event.start >= 50); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. done |
||
const quietPeriodInfo = this.findOverlappingQuietPeriods(longTasks, networkRecords, | ||
traceOfTab); | ||
const cpuQuietPeriod = quietPeriodInfo.cpuQuietPeriod; | ||
|
||
const timestamp = Math.max( | ||
cpuQuietPeriod.start, | ||
traceOfTab.timestamps.firstMeaningfulPaint, | ||
traceOfTab.timestamps.domContentLoaded | ||
); | ||
const timeInMs = timestamp - traceOfTab.timestamps.navigationStart; | ||
const extendedInfo = Object.assign(quietPeriodInfo, {timestamp, timeInMs}); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. shape of There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. added typedef to There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. that is...not a terribly useful type definition :P If I wanted to use that There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. added a
|
||
|
||
let score = 100 * distribution.computeComplementaryPercentile(timeInMs); | ||
// Clamp the score to 0 <= x <= 100. | ||
score = Math.min(100, score); | ||
score = Math.max(0, score); | ||
score = Math.round(score); | ||
|
||
const displayValue = Math.round(timeInMs / 10) * 10; | ||
return { | ||
score, | ||
rawValue: timeInMs, | ||
displayValue: `${displayValue.toLocaleString()}ms`, | ||
optimalValue: this.meta.optimalValue, | ||
extendedInfo: { | ||
value: extendedInfo, | ||
formatter: Formatter.SUPPORTED_FORMATS.NULL, | ||
} | ||
}; | ||
}); | ||
} | ||
} | ||
|
||
module.exports = ConsistentlyInteractiveMetric; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
remove
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
done