Skip to content
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

Finish adding getINP() #221

Merged
merged 2 commits into from
Apr 25, 2022
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
183 changes: 96 additions & 87 deletions src/getINP.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2020 Google LLC
* Copyright 2022 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -14,118 +14,127 @@
* limitations under the License.
*/

import {onBFCacheRestore} from './lib/bfcache.js';
import {bindReporter} from './lib/bindReporter.js';
import {initMetric} from './lib/initMetric.js';
import {observe, PerformanceEntryHandler} from './lib/observe.js';
import {onBFCacheRestore} from './lib/onBFCacheRestore.js';
import {observe} from './lib/observe.js';
import {onHidden} from './lib/onHidden.js';
import {PerformanceEventTiming, ReportHandler} from './types.js';
import {getInteractionCount, initInteractionCountPolyfill} from './lib/polyfills/interactionCountPolyfill.js';
import {Metric, PerformanceEventTiming, ReportHandler} from './types.js';

/*
* In order to compute a High Percentile (p98-p100) Interaction for INP,
* we need to store a list of the worst interactions measured.
*
* EVERY_N is the number of interactions before moving to the next-highest (i.e. p98)
* NUM_ENTRIES_TO_STORE is the max size of the list of entries
*
* EVERY_N * NUM_ENTRIES_TO_STORE becomes, effectively, the max number of interactions
* per page load for which getINP() works well. Adjust as needed.
*/
const EVERY_N = 50;
const NUM_ENTRIES_TO_STORE = 10;
const largestINPEntries: PerformanceEventTiming[] = [];
let minKnownInteractionId = Number.POSITIVE_INFINITY;
let maxKnownInteractionId = 0;

function updateInteractionIds(interactionId: number): void {
minKnownInteractionId = Math.min(minKnownInteractionId, interactionId);
maxKnownInteractionId = Math.max(maxKnownInteractionId, interactionId);
interface Interaction {
id: number;
latency: number;
entries: PerformanceEventTiming[];
}

function estimateInteractionCount(): number {
return (maxKnownInteractionId > 0) ? ((maxKnownInteractionId - minKnownInteractionId) / 7) + 1 : 0;
}
// Used to store the interaction count after a bfcache restore, since p98
// interaction latencies should only consider the current navigation.
let prevInteractionCount = 0;
philipwalton marked this conversation as resolved.
Show resolved Hide resolved

// To prevent unnecessary memory usage on pages with lots of interactions,
// store at most 10 of the longest interactions to consider as INP candidates.
const MAX_INTERACTIONS_TO_CONSIDER = 10;

// A list of longest interactions on the page (by latency) sorted so the
// longest one is first. The list is as most MAX_INTERACTIONS_TO_CONSIDER long.
let longestInteractions: Interaction[] = [];

/**
* Takes a performance entry and adds it to the list of worst interactions
* if its duration is long enough to make it among the worst. If the
* entry is part of an existing interaction, it is merged and the latency
* and entries list is updated as needed.
*/
const processEntry = (entry: PerformanceEventTiming) => {
// Only process the entry if it's possibly one of the ten longest.
if (longestInteractions.length < MAX_INTERACTIONS_TO_CONSIDER ||
entry.duration > longestInteractions[longestInteractions.length - 1].latency) {
philipwalton marked this conversation as resolved.
Show resolved Hide resolved

const existingInteractionIndex =
longestInteractions.findIndex((i) => entry.interactionId == i.id);

function addInteractionEntryToINPList(entry: PerformanceEventTiming): void {
// Optional: Skip this entry early if we know it won't be needed.
if (largestINPEntries.length >= NUM_ENTRIES_TO_STORE && entry.duration < largestINPEntries[largestINPEntries.length-1].duration) {
return;
}

// If we already have an interaction with this same ID, merge with it.
const existing = largestINPEntries.findIndex((other) => entry.interactionId == other.interactionId);
if (existing >= 0) {
// Only replace if this one is actually longer
if (entry.duration > largestINPEntries[existing].duration) {
largestINPEntries[existing] = entry;
// If the interaction already exists, update it. Otherwise create one.
if (existingInteractionIndex >= 0) {
const interaction = longestInteractions[existingInteractionIndex];
interaction.latency = Math.max(interaction.latency, entry.duration);
interaction.entries.push(entry);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will these be sorted by duration ever?

I think we have sample code that uses .entries[0] and performance.measure, as a simple way to visualize the latency on timeline, for example.

Copy link
Member Author

@philipwalton philipwalton Apr 25, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That was an open question I had (what to sort them by). I think (?) we want to keep them in the order they were dispatched and leave any sorting up to the debug code, but let me know if you disagree and think it's important to have the largest duration first.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alright, fair enough.

While I think making the first entry the largest would be reasonable given the current implementation -- my ultimate preference is to report all entries that present the same frame such that we can create a summary of where time was spent overall. (Or even just report the summary as part of the Metric return value).

So leaving it up to debug code sgtm.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I very much agree with that ultimate goal. Let's keep it simple for now and re-evaluate once we have more options there or when we plan to release this as stable.

} else {
longestInteractions.push({
id: entry.interactionId!,
latency: entry.duration,
entries: [entry],
});
}
} else {
largestINPEntries.push(entry);
}

largestINPEntries.sort((a,b) => b.duration - a.duration);
largestINPEntries.splice(NUM_ENTRIES_TO_STORE);
// Sort the entries by latency (descending) and keep only the top ten.
longestInteractions.sort((a, b) => b.latency - a.latency);
longestInteractions.splice(MAX_INTERACTIONS_TO_CONSIDER);
}
}

function getCurrentINPEntry(): PerformanceEventTiming {
const interactionCount = estimateInteractionCount();
const which = Math.min(largestINPEntries.length-1, Math.floor(interactionCount / EVERY_N));
return largestINPEntries[which];
/**
* Returns the estimated p98 longest interaction based on the stored
* interaction candidates and the interaction count for the current page.
*/
const estimateP98LongestInteraction = () => {
const candidateInteractionIndex = Math.min(longestInteractions.length - 1,
Math.floor((getInteractionCount() - prevInteractionCount) / 50));

return longestInteractions[candidateInteractionIndex];
}

export const getINP = (onReport: ReportHandler, reportAllChanges?: boolean) => {
// TODO(philipwalton): remove once the polyfill is no longer needed.
initInteractionCountPolyfill();

let metric = initMetric('INP');
let report: ReturnType<typeof bindReporter>;

const entryHandler = (entry: PerformanceEventTiming) => {
// TODO: Perhaps ignore values before FCP
if (!entry.interactionId) return;

updateInteractionIds(entry.interactionId);
addInteractionEntryToINPList(entry);

const inpEntry = getCurrentINPEntry();

// Only report when the IMP value changes. However:
// * When we cross a %-ile boundary, pushing `which` up, or
// * A new long value is added to the top, moving the current INP entry down
// ...then the inpEntry will change, but `duration` value of the new entry may still be the same.
// While technically the INP metric.value doesn't change, we still report since metric.entries changes.
//
// Potentially, we may even want to compare the whole metric.entries range for equality, because:
// * We can have cases where a middle value updates due to new-longest value with same interactionId.
// * When we are already at MAX_ENTRIES and `which` stops changing, but the current smallest can get popped off.
const which = largestINPEntries.indexOf(inpEntry);
if (which >= metric.entries.length || metric.value != inpEntry.duration) {
metric.value = inpEntry.duration;
// We attach all the longest responsiveness entries, not just the HighP value.
// While technically the INP score is exactly the entry.duration of one specific HighP-ile entry...
// the entry would not have been picked (and IMP would be lower) if *any* of the worst entries were not so high.
// Improving any of them will improve score.
metric.entries.length = 0;
metric.entries.push(...largestINPEntries.slice(0, which + 1));
}
const handleEntries = (entries: Metric['entries']) => {
(entries as PerformanceEventTiming[]).forEach((entry) => {
if (entry.interactionId) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI, we could potentially polyfill for Firefox by assigning an interactionId for the entry here, for events of specifics types. This would include more events than we want, ideally, but could still be useful.

interactionCount estimate would be off, so we could just always return 1.

I'm not sure how much error that approach would have, and how that compares to historical attempts to polyfill features.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can experiment.

I did experience some with your earlier polyfill using eventCounts, and that didn't work in Firefox because (IIRC) it doesn't seem to emit pointercancel events. I could try this and see how well it works.

processEntry(entry);
}
});

// Perhaps Event Timing is the first API that can have multiple entries in a single PO callback
// That means that we would ideally report() only after the whole list of entries is processed, not one per entry.
// If we were lucky, the entries would be in timestamp order so the first is the longest... but I've found they
// are ordered in other ways... by type, I think?
// Alternatively: sort entries in the observe() wrapper.
report();
const inp = estimateP98LongestInteraction();

if (inp && inp.latency !== metric.value) {
metric.value = inp.latency;
metric.entries = inp.entries;
report();
}
};

const po = observe('event', entryHandler as PerformanceEntryHandler);
const po = observe('event', handleEntries, {
// The use of 50 here as a balance between not wanting the callback to
// run for too many already fast events (one frame or less), but also
philipwalton marked this conversation as resolved.
Show resolved Hide resolved
// get enough fidelity below the recommended "good" threshold of 200.
durationThreshold: 50,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Event Timing is rounded to the nearest 8ms. That makes the duration a nice round number (either 48ms or 56ms).
That means that this 50ms durationThreshold is effectively a 56ms duration threshold, which we may want to be explicit about.

TL;DR I think this is a good threshold. But here are some nuances:

  • At 60hz, 56ms is 3.5 frames, which means it can effectively get presented at the same time as other entries with 64ms of latency.
  • Conversely, if you were to change to 48ms durationThreshold, you really should probably use 40ms to include entries that take 2.5 frames @ 60hz.
  • If you wanted to group entries by frame (i.e. by presentation time) you would need to take startTime + duration and then group entries which are within 8ms windows. A single shared presentation time can include entries with a wide range of durations. An entry with less than durationThreshold duration can have large processing time. (This is one reason why we may want a dedicated api)

Copy link
Member Author

@philipwalton philipwalton Apr 25, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe 40 makes more sense then? I think the spirit of that comment is actually what I'm trying to report on. I.e. missing a frame deadline is probably inevitable for most pages, but once you're missing more than 2 or more frames it's probably worth knowing about.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

} as PerformanceObserverInit);

report = bindReporter(onReport, metric, reportAllChanges);

if (po) {
onHidden(() => {
po.takeRecords().map(entryHandler as PerformanceEntryHandler);
handleEntries(po.takeRecords());

// If the interaction count shows that there were interactions but
// none were captured by the PerformanceObserver, report a latency of 0.
philipwalton marked this conversation as resolved.
Show resolved Hide resolved
if (metric.value < 0 &&
getInteractionCount() - prevInteractionCount > 0) {
philipwalton marked this conversation as resolved.
Show resolved Hide resolved
metric.value = 0;
metric.entries = [];
}

report(true);
}, true);

// TODO: Test this
});

onBFCacheRestore(() => {
largestINPEntries.length = 0;
longestInteractions = [];
prevInteractionCount = getInteractionCount();

metric = initMetric('INP');
report = bindReporter(onReport, metric, reportAllChanges);
});
Expand Down
64 changes: 64 additions & 0 deletions src/lib/polyfills/interactionCountPolyfill.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/*
* Copyright 2022 Google LLC
*
* 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
*
* https://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.
*/

import {observe} from '../observe.js';
import {Metric, PerformanceEventTiming} from '../../types.js';


declare global {
interface Performance {
interactionCount: number;
}
}

let interactionCountEstimate = 0;
let minKnownInteractionId = Infinity;
let maxKnownInteractionId = 0;

const updateEstimate = (entries: Metric['entries']) => {
(entries as PerformanceEventTiming[]).forEach((e) => {
if (e.interactionId) {
minKnownInteractionId = Math.min(minKnownInteractionId, e.interactionId);
maxKnownInteractionId = Math.max(maxKnownInteractionId, e.interactionId);

interactionCountEstimate = maxKnownInteractionId ?
(maxKnownInteractionId - minKnownInteractionId) / 7 + 1 : 0;
}
});
}

let po: PerformanceObserver | undefined;

/**
* Returns the `interactionCount` value using the native API (if available)
* or the polyfill estimate in this module.
*/
export const getInteractionCount = () => {
return po ? interactionCountEstimate : performance.interactionCount || 0;
philipwalton marked this conversation as resolved.
Show resolved Hide resolved
}

/**
* Feature detects native support or initializes the polyfill if needed.
*/
export const initInteractionCountPolyfill = () => {
if ('interactionCount' in performance || po) return;

po = observe('event', updateEstimate, {
type: 'event',
buffered: true,
durationThreshold: 0,
} as PerformanceObserverInit);
};
4 changes: 4 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ export interface LayoutShift extends PerformanceEntry {
hadRecentInput: boolean;
}

export interface PerformanceObserverInit {
durationThreshold?: number;
}

export type FirstInputPolyfillEntry =
Omit<PerformanceEventTiming, 'processingEnd'>

Expand Down
Loading