chrome-devtools-frontend
Version:
Chrome DevTools UI
383 lines (337 loc) • 16.6 kB
text/typescript
// Copyright 2022 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import * as Helpers from '../helpers/helpers.js';
import * as Types from '../types/types.js';
import {data as metaHandlerData} from './MetaHandler.js';
import {ScoreClassification} from './PageLoadMetricsHandler.js';
import type {HandlerName} from './types.js';
// This handler serves two purposes. It generates a list of events that are
// used to show user clicks in the timeline. It is also used to gather
// EventTimings into Interactions, which we use to show interactions and
// highlight long interactions to the user, along with INP.
// We don't need to know which process / thread these events occurred in,
// because they are effectively global, so we just track all that we find.
const allEvents: Types.Events.EventTimingBeginOrEnd[] = [];
const beginCommitCompositorFrameEvents: Types.Events.BeginCommitCompositorFrame[] = [];
const parseMetaViewportEvents: Types.Events.ParseMetaViewport[] = [];
export const LONG_INTERACTION_THRESHOLD = Helpers.Timing.milliToMicro(Types.Timing.Milli(200));
const INP_GOOD_TIMING = LONG_INTERACTION_THRESHOLD;
const INP_MEDIUM_TIMING = Helpers.Timing.milliToMicro(Types.Timing.Milli(500));
export interface UserInteractionsData {
/** All the user events we found in the trace */
allEvents: readonly Types.Events.EventTimingBeginOrEnd[];
/** All the BeginCommitCompositorFrame events we found in the trace */
beginCommitCompositorFrameEvents: readonly Types.Events.BeginCommitCompositorFrame[];
/** All the ParseMetaViewport events we found in the trace */
parseMetaViewportEvents: readonly Types.Events.ParseMetaViewport[];
/** All the interaction events we found in the trace that had an
* interactionId and a duration > 0
**/
interactionEvents: readonly Types.Events.SyntheticInteractionPair[];
/** If the user rapidly generates interaction events (think typing into a
* text box), in the UI we only really want to show the user the longest
* interaction in that set.
* For example picture interactions like this:
* ===[interaction A]==========
* =[interaction B]======
* =[interaction C]=
*
* These events all end at the same time, and so in this instance we only want
* to show the first interaction A on the timeline, as that is the longest one
* and the one the developer should be focusing on. So this array of events is
* all the interaction events filtered down, removing any nested interactions
* entirely.
**/
interactionEventsWithNoNesting: readonly Types.Events.SyntheticInteractionPair[];
// The longest duration interaction event. Can be null if the trace has no interaction events.
longestInteractionEvent: Readonly<Types.Events.SyntheticInteractionPair>|null;
// All interactions that went over the interaction threshold (200ms, see https://web.dev/inp/)
interactionsOverThreshold: Readonly<Set<Types.Events.SyntheticInteractionPair>>;
}
let longestInteractionEvent: Types.Events.SyntheticInteractionPair|null = null;
const interactionEvents: Types.Events.SyntheticInteractionPair[] = [];
const interactionEventsWithNoNesting: Types.Events.SyntheticInteractionPair[] = [];
const eventTimingEndEventsById = new Map<string, Types.Events.EventTimingEnd>();
const eventTimingStartEventsForInteractions: Types.Events.EventTimingBegin[] = [];
export function reset(): void {
allEvents.length = 0;
beginCommitCompositorFrameEvents.length = 0;
parseMetaViewportEvents.length = 0;
interactionEvents.length = 0;
eventTimingStartEventsForInteractions.length = 0;
eventTimingEndEventsById.clear();
interactionEventsWithNoNesting.length = 0;
longestInteractionEvent = null;
}
export function handleEvent(event: Types.Events.Event): void {
if (Types.Events.isBeginCommitCompositorFrame(event)) {
beginCommitCompositorFrameEvents.push(event);
return;
}
if (Types.Events.isParseMetaViewport(event)) {
parseMetaViewportEvents.push(event);
return;
}
if (!Types.Events.isEventTiming(event)) {
return;
}
if (Types.Events.isEventTimingEnd(event)) {
// Store the end event; for each start event that is an interaction, we need the matching end event to calculate the duration correctly.
eventTimingEndEventsById.set(event.id, event);
}
allEvents.push(event);
// From this point on we want to find events that represent interactions.
// These events are always start events - those are the ones that contain all
// the metadata about the interaction.
if (!event.args.data || !Types.Events.isEventTimingStart(event)) {
return;
}
const {duration, interactionId} = event.args.data;
// We exclude events for the sake of interactions if:
// 1. They have no duration.
// 2. They have no interactionId
// 3. They have an interactionId of 0: this indicates that it's not an
// interaction that we care about because it hasn't had its own interactionId
// set (0 is the default on the backend).
// See: https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/timing/responsiveness_metrics.cc;l=133;drc=40c209a9c365ebb9f16fb99dfe78c7fe768b9594
if (duration < 1 || interactionId === undefined || interactionId === 0) {
return;
}
// Store the start event. In the finalize() function we will pair this with
// its end event and create the synthetic interaction event.
eventTimingStartEventsForInteractions.push(event);
}
/**
* See https://web.dev/better-responsiveness-metric/#interaction-types for the
* table that defines these sets.
**/
const pointerEventTypes = new Set([
'pointerdown',
'touchstart',
'pointerup',
'touchend',
'mousedown',
'mouseup',
'click',
]);
const keyboardEventTypes = new Set([
'keydown',
'keypress',
'keyup',
]);
export type InteractionCategory = 'KEYBOARD'|'POINTER'|'OTHER';
export function categoryOfInteraction(interaction: Types.Events.SyntheticInteractionPair): InteractionCategory {
if (pointerEventTypes.has(interaction.type)) {
return 'POINTER';
}
if (keyboardEventTypes.has(interaction.type)) {
return 'KEYBOARD';
}
return 'OTHER';
}
/**
* We define a set of interactions as nested where:
* 1. Their end times align.
* 2. The longest interaction's start time is earlier than all other
* interactions with the same end time.
* 3. The interactions are of the same category [each interaction is either
* categorised as keyboard, or pointer.]
*
* =============A=[pointerup]=
* ====B=[pointerdown]=
* ===C=[pointerdown]==
* ===D=[pointerup]===
*
* In this example, B, C and D are all nested and therefore should not be
* returned from this function.
*
* However, in this example we would only consider B nested (under A) and D
* nested (under C). A and C both stay because they are of different types.
* ========A=[keydown]====
* =======B=[keyup]=====
* ====C=[pointerdown]=
* =D=[pointerup]=
**/
export function removeNestedInteractions(interactions: readonly Types.Events.SyntheticInteractionPair[]):
readonly Types.Events.SyntheticInteractionPair[] {
/**
* Because we nest events only that are in the same category, we store the
* longest event for a given end time by category.
**/
const earliestEventForEndTimePerCategory:
Record<InteractionCategory, Map<Types.Timing.Micro, Types.Events.SyntheticInteractionPair>> = {
POINTER: new Map(),
KEYBOARD: new Map(),
OTHER: new Map(),
};
function storeEventIfEarliestForCategoryAndEndTime(interaction: Types.Events.SyntheticInteractionPair): void {
const category = categoryOfInteraction(interaction);
const earliestEventForEndTime = earliestEventForEndTimePerCategory[category];
const endTime = Types.Timing.Micro(interaction.ts + interaction.dur);
const earliestCurrentEvent = earliestEventForEndTime.get(endTime);
if (!earliestCurrentEvent) {
earliestEventForEndTime.set(endTime, interaction);
return;
}
if (interaction.ts < earliestCurrentEvent.ts) {
earliestEventForEndTime.set(endTime, interaction);
} else if (
interaction.ts === earliestCurrentEvent.ts &&
interaction.interactionId === earliestCurrentEvent.interactionId) {
// We have seen in traces that the same interaction can have multiple
// events (e.g. a 'click' and a 'pointerdown'). Often only one of these
// events will have an event handler bound to it which caused delay on
// the main thread, and the others will not. This leads to a situation
// where if we pick one of the events that had no event handler, its
// processing duration (processingEnd - processingStart) will be 0, but if we
// had picked the event that had the slow event handler, we would show
// correctly the main thread delay due to the event handler.
// So, if we find events with the same interactionId and the same
// begin/end times, we pick the one with the largest (processingEnd -
// processingStart) time in order to make sure we find the event with the
// worst main thread delay, as that is the one the user should care
// about.
const currentProcessingDuration = earliestCurrentEvent.processingEnd - earliestCurrentEvent.processingStart;
const newProcessingDuration = interaction.processingEnd - interaction.processingStart;
// Use the new interaction if it has a longer processing duration than the existing one.
if (newProcessingDuration > currentProcessingDuration) {
earliestEventForEndTime.set(endTime, interaction);
}
}
// Maximize the processing duration based on the "children" interactions.
// We pick the earliest start processing duration, and the latest end
// processing duration to avoid under-reporting.
if (interaction.processingStart < earliestCurrentEvent.processingStart) {
earliestCurrentEvent.processingStart = interaction.processingStart;
writeSyntheticTimespans(earliestCurrentEvent);
}
if (interaction.processingEnd > earliestCurrentEvent.processingEnd) {
earliestCurrentEvent.processingEnd = interaction.processingEnd;
writeSyntheticTimespans(earliestCurrentEvent);
}
}
for (const interaction of interactions) {
storeEventIfEarliestForCategoryAndEndTime(interaction);
}
// Combine all the events that we have kept from all the per-category event
// maps back into an array and sort them by timestamp.
const keptEvents = Object.values(earliestEventForEndTimePerCategory)
.flatMap(eventsByEndTime => Array.from(eventsByEndTime.values()));
keptEvents.sort((eventA, eventB) => {
return eventA.ts - eventB.ts;
});
return keptEvents;
}
function writeSyntheticTimespans(event: Types.Events.SyntheticInteractionPair): void {
const startEvent = event.args.data.beginEvent;
const endEvent = event.args.data.endEvent;
event.inputDelay = Types.Timing.Micro(event.processingStart - startEvent.ts);
event.mainThreadHandling = Types.Timing.Micro(event.processingEnd - event.processingStart);
event.presentationDelay = Types.Timing.Micro(endEvent.ts - event.processingEnd);
}
export async function finalize(): Promise<void> {
const {navigationsByFrameId} = metaHandlerData();
// For each interaction start event, find the async end event by the ID, and then create the Synthetic Interaction event.
for (const interactionStartEvent of eventTimingStartEventsForInteractions) {
const endEvent = eventTimingEndEventsById.get(interactionStartEvent.id);
if (!endEvent) {
// If we cannot find an end event, bail and drop this event.
continue;
}
const {type, interactionId, timeStamp, processingStart, processingEnd} = interactionStartEvent.args.data;
if (!type || !interactionId || !timeStamp || !processingStart || !processingEnd) {
// A valid interaction event that we care about has to have a type (e.g. pointerdown, keyup).
// We also need to ensure it has an interactionId and various timings. There are edge cases where these aren't included in the trace event.
continue;
}
// In the future we will add microsecond timestamps to the trace events…
// (See https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/timing/window_performance.cc;l=900-901;drc=b503c262e425eae59ced4a80d59d176ed07152c7 )
// …but until then we can use the millisecond precision values that are in
// the trace event. To adjust them to be relative to the event.ts and the
// trace timestamps, for both processingStart and processingEnd we subtract
// the event timestamp (NOT event.ts, but the timeStamp millisecond value
// emitted in args.data), and then add that value to the event.ts. This
// will give us a processingStart and processingEnd time in microseconds
// that is relative to event.ts, and can be used when drawing boxes.
// There is some inaccuracy here as we are converting milliseconds to microseconds, but it is good enough until the backend emits more accurate numbers.
const processingStartRelativeToTraceTime = Types.Timing.Micro(
Helpers.Timing.milliToMicro(processingStart) - Helpers.Timing.milliToMicro(timeStamp) +
interactionStartEvent.ts,
);
const processingEndRelativeToTraceTime = Types.Timing.Micro(
(Helpers.Timing.milliToMicro(processingEnd) - Helpers.Timing.milliToMicro(timeStamp)) +
interactionStartEvent.ts);
// Ultimate frameId fallback only needed for TSC, see comments in the type.
const frameId = interactionStartEvent.args.frame ?? interactionStartEvent.args.data.frame ?? '';
const navigation = Helpers.Trace.getNavigationForTraceEvent(interactionStartEvent, frameId, navigationsByFrameId);
const navigationId = navigation?.args.data?.navigationId;
const interactionEvent =
Helpers.SyntheticEvents.SyntheticEventsManager.registerSyntheticEvent<Types.Events.SyntheticInteractionPair>({
// Use the start event to define the common fields.
rawSourceEvent: interactionStartEvent,
cat: interactionStartEvent.cat,
name: interactionStartEvent.name,
pid: interactionStartEvent.pid,
tid: interactionStartEvent.tid,
ph: interactionStartEvent.ph,
processingStart: processingStartRelativeToTraceTime,
processingEnd: processingEndRelativeToTraceTime,
// These will be set in writeSyntheticTimespans()
inputDelay: Types.Timing.Micro(-1),
mainThreadHandling: Types.Timing.Micro(-1),
presentationDelay: Types.Timing.Micro(-1),
args: {
data: {
beginEvent: interactionStartEvent,
endEvent,
frame: frameId,
navigationId,
},
},
ts: interactionStartEvent.ts,
dur: Types.Timing.Micro(endEvent.ts - interactionStartEvent.ts),
type: interactionStartEvent.args.data.type,
interactionId: interactionStartEvent.args.data.interactionId,
});
writeSyntheticTimespans(interactionEvent);
interactionEvents.push(interactionEvent);
}
interactionEventsWithNoNesting.push(...removeNestedInteractions(interactionEvents));
// Pick the longest interactions from the set that were not nested, as we
// know those are the set of the largest interactions.
for (const interactionEvent of interactionEventsWithNoNesting) {
if (!longestInteractionEvent || longestInteractionEvent.dur < interactionEvent.dur) {
longestInteractionEvent = interactionEvent;
}
}
}
export function data(): UserInteractionsData {
return {
allEvents,
beginCommitCompositorFrameEvents,
parseMetaViewportEvents,
interactionEvents,
interactionEventsWithNoNesting,
longestInteractionEvent,
interactionsOverThreshold: new Set(interactionEvents.filter(event => {
return event.dur > LONG_INTERACTION_THRESHOLD;
})),
};
}
export function deps(): HandlerName[] {
return ['Meta'];
}
/**
* Classifications sourced from
* https://web.dev/articles/inp#good-score
*/
export function scoreClassificationForInteractionToNextPaint(timing: Types.Timing.Micro): ScoreClassification {
if (timing <= INP_GOOD_TIMING) {
return ScoreClassification.GOOD;
}
if (timing <= INP_MEDIUM_TIMING) {
return ScoreClassification.OK;
}
return ScoreClassification.BAD;
}