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

Add INP breakdown timings and LoAF attribution #442

Merged
merged 25 commits into from
Mar 29, 2024
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
5e61149
Remove missed polyfill code
philipwalton Mar 10, 2024
84abacf
Remove unused rollup plugin
philipwalton Mar 11, 2024
98d9090
Add frame-based INP and LoAF attribution
philipwalton Mar 9, 2024
c68c21b
Add additional code comments
philipwalton Mar 11, 2024
078c577
Merge branch 'v4' into loaf-inp
tunetheweb Mar 11, 2024
5d6a09b
Update src/types.ts
philipwalton Mar 11, 2024
be0b1d3
Update src/onINP.ts
philipwalton Mar 11, 2024
8f3b241
Address review feedback
philipwalton Mar 18, 2024
a41d463
Update README to match latest JSDocs
philipwalton Mar 18, 2024
32a47e1
Apply suggestions from code review
philipwalton Mar 19, 2024
4847ecd
Address review feedback
philipwalton Mar 19, 2024
06abad2
Add fallback for requestIdleCallback
philipwalton Mar 19, 2024
9034cd4
Add missing null check
philipwalton Mar 20, 2024
82a9b6e
Rename processingTime to processingDuration
philipwalton Mar 20, 2024
9271d29
Update tests to reduce flakiness
philipwalton Mar 22, 2024
012d5fa
Address review feedback
philipwalton Mar 22, 2024
8fd1ebb
Update type definitions and descriptions
philipwalton Mar 26, 2024
3d3709a
Update src/onINP.ts
philipwalton Mar 26, 2024
7a5515d
Revert interactionType to be pointer or keyboard
philipwalton Mar 27, 2024
ecf6960
Increase the past renderTimes frame length
philipwalton Mar 29, 2024
b59a791
Format code
philipwalton Mar 29, 2024
fa100e7
Update README.md
philipwalton Mar 29, 2024
dd644da
Update README
philipwalton Mar 29, 2024
c258ca4
Add test for LoAF entries
philipwalton Mar 29, 2024
157d1a8
Fix failing test
philipwalton Mar 29, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
89 changes: 72 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@

## Overview

The `web-vitals` library is a tiny (~1.5K, brotli'd), modular library for measuring all the [Web Vitals](https://web.dev/articles/vitals) metrics on real users, in a way that accurately matches how they're measured by Chrome and reported to other Google tools (e.g. [Chrome User Experience Report](https://developers.google.com/web/tools/chrome-user-experience-report), [Page Speed Insights](https://developers.google.com/speed/pagespeed/insights/), [Search Console's Speed Report](https://webmasters.googleblog.com/2019/11/search-console-speed-report.html)).
The `web-vitals` library is a tiny (~2K, brotli'd), modular library for measuring all the [Web Vitals](https://web.dev/articles/vitals) metrics on real users, in a way that accurately matches how they're measured by Chrome and reported to other Google tools (e.g. [Chrome User Experience Report](https://developers.google.com/web/tools/chrome-user-experience-report), [Page Speed Insights](https://developers.google.com/speed/pagespeed/insights/), [Search Console's Speed Report](https://webmasters.googleblog.com/2019/11/search-console-speed-report.html)).

The library supports all of the [Core Web Vitals](https://web.dev/articles/vitals#core_web_vitals) as well as a number of other metrics that are useful in diagnosing [real-user](https://web.dev/articles/user-centric-performance-metrics) performance issues.

Expand Down Expand Up @@ -877,31 +877,86 @@ interface FIDAttribution {
```ts
interface INPAttribution {
/**
* A selector identifying the element that the user interacted with for
* the event corresponding to INP. This element will be the `target` of the
* `event` dispatched.
* A selector identifying the element that the user first interacted with
philipwalton marked this conversation as resolved.
Show resolved Hide resolved
* as part of the frame where the INP candidate interaction occurred.
philipwalton marked this conversation as resolved.
Show resolved Hide resolved
* If `interactionTarget` is an empty string, that generally means the
* element was removed from the DOM as part of the interaction.
philipwalton marked this conversation as resolved.
Show resolved Hide resolved
philipwalton marked this conversation as resolved.
Show resolved Hide resolved
*/
eventTarget?: string;
interactionTarget: string;
tunetheweb marked this conversation as resolved.
Show resolved Hide resolved
/**
* The time when the user interacted for the event corresponding to INP.
* This time will match the `timeStamp` value of the `event` dispatched.
* The time when the user first interacted during the frame where the INP
* candidate interaction occurred (if more than one interaction occurred
* within the frame, only the first time is reported).
*/
eventTime?: number;
interactionTime: number;
/**
* The `type` of the `event` dispatched corresponding to INP.
* The type of interaction. This will be either 'pointer' or 'keyboard'
* since those are the only types of interactions considered for INP.
*/
eventType?: string;
interactionType: 'pointer' | 'keyboard';
philipwalton marked this conversation as resolved.
Show resolved Hide resolved

/**
* The `PerformanceEventTiming` entry corresponding to INP.
* If the browser supports the Long Animation Frame API and a
* `long-animation-frame` entry is detected that corresponds to the INP
* frame, it will be reported here.
*/
eventEntry?: PerformanceEventTiming;
longAnimationFrameEntry?: PerformanceLongAnimationFrameTiming;
philipwalton marked this conversation as resolved.
Show resolved Hide resolved

/**
* The loading state of the document at the time when the even corresponding
* to INP occurred (see `LoadState` for details). If the interaction occurred
* while the document was loading and executing script (e.g. usually in the
* `dom-interactive` phase) it can result in long delays.
* The time from when the user interacted with the page until when the
* browser was first able to start processing event listeners for that
* interaction. This time captures the delay in processing an interaction
* due to the main thread being busy with other work.
*/
loadState?: LoadState;
inputDelay: number;

/**
* The time from when the first event listener started running in response to
* a user interaction until when all processing code has finished running,
philipwalton marked this conversation as resolved.
Show resolved Hide resolved
* and the browser is able to start rendering the next frame. If the user
* interacts again before processing has finished (which is common if event
* processing takes a long time), then any event processing time from
* subsequent interactions could get included in this processing time value
* since that delays the next paint for this interaction. If multiple
* interactions occur within the same frame, only the first interaction is
* considered for INP since it will be the longest.
*
* Note: if a `long-animation-frame` entry was detected for this frame
* its `renderStart` property will be used to mark the `processingTime`
philipwalton marked this conversation as resolved.
Show resolved Hide resolved
* end, otherwise the `processingEnd` time of the last `event` entry in the
* frame before the next paint will be used. Usually, these times are the
* same. However, the `long-animation-frame` time provides a more accurate
* measurement in rare cases where an event entry is missed or a
* `setTimeout()` or other task is prioritized before rendering starts.
*/
processingTime: number;

/**
* The time from when the browser finished processing all event listeners
* and started rendering the next frame until when that frame is actually
* presented on the screen and visible to the user. This time includes
* work on the main thread (such as `requestAnimationFrame()` callbacks,
* `ResizeObserver` and `IntersectionObserver` callbacks, and style/layout
* calculation) as well as off-main-thread work (such as compositor, GPU, and
* raster work).
*
* Note: if a `long-animation-frame` entry was detected for this frame
* its `renderStart` property will be used to mark the `presentationDelay`
* start, otherwise the `processingEnd` time of the last `event` entry in the
* frame before the next paint will be used. Usually, these times are the
* same. However, the `long-animation-frame` time provides a more accurate
* measurement in rare cases where an event entry is missed or a
* `setTimeout()` or other task is prioritized before rendering starts.
*/
presentationDelay: number;

/**
* The loading state of the document at the time when the interaction
* corresponding to INP occurred (see `LoadState` for details). If the
* interaction occurred while the document was loading and executing script
* (e.g. usually in the `dom-interactive` phase) it can result in long delays.
*/
loadState: LoadState;
}
```

Expand Down
136 changes: 106 additions & 30 deletions src/attribution/onINP.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

import {getLoadState} from '../lib/getLoadState.js';
import {getSelector} from '../lib/getSelector.js';
import {observe} from '../lib/observe.js';
import {onINP as unattributedOnINP} from '../onINP.js';
import {
INPMetric,
Expand All @@ -25,38 +26,109 @@ import {
ReportOpts,
} from '../types.js';

// The maximum number of LoAF entries with interactions to keep in memory.
// 10 is chosen here because it corresponds to the maximum number of
// long interactions needed to compute INP, so no matter which one of those
// interactions ends up being the INP candidate, it should correspond to one
// of the 10 longest LoAF entries containing an interaction.
const MAX_LOAFS_TO_CONSIDER = 10;
philipwalton marked this conversation as resolved.
Show resolved Hide resolved

// A list of longest LoAFs with interactions on the page sorted so the
// longest one is first. The list is at most MAX_LOAFS_TO_CONSIDER long.
const longestLoAFsList: PerformanceLongAnimationFrameTiming[] = [];

// A PerformanceObserver, observing new `long-animation-frame` entries.
// If this variable is defined it means the browser supports LoAF.
let loafObserver: PerformanceObserver | undefined;

const handleEntries = (entries: PerformanceLongAnimationFrameTiming[]) => {
entries.forEach((entry) => {
if (entry.firstUIEventTimestamp > 0) {
philipwalton marked this conversation as resolved.
Show resolved Hide resolved
const minLongestLoAF = longestLoAFsList[longestLoAFsList.length - 1];

if (!longestLoAFsList.length) {
longestLoAFsList.push(entry);
return;
}

// If the entry is possibly one of the 10 longest, insert it into the
// list of pending LoAFs at the correct spot (sorted), ensuring the list
// is not longer than `MAX_LOAFS_TO_CONSIDER`.
if (
longestLoAFsList.length < MAX_LOAFS_TO_CONSIDER ||
entry.duration > minLongestLoAF.duration
) {
for (let i = 0; i < longestLoAFsList.length; i++) {
if (entry.duration > longestLoAFsList[i].duration) {
longestLoAFsList.splice(i, 0, entry);
break;
}
}
if (longestLoAFsList.length > MAX_LOAFS_TO_CONSIDER) {
longestLoAFsList.splice(MAX_LOAFS_TO_CONSIDER);
}
}
}
});
};

const attributeINP = (metric: INPMetric): void => {
if (metric.entries.length) {
const longestEntry = metric.entries.sort((a, b) => {
// Sort by: 1) duration (DESC), then 2) processing time (DESC)
return (
b.duration - a.duration ||
b.processingEnd -
b.processingStart -
(a.processingEnd - a.processingStart)
);
})[0];

// Currently Chrome can return a null target for certain event types
// (especially pointer events). As the event target should be the same
// for all events in the same interaction, we pick the first non-null one.
// TODO: remove when 1367329 is resolved
// https://bugs.chromium.org/p/chromium/issues/detail?id=1367329
const firstEntryWithTarget = metric.entries.find((entry) => entry.target);
philipwalton marked this conversation as resolved.
Show resolved Hide resolved

(metric as INPMetricWithAttribution).attribution = {
eventTarget: getSelector(
firstEntryWithTarget && firstEntryWithTarget.target,
),
eventType: longestEntry.name,
eventTime: longestEntry.startTime,
eventEntry: longestEntry,
loadState: getLoadState(longestEntry.startTime),
};
return;
const sortedEntries = metric.entries.sort((a, b) => {
return a.processingStart - b.processingStart;
});

const firstEntry = sortedEntries[0];
const lastEntry = sortedEntries[sortedEntries.length - 1];
const renderTime = firstEntry.startTime + firstEntry.duration;
philipwalton marked this conversation as resolved.
Show resolved Hide resolved

let longAnimationFrameEntry;
for (const loaf of longestLoAFsList) {
const loafEnd = loaf.startTime + loaf.duration;
if (
firstEntry.startTime === loaf.firstUIEventTimestamp ||
philipwalton marked this conversation as resolved.
Show resolved Hide resolved
(loafEnd <= renderTime && loafEnd >= firstEntry.processingStart)
philipwalton marked this conversation as resolved.
Show resolved Hide resolved
) {
longAnimationFrameEntry = loaf;
break;
}
}
// Set an empty object if no other attribution has been set.
(metric as INPMetricWithAttribution).attribution = {};

// If the browser supports the Long Animation Frame API and a
// `long-animation-frame` entry was found matching this interaction,
// use that entry's `renderTime` since it could be more accurate (and
// it accounts for non-event listener work such as timers or other
// pending tasks that could get run before rendering starts). If not,
// use the `processingEnd` value of the last entry in the list, which
// should match the `renderTime` value in most cases.
const renderStart = longAnimationFrameEntry
philipwalton marked this conversation as resolved.
Show resolved Hide resolved
? longAnimationFrameEntry.renderStart
: lastEntry.processingEnd;

// The first entry may not have a target defined, so use the first
// one found in the entry list.
const firstEntryWithTarget = metric.entries.find((entry) => entry.target);
philipwalton marked this conversation as resolved.
Show resolved Hide resolved

// Determine the type of interaction based on the first entry with
// a matching keydown/keyup or pointerdown/pointerup entry name.
const firstEntryWithType = metric.entries.find((entry) =>
entry.name.match(/^(key|pointer)(down|up)$/),
);
philipwalton marked this conversation as resolved.
Show resolved Hide resolved

(metric as INPMetricWithAttribution).attribution = {
interactionTarget: getSelector(
firstEntryWithTarget && firstEntryWithTarget.target,
),
interactionType:
firstEntryWithType && firstEntryWithType.name.startsWith('key')
? 'keyboard'
: 'pointer',
interactionTime: sortedEntries[0].startTime,
longAnimationFrameEntry: longAnimationFrameEntry,
inputDelay: firstEntry.processingStart - firstEntry.startTime,
processingTime: renderStart - firstEntry.processingStart,
presentationDelay: Math.max(renderTime - renderStart, 0),
loadState: getLoadState(sortedEntries[0].startTime),
};
};

/**
Expand Down Expand Up @@ -90,6 +162,10 @@ export const onINP = (
onReport: INPReportCallbackWithAttribution,
opts?: ReportOpts,
) => {
if (!loafObserver) {
loafObserver = observe('long-animation-frame', handleEntries);
}

unattributedOnINP(
((metric: INPMetricWithAttribution) => {
attributeINP(metric);
Expand Down
5 changes: 3 additions & 2 deletions src/lib/observe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,11 @@

interface PerformanceEntryMap {
'event': PerformanceEventTiming[];
'paint': PerformancePaintTiming[];
'first-input': PerformanceEventTiming[];
'layout-shift': LayoutShift[];
'largest-contentful-paint': LargestContentfulPaint[];
'first-input': PerformanceEventTiming[];
'long-animation-frame': PerformanceLongAnimationFrameTiming[];
'paint': PerformancePaintTiming[];
'navigation': PerformanceNavigationTiming[];
'resource': PerformanceResourceTiming[];
}
Expand Down