Skip to content

Commit a51d9f9

Browse files
Adam RaineDevtools-frontend LUCI CQ
authored andcommitted
[RPP Observations] Add phases for each interaction
- Interaction list items are expandable - Creates separate interaction if events happen in different frames - Interactions are restored from the buffer - `eventNames` property added to each interaction for future use Bug: 369097528, 365160880, 371052022 Change-Id: Iab39cb300143aad3a6f2cc5ef5ecab79d7fd879e Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/5933344 Reviewed-by: Paul Irish <[email protected]> Commit-Queue: Adam Raine <[email protected]>
1 parent 0d448bd commit a51d9f9

File tree

13 files changed

+397
-144
lines changed

13 files changed

+397
-144
lines changed

front_end/models/live-metrics/LiveMetrics.ts

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import * as Common from '../../core/common/common.js';
66
import * as Host from '../../core/host/host.js';
7+
import * as Platform from '../../core/platform/platform.js';
78
import * as Root from '../../core/root/root.js';
89
import * as SDK from '../../core/sdk/sdk.js';
910
import type * as Protocol from '../../generated/protocol.js';
@@ -26,6 +27,8 @@ class InjectedScript {
2627
}
2728
}
2829

30+
export type InteractionMap = Map<Spec.UniqueInteractionId, Interaction>;
31+
2932
export class LiveMetrics extends Common.ObjectWrapper.ObjectWrapper<EventTypes> implements SDK.TargetManager.Observer {
3033
#enabled = false;
3134
#target?: SDK.Target.Target;
@@ -34,7 +37,7 @@ export class LiveMetrics extends Common.ObjectWrapper.ObjectWrapper<EventTypes>
3437
#lcpValue?: LCPValue;
3538
#clsValue?: CLSValue;
3639
#inpValue?: INPValue;
37-
#interactions: Interaction[] = [];
40+
#interactions: InteractionMap = new Map();
3841
#layoutShifts: LayoutShift[] = [];
3942
#mutex = new Common.Mutex.Mutex();
4043

@@ -64,7 +67,7 @@ export class LiveMetrics extends Common.ObjectWrapper.ObjectWrapper<EventTypes>
6467
return this.#inpValue;
6568
}
6669

67-
get interactions(): Interaction[] {
70+
get interactions(): InteractionMap {
6871
return this.#interactions;
6972
}
7073

@@ -140,7 +143,7 @@ export class LiveMetrics extends Common.ObjectWrapper.ObjectWrapper<EventTypes>
140143

141144
const allLayoutAffectedNodes = this.#layoutShifts.flatMap(shift => shift.affectedNodes);
142145
const toRefresh: Array<{node?: SDK.DOMModel.DOMNode}> =
143-
[this.#lcpValue || {}, ...this.#interactions, ...allLayoutAffectedNodes];
146+
[this.#lcpValue || {}, ...this.#interactions.values(), ...allLayoutAffectedNodes];
144147

145148
const allPromises = toRefresh.map(item => {
146149
const node = item.node;
@@ -198,20 +201,30 @@ export class LiveMetrics extends Common.ObjectWrapper.ObjectWrapper<EventTypes>
198201
this.#inpValue = inpEvent;
199202
break;
200203
}
201-
case 'Interaction': {
202-
const interaction: Interaction = {
203-
duration: webVitalsEvent.duration,
204-
interactionType: webVitalsEvent.interactionType,
205-
uniqueInteractionId: webVitalsEvent.uniqueInteractionId,
206-
};
204+
case 'InteractionEntry': {
205+
const interaction: Interaction = Platform.MapUtilities.getWithDefault(
206+
this.#interactions, webVitalsEvent.uniqueInteractionId,
207+
() => ({
208+
interactionType: webVitalsEvent.interactionType,
209+
duration: webVitalsEvent.duration,
210+
eventNames: [],
211+
phases: webVitalsEvent.phases,
212+
uniqueInteractionId: webVitalsEvent.uniqueInteractionId,
213+
}));
214+
215+
// We can get multiple instances of the first input interaction since web-vitals.js installs
216+
// an extra listener for events of type `first-input`. This is a simple way to de-dupe those
217+
// events without adding complexity to the injected code.
218+
if (!interaction.eventNames.includes(webVitalsEvent.eventName)) {
219+
interaction.eventNames.push(webVitalsEvent.eventName);
220+
}
221+
207222
if (webVitalsEvent.nodeIndex !== undefined) {
208223
const node = await this.#resolveDomNode(webVitalsEvent.nodeIndex, executionContextId);
209224
if (node) {
210225
interaction.node = node;
211226
}
212227
}
213-
214-
this.#interactions.push(interaction);
215228
break;
216229
}
217230
case 'LayoutShift': {
@@ -235,7 +248,7 @@ export class LiveMetrics extends Common.ObjectWrapper.ObjectWrapper<EventTypes>
235248
this.#lcpValue = undefined;
236249
this.#clsValue = undefined;
237250
this.#inpValue = undefined;
238-
this.#interactions = [];
251+
this.#interactions.clear();
239252
this.#layoutShifts = [];
240253
break;
241254
}
@@ -322,7 +335,7 @@ export class LiveMetrics extends Common.ObjectWrapper.ObjectWrapper<EventTypes>
322335
}
323336

324337
clearInteractions(): void {
325-
this.#interactions = [];
338+
this.#interactions.clear();
326339
this.#sendStatusUpdate();
327340
}
328341

@@ -467,8 +480,10 @@ export interface LayoutShift {
467480
}
468481

469482
export interface Interaction {
470-
interactionType: Spec.InteractionEvent['interactionType'];
471-
duration: Spec.InteractionEvent['duration'];
483+
interactionType: Spec.InteractionEntryEvent['interactionType'];
484+
eventNames: string[];
485+
duration: Spec.InteractionEntryEvent['duration'];
486+
phases: Spec.INPPhases;
472487
uniqueInteractionId: Spec.UniqueInteractionId;
473488
node?: SDK.DOMModel.DOMNode;
474489
}
@@ -477,7 +492,7 @@ export interface StatusEvent {
477492
lcp?: LCPValue;
478493
cls?: CLSValue;
479494
inp?: INPValue;
480-
interactions: Interaction[];
495+
interactions: InteractionMap;
481496
layoutShifts: LayoutShift[];
482497
}
483498

front_end/models/live-metrics/web-vitals-injected/OnEachInteraction.ts

Lines changed: 24 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -4,68 +4,31 @@
44

55
/**
66
* @fileoverview web-vitals.js doesn't provide a log of all interactions.
7-
* This was largely copied from the web vitals extension:
8-
* https://github.com/GoogleChrome/web-vitals-extension/blob/main/src/browser_action/on-each-interaction.js
7+
* This solution is hacky but it was recommended by web-vitals devs:
8+
* b/371052022
99
*/
1010

11-
import type * as WebVitals from '../../../third_party/web-vitals/web-vitals.js';
12-
13-
export interface InteractionWithAttribution {
14-
attribution: {
15-
interactionTargetElement: Node|null,
16-
interactionType: WebVitals.INPAttribution['interactionType'],
17-
};
18-
entries: PerformanceEventTiming[];
19-
value: number;
20-
}
21-
22-
export function onEachInteraction(callback: (interaction: InteractionWithAttribution) => void): void {
23-
const eventObserver = new PerformanceObserver(list => {
24-
const entries = list.getEntries();
25-
const interactions = new Map<number, PerformanceEventTiming[]>();
26-
27-
const performanceEventTimings = entries.filter((entry): entry is PerformanceEventTiming => 'interactionId' in entry)
28-
.filter(entry => entry.interactionId);
29-
30-
for (const entry of performanceEventTimings) {
31-
const interaction = interactions.get(entry.interactionId) || [];
32-
interaction.push(entry);
33-
interactions.set(entry.interactionId, interaction);
34-
}
35-
36-
// Will report as a single interaction even if parts are in separate frames.
37-
// Consider splitting by animation frame.
38-
for (const interaction of interactions.values()) {
39-
const longestEntry = interaction.reduce((prev, curr) => prev.duration >= curr.duration ? prev : curr);
40-
const value = longestEntry.duration;
41-
42-
const firstEntryWithTarget = interaction.find(entry => entry.target);
43-
44-
callback({
45-
attribution: {
46-
interactionTargetElement: firstEntryWithTarget?.target ?? null,
47-
interactionType: longestEntry.name.startsWith('key') ? 'keyboard' : 'pointer',
48-
},
49-
entries: interaction,
50-
value,
51-
});
52-
}
53-
});
54-
55-
eventObserver.observe({
56-
type: 'first-input',
57-
buffered: false,
58-
});
59-
60-
eventObserver.observe({
61-
type: 'event',
62-
durationThreshold: 0,
63-
// Interaction events can only be stored to the buffer if their duration is >=104ms.
64-
// https://www.w3.org/TR/event-timing/#sec-events-exposed
65-
//
66-
// This means we can only collect a subset of interactions that happen before this observer is started.
67-
// To avoid confusion, we only collect interactions that after this observer has started.
68-
// Note: This DOES NOT affect the collection for the INP metric and so INP will still be restored from the buffer.
69-
buffered: false,
11+
import * as WebVitals from '../../../third_party/web-vitals/web-vitals.js';
12+
13+
export function onEachInteraction(onReport: (metric: WebVitals.INPMetricWithAttribution) => void): void {
14+
WebVitals.entryPreProcessingCallbacks.push((entry: PerformanceEventTiming) => {
15+
// Wait a microtask so this "pre" processing callback actually
16+
// becomes a "post" processing callback.
17+
void Promise.resolve().then(() => {
18+
if (entry.interactionId) {
19+
const interaction = WebVitals.attributeINP({
20+
entries: [entry],
21+
// The only value we really need for `attributeINP` is `entries`
22+
// Everything else is included to fill out the type.
23+
name: 'INP',
24+
rating: 'good',
25+
value: entry.duration,
26+
delta: entry.duration,
27+
navigationType: 'navigate',
28+
id: 'N/A',
29+
});
30+
onReport(interaction);
31+
}
32+
});
7033
});
7134
}

front_end/models/live-metrics/web-vitals-injected/spec/spec.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,11 +62,18 @@ export interface INPChangeEvent extends MetricChangeEvent {
6262
uniqueInteractionId: UniqueInteractionId;
6363
}
6464

65-
export interface InteractionEvent {
66-
name: 'Interaction';
65+
/**
66+
* This event is not 1:1 with the interactions that the user sees in the interactions log.
67+
* It is 1:1 with a `PerformanceEventTiming` entry that will be combined by `uniqueInteractionId`
68+
* in the DevTools client.
69+
*/
70+
export interface InteractionEntryEvent {
71+
name: 'InteractionEntry';
6772
interactionType: INPAttribution['interactionType'];
73+
eventName: string;
6874
uniqueInteractionId: UniqueInteractionId;
6975
duration: number;
76+
phases: INPPhases;
7077
nodeIndex?: number;
7178
}
7279

@@ -81,4 +88,5 @@ export interface ResetEvent {
8188
name: 'reset';
8289
}
8390

84-
export type WebVitalsEvent = LCPChangeEvent|CLSChangeEvent|INPChangeEvent|InteractionEvent|LayoutShiftEvent|ResetEvent;
91+
export type WebVitalsEvent =
92+
LCPChangeEvent|CLSChangeEvent|INPChangeEvent|InteractionEntryEvent|LayoutShiftEvent|ResetEvent;

front_end/models/live-metrics/web-vitals-injected/web-vitals-injected.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -152,14 +152,23 @@ function initialize(): void {
152152
interactionType: metric.attribution.interactionType,
153153
};
154154
sendEventToDevTools(event);
155-
}, {reportAllChanges: true});
155+
}, {reportAllChanges: true, durationThreshold: 0});
156156

157157
onEachInteraction(interaction => {
158-
const event: Spec.InteractionEvent = {
159-
name: 'Interaction',
158+
// Multiple `InteractionEntry` events can be emitted for the same `uniqueInteractionId`
159+
// However, it is easier to combine these entries in the DevTools client rather than in
160+
// this injected code.
161+
const event: Spec.InteractionEntryEvent = {
162+
name: 'InteractionEntry',
160163
duration: interaction.value,
164+
phases: {
165+
inputDelay: interaction.attribution.inputDelay,
166+
processingDuration: interaction.attribution.processingDuration,
167+
presentationDelay: interaction.attribution.presentationDelay,
168+
},
161169
uniqueInteractionId: Spec.getUniqueInteractionId(interaction.entries),
162170
interactionType: interaction.attribution.interactionType,
171+
eventName: interaction.entries[0].name,
163172
};
164173
const node = interaction.attribution.interactionTargetElement;
165174
if (node) {

0 commit comments

Comments
 (0)