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

INP breakdown and LoAF integration with web-vitals v4 #177

Merged
merged 42 commits into from
May 13, 2024
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
8db4f5b
Update web-vitals-extension to work with web vitals v4
tunetheweb Mar 26, 2024
2e885ed
Add back interaction type
tunetheweb Mar 26, 2024
1f5a72f
Simplify first-start
tunetheweb Mar 26, 2024
2d5b033
More simplier
tunetheweb Mar 26, 2024
c42c100
Tidy up
tunetheweb Mar 27, 2024
5ba9825
TTFB redirect breakdown
tunetheweb Apr 5, 2024
16fac55
Update interaction logging to be similar to v4 INP
tunetheweb Apr 5, 2024
4b8ddc9
Script order, invoker, and latest web-vitals v4
tunetheweb Apr 17, 2024
ac7d440
With LCP fixes
tunetheweb Apr 26, 2024
0064458
Fix marks
tunetheweb Apr 28, 2024
c2fb5af
Handle when no metric has target
tunetheweb Apr 30, 2024
46df833
Import thresholds from web-vitals.js
tunetheweb Apr 30, 2024
23dee41
Standardise units
tunetheweb Apr 30, 2024
4302f86
Fix TTFB sub part name
tunetheweb May 1, 2024
e8ce0bb
Latest v4 web-vitals
tunetheweb May 1, 2024
bd94df4
Review feedback
tunetheweb May 1, 2024
4e3b539
Move LoAF observer
tunetheweb May 2, 2024
9d28444
Remove interaction breakdowns to simplify code
tunetheweb May 2, 2024
3d61934
Better filtering of sub parts
tunetheweb May 2, 2024
4bdce40
Improve formatting and stop hardcoding thresholds
tunetheweb May 2, 2024
db7efd3
Further clean ups
tunetheweb May 2, 2024
6f7f8b1
More clean up
tunetheweb May 2, 2024
a4daaeb
More cleanup
tunetheweb May 2, 2024
f35e290
Interaction threshold
tunetheweb May 2, 2024
191786d
Default green status for waiting
tunetheweb May 3, 2024
8e36384
Merge branch 'formatting' into webvitals-v4-support
tunetheweb May 3, 2024
6849e26
Merge issue
tunetheweb May 3, 2024
7070fef
Update to latest v4 changes
tunetheweb May 3, 2024
e886298
Review feedback
tunetheweb May 5, 2024
dd14585
Merge branch 'main' into webvitals-v4-support
tunetheweb May 7, 2024
9ec020a
cleanup
tunetheweb May 7, 2024
4b176ec
Fix merge issues
tunetheweb May 7, 2024
ef03287
clickable script sources
tunetheweb May 8, 2024
d4ae5bf
formatting
tunetheweb May 8, 2024
638b5a6
Merge branch 'main' into webvitals-v4-support
tunetheweb May 8, 2024
4a9cc97
Better target
tunetheweb May 9, 2024
d896140
Latest build
tunetheweb May 9, 2024
356957e
Latest web-vitals build
tunetheweb May 9, 2024
7c2a68f
Add element
tunetheweb May 10, 2024
4790442
t commit -a latest web-vitals v4
tunetheweb May 12, 2024
8355b94
Upgrade web-vitals
tunetheweb May 13, 2024
76a57f9
Minor version bump
tunetheweb May 13, 2024
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
3 changes: 2 additions & 1 deletion manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@
}
],
"background": {
"service_worker": "service_worker.js"
"service_worker": "service_worker.js",
"type": "module"
tunetheweb marked this conversation as resolved.
Show resolved Hide resolved
},
"content_security_policy": {
"extension_pages": "default-src 'self'; connect-src https://chromeuxreport.googleapis.com;"
Expand Down
14 changes: 8 additions & 6 deletions service_worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@
limitations under the License.
*/

import {CLSThresholds, FCPThresholds, FIDThresholds, INPThresholds, LCPThresholds, TTFBThresholds} from './src/browser_action/web-vitals.js'

// Core Web Vitals thresholds
const LCP_THRESHOLD = 2500;
const FID_THRESHOLD = 100;
const INP_THRESHOLD = 200;
const CLS_THRESHOLD = 0.1;
const FCP_THRESHOLD = 1800;
const TTFB_THRESHOLD = 800;
const LCP_THRESHOLD = LCPThresholds[0];
const FID_THRESHOLD = FIDThresholds[0];
const INP_THRESHOLD = INPThresholds[0];
const CLS_THRESHOLD = CLSThresholds[0];
const FCP_THRESHOLD = FCPThresholds[0];
const TTFB_THRESHOLD = TTFBThresholds[0];
const ONE_DAY_MS = 24 * 60 * 60 * 1000;

// Get the optionsNoBadgeAnimation value
Expand Down
45 changes: 26 additions & 19 deletions src/browser_action/metric.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import {CLSThresholds, FCPThresholds, FIDThresholds, INPThresholds, LCPThresholds, TTFBThresholds} from './web-vitals.js';

export class Metric {

constructor({id, name, local, background, thresholds}) {
Expand Down Expand Up @@ -81,13 +83,13 @@ export class Metric {
return;
}

toLocaleFixed({value, unit}) {
toLocaleFixed({value, unit, precision }) {
return value.toLocaleString(undefined, {
style: unit && 'unit',
unit,
unitDisplay: 'narrow',
minimumFractionDigits: this.digitsOfPrecision,
maximumFractionDigits: this.digitsOfPrecision
unitDisplay: 'short',
minimumFractionDigits: precision ?? this.digitsOfPrecision,
maximumFractionDigits: precision ?? this.digitsOfPrecision,
});
}

Expand Down Expand Up @@ -154,8 +156,8 @@ export class LCP extends Metric {

constructor({local, background}) {
const thresholds = {
good: 2500,
poor: 4000
good: LCPThresholds[0],
poor: LCPThresholds[1]
};

// TODO(rviscomi): Consider better defaults.
Expand Down Expand Up @@ -196,8 +198,8 @@ export class FID extends Metric {

constructor({local, background}) {
const thresholds = {
good: 100,
poor: 300
good: FIDThresholds[0],
poor: FIDThresholds[1]
};

super({
Expand All @@ -216,7 +218,8 @@ export class FID extends Metric {

return this.toLocaleFixed({
value,
unit: 'millisecond'
unit: 'millisecond',
precision: 0
});
}

Expand All @@ -234,8 +237,8 @@ export class INP extends Metric {

constructor({local, background}) {
const thresholds = {
good: 200,
poor: 500
good: INPThresholds[0],
poor: INPThresholds[1]
};

super({
Expand All @@ -254,7 +257,8 @@ export class INP extends Metric {

return this.toLocaleFixed({
value,
unit: 'millisecond'
unit: 'millisecond',
precision: 0
});
}

Expand All @@ -272,8 +276,8 @@ export class CLS extends Metric {

constructor({local, background}) {
const thresholds = {
good: 0.10,
poor: 0.25
good: CLSThresholds[0],
poor: CLSThresholds[1]
};

// TODO(rviscomi): Consider better defaults.
Expand All @@ -289,7 +293,10 @@ export class CLS extends Metric {
}

formatValue(value) {
return this.toLocaleFixed({value});
return this.toLocaleFixed({
value: value,
precision: 2
});
}

}
Expand All @@ -298,8 +305,8 @@ export class FCP extends Metric {

constructor({local, background}) {
const thresholds = {
good: 1800,
poor: 3000
good: FCPThresholds[0],
poor: FCPThresholds[1]
};

// TODO(rviscomi): Consider better defaults.
Expand Down Expand Up @@ -340,8 +347,8 @@ export class TTFB extends Metric {

constructor({local, background}) {
const thresholds = {
good: 800,
poor: 1800
good: TTFBThresholds[0],
poor: TTFBThresholds[1]
};

// TODO(rviscomi): Consider better defaults.
Expand Down
140 changes: 118 additions & 22 deletions src/browser_action/on-each-interaction.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,43 +11,139 @@
limitations under the License.
*/


tunetheweb marked this conversation as resolved.
Show resolved Hide resolved
let recentLoAFs = [];

const getIntersectingLoAFs = (start, end) => {
const intersectingLoAFs = [];

for (let i = 0, loaf; (loaf = recentLoAFs[i]); i++) {
// If the LoAF ends before the given start time, ignore it.
if (loaf.startTime + loaf.duration < start) continue;

// If the LoAF starts after the given end time, ignore it and all
// subsequent pending LoAFs (because they're in time order).
if (loaf.startTime > end) break;

// Still here? If so this LoAF intersects with the interaction.
intersectingLoAFs.push(loaf);
}
return intersectingLoAFs;
};

const loafObserver = new PerformanceObserver((list) => {

for (const entry of list.getEntries()) { recentLoAFs.push(entry) };
// We report interactions immediately, so don't need to keep many LoAFs around.
// Let's keep the last 5.
recentLoAFs = recentLoAFs.slice(-5);

});
loafObserver.observe({
tunetheweb marked this conversation as resolved.
Show resolved Hide resolved
type: 'long-animation-frame',
buffered: true,
});

/**
* We emulate an INP entry with similar logic to web-vitals.js
* But we have it easier as will emit it immediately and don't need the p98 stuff
* So can strip a lot of that logic out.
* @param {Function} callback
*/
export function onEachInteraction(callback) {

const valueToRating = (score) => score <= 200 ? 'good' : score <= 500 ? 'needs-improvement' : 'poor';

const observer = new PerformanceObserver((list) => {
const interactions = {};

for (const entry of list.getEntries().filter((entry) => entry.interactionId)) {
interactions[entry.interactionId] = interactions[entry.interactionId] || [];
interactions[entry.interactionId].push(entry);
const allEvents = list.getEntries();

// filter just for INP-eligible events (those with an interactionId)
const interactions = allEvents.filter((entry) => entry.interactionId)
// If none then can just end
if (interactions.length == 0 ) return;

const longestEntry = interactions.reduce((prev, curr) => prev.duration >= curr.duration ? prev : curr);
const largestDuration = longestEntry.duration;
const largestRenderTime = longestEntry.startTime + longestEntry.duration;

const longestFrameEntries = [];
const group = {
tunetheweb marked this conversation as resolved.
Show resolved Hide resolved
startTime: null,
processingStart: null,
processingEnd: null
}

// Will report as a single interaction even if parts are in separate frames.
// Consider splitting by animation frame.
for (const interaction of Object.values(interactions)) {
const entry = interaction.reduce((prev, curr) => prev.duration >= curr.duration ? prev : curr);
const value = entry.duration;

callback({
attribution: {
eventEntry: entry,
eventTime: entry.startTime,
eventType: entry.name,
},
entries: interaction,
name: 'Interaction',
rating: valueToRating(value),
value,
});
// Go though the list of all events (not just those with interactionIds)
// do get details of what was in the longest frame and the earliest and latest event timestamps
tunetheweb marked this conversation as resolved.
Show resolved Hide resolved
for (const entry of allEvents) {
tunetheweb marked this conversation as resolved.
Show resolved Hide resolved
const renderTime = entry.startTime + entry.duration;
// If a previous render time is within 8ms of the largest render time,
// assume they were part of the same frame so include them
if (Math.abs(renderTime - largestRenderTime) <= 8) {
longestFrameEntries.push(entry);
group.startTime = group.startTime ? Math.min(entry.startTime, group.startTime) : entry.startTime;
group.processingStart = group.processingStart ?
Math.min(
entry.processingStart,
group.processingStart,
) :
entry.processingStart;
group.processingEnd = group.processingEnd ? Math.max(entry.processingEnd, group.processingEnd) : entry.processingEnd;
}
}

// For the breakdowns we need to know the first and last interaction events (i.e. those wiht interactionIds) in that longest list
tunetheweb marked this conversation as resolved.
Show resolved Hide resolved
const longestFrameInteractionEntries = longestFrameEntries.filter((entry) => entry.interactionId);
// Entries should be in order so can use that to find first and last
const firstInteractionEntry = longestFrameInteractionEntries[0];
const lastInteractionEntry = longestFrameInteractionEntries.slice(-1)[0];

// Filter further to get the entries entry (the longest interactionId events wiht the longest durations)
tunetheweb marked this conversation as resolved.
Show resolved Hide resolved
const longestInteractionEntries = longestFrameInteractionEntries.filter((entry) => entry.interactionId === longestEntry.interactionId && entry.duration === largestDuration);

// Sometimes target is not set so look across all events with that interactionId to get it
const firstInteractionEntryWithTarget = interactions.find((entry) => longestEntry.interactionId === longestEntry.interactionId && entry.target);

// Filter down LoAFs to ones that interested any event startTime and any processingEnd
tunetheweb marked this conversation as resolved.
Show resolved Hide resolved
// Theb LoAF processing the last script in that frame will then present that frame so
tunetheweb marked this conversation as resolved.
Show resolved Hide resolved
// we get rAF and other rendering work too with this intrsection.
tunetheweb marked this conversation as resolved.
Show resolved Hide resolved
const longAnimationFrameEntries = getIntersectingLoAFs(group.startTime, group.processingEnd)

// Since entry durations are rounded to the nearest 8ms, we need to clamp
// the `nextPaintTime` value to be higher than the `group.processingEnd` or
// end time of any LoAF entry.
const nextPaintTimeCandidates = [
firstInteractionEntry.startTime + firstInteractionEntry.duration,
group.processingEnd,
].concat(
longAnimationFrameEntries.map((loaf) => loaf.startTime + loaf.duration),
);
const nextPaintTime = Math.max.apply(Math, nextPaintTimeCandidates);

// Gather that all in a similar structure to an INP attribution object and send it back
callback({
tunetheweb marked this conversation as resolved.
Show resolved Hide resolved
name: 'Interaction',
value: largestDuration,
rating: valueToRating(largestDuration),
entries: longestInteractionEntries,
attribution: {
inputDelay: firstInteractionEntry.processingStart - firstInteractionEntry.startTime,
interactionTarget: firstInteractionEntryWithTarget?.target,
interactionTime: firstInteractionEntry.startTime,
interactionType: firstInteractionEntryWithTarget?.name?.startsWith('key') ? 'keyboard' : 'pointer',
longAnimationFrameEntries: longAnimationFrameEntries,
nextPaintTime: nextPaintTime,
presentationDelay: nextPaintTime - lastInteractionEntry.processingEnd,
processedEventEntries: longestFrameEntries,
processingDuration: lastInteractionEntry.processingEnd - firstInteractionEntry.processingStart,
tunetheweb marked this conversation as resolved.
Show resolved Hide resolved
},
});
});

observer.observe({
type: 'event',
durationThreshold: 0,
durationThreshold: 40,
tunetheweb marked this conversation as resolved.
Show resolved Hide resolved
buffered: true,
});
}
Loading
Loading