Permalink
Browse files

This is a 0.1 definition of Time to Interactive (TTI) which considers

the main thread availability to help identify a better "page is loaded
and ready to be used" timestamp.

Requirements:
* first meaningful paint has hit
* we're 85% visually complete
* domContentLoaded has fired
* The main thread is available enough to handle user (first 500ms window
where Est Input Latency is <50ms at the 90% percentile.)

Currently, scoring of tti to a 0-100 score hasn't been validated. Also
very 0.1.

pr ticket: 450
fixes: #27

Many thanks to Brendan Kenny. Cheers to Addy Osmani as well.
No tweeting though, guys. ;)
  • Loading branch information...
paulirish committed Jul 22, 2016
1 parent 3b3e3de commit d0d38292d4fa0ee6b552b750dc8130b85563ac3b
@@ -19,6 +19,7 @@

const Audit = require('./audit');
const TracingProcessor = require('../lib/traces/tracing-processor');
const Formatter = require('../formatters/formatter');

const FAILURE_MESSAGE = 'Navigation and first paint timings not found.';

@@ -84,7 +85,10 @@ class FirstMeaningfulPaint extends Audit {
displayValue: `${result.duration}ms`,
debugString: result.debugString,
optimalValue: this.meta.optimalValue,
extendedInfo: result.extendedInfo
extendedInfo: {
value: result.extendedInfo,
formatter: Formatter.SUPPORTED_FORMATS.NULL
}
}));
}).catch(err => {
// Recover from trace parsing failures.
@@ -127,6 +131,7 @@ class FirstMeaningfulPaint extends Audit {
score = Math.min(100, score);
score = Math.max(0, score);

timings.navStart = data.navStart.ts / 1000;
return {
duration: `${firstMeaningfulPaint.toFixed(1)}`,
score: Math.round(score),
@@ -0,0 +1,172 @@
/**
* @license
* Copyright 2016 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 FMPMetric = require('./first-meaningful-paint');
const Formatter = require('../formatters/formatter');

// Parameters (in ms) for log-normal CDF scoring. To see the curve:
// https://www.desmos.com/calculator/jlrx14q4w8
const SCORING_POINT_OF_DIMINISHING_RETURNS = 1700;
const SCORING_MEDIAN = 5000;

class TTIMetric extends Audit {
/**
* @return {!AuditMeta}
*/
static get meta() {
return {
category: 'Performance',
name: 'time-to-interactive',
description: 'Time To Interactive (alpha)',
optimalValue: SCORING_POINT_OF_DIMINISHING_RETURNS.toLocaleString(),
requiredArtifacts: ['traceContents', 'speedline']
};
}

/**
* Identify the time the page is "interactive"
* @see https://docs.google.com/document/d/1oiy0_ych1v2ADhyG_QW7Ps4BNER2ShlJjx2zCbVzVyY/edit#
*
* The user thinks the page is ready - (They believe the page is done enough to start interacting with)
* - Layout has stabilized & key webfonts are visible.
* AKA: First meaningful paint has fired.
* - Page is nearly visually complete
* Visual completion is 85%
*
* The page is actually ready for user:
* - domContentLoadedEventEnd has fired
* Definition: HTML parsing has finished, all DOMContentLoaded handlers have run.
* No risk of DCL event handlers changing the page
* No surprises of inactive buttons/actions as DOM element event handlers should be bound
* - The main thread is available enough to handle user input
* first 500ms window where Est Input Latency is <50ms at the 90% percentile.
*
* WARNING: This metric WILL change its calculation. If you rely on its numbers now, know that they
* will be changing in the future to a more accurate number.
*
* @param {!Artifacts} artifacts The artifacts from the gather phase.
* @return {!AuditResult} The score from the audit, ranging from 0-100.
*/
static audit(artifacts) {
// We start looking at Math.Max(FMPMetric, visProgress[0.85])
return FMPMetric.audit(artifacts).then(fmpResult => {
if (fmpResult.rawValue === -1) {
return generateError(fmpResult.debugString);
}
const fmpTiming = parseFloat(fmpResult.rawValue);
const timings = fmpResult.extendedInfo && fmpResult.extendedInfo.value &&
fmpResult.extendedInfo.value.timings;

// Process the trace
const tracingProcessor = new TracingProcessor();
const model = tracingProcessor.init(artifacts.traceContents);
const endOfTraceTime = model.bounds.max;

// TODO: Wait for DOMContentLoadedEndEvent
// TODO: Wait for UA loading indicator to be done

// TODO CHECK these units are the same
const fMPts = timings.fMPfull + timings.navStart;

// look at speedline results for 85% starting at FMP
let eightyFivePctVC = artifacts.Speedline.frames.find(frame => {
return frame.getTimeStamp() >= fMPts && frame.getProgress() >= 85;
});

// Check to avoid closure compiler null dereferencing errors
if (eightyFivePctVC === undefined) {
eightyFivePctVC = 0;
}

// TODO CHECK these units are the same
const visuallyReadyTiming = (eightyFivePctVC.getTimeStamp() - timings.navStart) || 0;

// Find first 500ms window where Est Input Latency is <50ms at the 90% percentile.
let startTime = Math.max(fmpTiming, visuallyReadyTiming) - 50;
let endTime;
let currentLatency = Infinity;
const percentiles = [0.9]; // [0.75, 0.9, 0.99, 1];
const threshold = 50;
let foundLatencies = [];

// When we've found a latency that's good enough, we're good.
while (currentLatency > threshold) {
// While latency is too high, increment just 50ms and look again.
startTime += 50;
endTime = startTime + 500;
// If there's no more room in the trace to look, we're done.
if (endTime > endOfTraceTime) {
return generateError('Entire trace was found to be busy.');
}
// Get our expected latency for the time window
const latencies = TracingProcessor.getRiskToResponsiveness(
model, artifacts.traceContents, startTime, endTime, percentiles);
const estLatency = latencies[0].time.toFixed(2);
foundLatencies.push({
estLatency: estLatency,
startTime: startTime.toFixed(1)
});

// Grab this latency and try the threshold again
currentLatency = estLatency;
}
const timeToInteractive = startTime.toFixed(1);

// Use the CDF of a log-normal distribution for scoring.
// < 1200ms: score≈100
// 5000ms: score=50
// >= 15000ms: score≈0
const distribution = TracingProcessor.getLogNormalDistribution(SCORING_MEDIAN,
SCORING_POINT_OF_DIMINISHING_RETURNS);
let score = 100 * distribution.computeComplementaryPercentile(timeToInteractive);

// Clamp the score to 0 <= x <= 100.
score = Math.min(100, score);
score = Math.max(0, score);
score = Math.round(score);

const extendedInfo = {
timings: {
fMP: fmpTiming.toFixed(1),
visuallyReady: visuallyReadyTiming.toFixed(1),
mainThreadAvail: startTime.toFixed(1)
},
expectedLatencyAtTTI: currentLatency,
foundLatencies
};

return TTIMetric.generateAuditResult({
score,
rawValue: timeToInteractive,
displayValue: `${timeToInteractive}ms`,
optimalValue: this.meta.optimalValue,
extendedInfo: {
value: extendedInfo,
formatter: Formatter.SUPPORTED_FORMATS.NULL
}
});
}).catch(err => {
return generateError(err);
});
}
}

module.exports = TTIMetric;

function generateError(err) {
return TTIMetric.generateAuditResult({
value: -1,
rawValue: -1,
optimalValue: TTIMetric.meta.optimalValue,
debugString: err
});
}
@@ -44,7 +44,6 @@ AuditResultInput.prototype.optimalValue;
/** @type {(AuditExtendedInfo|undefined|null)} */
AuditResultInput.prototype.extendedInfo;


/**
* @struct
* @record
@@ -54,7 +53,7 @@ function AuditExtendedInfo() {}
/** @type {string} */
AuditExtendedInfo.prototype.formatter;

/** @type {Object|Array<UserTimingsExtendedInfo>|undefined} */
/** @type {(Object|Array<UserTimingsExtendedInfo>|FirstMeaningfulPaintExtendedInfo|undefined)} */
AuditExtendedInfo.prototype.value;

/**
@@ -81,6 +80,38 @@ UserTimingsExtendedInfo.prototype.endTime;
/** @type {(number|undefined)} */
UserTimingsExtendedInfo.prototype.duration;

/**
* @struct
* @record
*/
function FirstMeaningfulPaintExtendedInfo() {}

/** @type {!FirstMeaningfulPaintTimings} */
FirstMeaningfulPaintExtendedInfo.prototype.timings;

/**
* @struct
* @record
*/
function FirstMeaningfulPaintTimings() {}

/** @type {number} */
FirstMeaningfulPaintTimings.prototype.fCP;

/** @type {number} */
FirstMeaningfulPaintTimings.prototype.fMPbasic;

/** @type {number} */
FirstMeaningfulPaintTimings.prototype.fMPpageheight;

/** @type {number} */
FirstMeaningfulPaintTimings.prototype.fMPwebfont;

/** @type {number} */
FirstMeaningfulPaintTimings.prototype.fMPfull;

/** @type {number} */
FirstMeaningfulPaintTimings.prototype.navStart;

/**
* @struct
@@ -45,6 +45,7 @@
"first-meaningful-paint",
"speed-index-metric",
"estimated-input-latency",
"time-to-interactive",
"user-timings",
"screenshots",
"critical-request-chains",
@@ -106,6 +107,10 @@
"rawValue": 100,
"weight": 1
},
"time-to-interactive": {
"value": 100,
"weight": 1
},
"scrolling-60fps": {
"rawValue": true,
"weight": 0,
@@ -225,6 +225,7 @@ class TraceProcessor {
let clippedLength = 0;
mainThread.sliceGroup.topLevelSlices.forEach(slice => {
// Discard slices outside range.

if (slice.end <= startTime || slice.start >= endTime) {
return;
}
@@ -51,11 +51,11 @@ describe('Performance: first-meaningful-paint audit', () => {
});

it('finds the correct fCP + fMP timings', () => {
assert.equal(fmpResult.extendedInfo.timings.fCP, 461.901);
assert.equal(fmpResult.extendedInfo.timings.fMPbasic, 461.342);
assert.equal(fmpResult.extendedInfo.timings.fMPpageheight, 461.342);
assert.equal(fmpResult.extendedInfo.timings.fMPwebfont, 1099.523);
assert.equal(fmpResult.extendedInfo.timings.fMPfull, 1099.523);
assert.equal(fmpResult.extendedInfo.value.timings.fCP, 461.901);
assert.equal(fmpResult.extendedInfo.value.timings.fMPbasic, 461.342);
assert.equal(fmpResult.extendedInfo.value.timings.fMPpageheight, 461.342);
assert.equal(fmpResult.extendedInfo.value.timings.fMPwebfont, 1099.523);
assert.equal(fmpResult.extendedInfo.value.timings.fMPfull, 1099.523);
});

it('scores the fMP correctly', () => {
@@ -0,0 +1,54 @@
/**
* Copyright 2016 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('../../audits/time-to-interactive.js');
const SpeedlineGather = require('../../driver/gatherers/speedline');
const assert = require('assert');

const traceContents = require('../fixtures/traces/progressive-app.json');
const speedlineGather = new SpeedlineGather();

/* eslint-env mocha */
describe('Performance: time-to-interactive audit', () => {
it('scores a -1 with invalid trace data', () => {
return Audit.audit({
traceContents: '[{"pid": 15256,"tid": 1295,"t',
Speedline: {
first: 500
}
}).then(output => {
assert.equal(output.rawValue, -1);
assert(output.debugString);
});
});

it('evaluates valid input correctly', () => {
let artifacts = {
traceContents: traceContents
};
return speedlineGather.afterPass({}, artifacts).then(_ => {
artifacts.Speedline = speedlineGather.artifact;
return Audit.audit(artifacts).then(output => {
assert.equal(output.rawValue, '1105.8');
assert.equal(output.extendedInfo.value.expectedLatencyAtTTI, '20.72');
assert.equal(output.extendedInfo.value.timings.fMP, '1099.5');
assert.equal(output.extendedInfo.value.timings.mainThreadAvail, '1105.8');
assert.equal(output.extendedInfo.value.timings.visuallyReady, '1105.8');
});
});
});
});

0 comments on commit d0d3829

Please sign in to comment.