Next.js website for Rocky Mountain Vending company featuring: - Product catalog with Stripe integration - Service areas and parts pages - Admin dashboard with Clerk authentication - SEO optimized pages with JSON-LD structured data Co-authored-by: Cursor <cursoragent@cursor.com>
1024 lines
39 KiB
Text
1024 lines
39 KiB
Text
/**
|
|
* @license
|
|
* Copyright 2017 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
/**
|
|
* @fileoverview Singluar helper to parse a raw trace and extract the most useful data for
|
|
* various tools. This artifact will take a trace and then:
|
|
*
|
|
* 1. Find the TracingStartedInPage and navigationStart events of our intended tab & frame.
|
|
* 2. Find the firstContentfulPaint and marked largestContentfulPaint events
|
|
* 3. Isolate only the trace events from the tab's process (including all threads like compositor)
|
|
* * Sort those trace events in chronological order (as order isn't guaranteed)
|
|
* 4. Return all those items in one handy bundle.
|
|
*/
|
|
|
|
/** @typedef {Omit<LH.Artifacts.NavigationTraceTimes, 'firstContentfulPaintAllFrames'|'traceEnd'>} TraceNavigationTimesForFrame */
|
|
/** @typedef {'lastNavigationStart'|'firstResourceSendRequest'|'lighthouseMarker'|'auto'} TimeOriginDeterminationMethod */
|
|
/** @typedef {Omit<LH.TraceEvent, 'name'|'args'> & {name: 'FrameCommittedInBrowser', args: {data: {frame: string, url: string, parent?: string}}}} FrameCommittedEvent */
|
|
/** @typedef {Omit<LH.TraceEvent, 'name'|'args'> & {name: 'largestContentfulPaint::Invalidate'|'largestContentfulPaint::Candidate', args: {data?: {size?: number}, frame: string}}} LCPEvent */
|
|
/** @typedef {Omit<LH.TraceEvent, 'name'|'args'> & {name: 'largestContentfulPaint::Candidate', args: {data: {size: number}, frame: string}}} LCPCandidateEvent */
|
|
|
|
import log from 'lighthouse-logger';
|
|
|
|
const ACCEPTABLE_NAVIGATION_URL_REGEX = /^(chrome|https?):/;
|
|
|
|
// The ideal input response latency, the time between the input task and the
|
|
// first frame of the response.
|
|
const BASE_RESPONSE_LATENCY = 16;
|
|
// COMPAT: m71+ We added RunTask to `disabled-by-default-lighthouse`
|
|
const SCHEDULABLE_TASK_TITLE_LH = 'RunTask';
|
|
// m69-70 DoWork is different and we now need RunTask, see https://bugs.chromium.org/p/chromium/issues/detail?id=871204#c11
|
|
const SCHEDULABLE_TASK_TITLE_ALT1 = 'ThreadControllerImpl::RunTask';
|
|
// In m66-68 refactored to this task title, https://crrev.com/c/883346
|
|
const SCHEDULABLE_TASK_TITLE_ALT2 = 'ThreadControllerImpl::DoWork';
|
|
// m65 and earlier
|
|
const SCHEDULABLE_TASK_TITLE_ALT3 = 'TaskQueueManager::ProcessTaskFromWorkQueue';
|
|
|
|
class TraceProcessor {
|
|
static get TIMESPAN_MARKER_ID() {
|
|
return '__lighthouseTimespanStart__';
|
|
}
|
|
|
|
/**
|
|
* @return {Error}
|
|
*/
|
|
static createNoNavstartError() {
|
|
return new Error('No navigationStart event found');
|
|
}
|
|
|
|
/**
|
|
* @return {Error}
|
|
*/
|
|
static createNoResourceSendRequestError() {
|
|
return new Error('No ResourceSendRequest event found');
|
|
}
|
|
|
|
/**
|
|
* @return {Error}
|
|
*/
|
|
static createNoTracingStartedError() {
|
|
return new Error('No tracingStartedInBrowser event found');
|
|
}
|
|
|
|
/**
|
|
* @return {Error}
|
|
*/
|
|
static createNoFirstContentfulPaintError() {
|
|
return new Error('No FirstContentfulPaint event found');
|
|
}
|
|
|
|
/**
|
|
* @return {Error}
|
|
*/
|
|
static createNoLighthouseMarkerError() {
|
|
return new Error('No Lighthouse timespan marker event found');
|
|
}
|
|
|
|
/**
|
|
* Returns true if the event is a navigation start event of a document whose URL seems valid.
|
|
*
|
|
* @param {LH.TraceEvent} event
|
|
* @return {boolean}
|
|
*/
|
|
static _isNavigationStartOfInterest(event) {
|
|
if (event.name !== 'navigationStart') return false;
|
|
// COMPAT: support pre-m67 test traces before `args.data` added to all navStart events.
|
|
// TODO: remove next line when old test traces (e.g. progressive-app-m60.json) are updated.
|
|
if (event.args.data?.documentLoaderURL === undefined) return true;
|
|
if (!event.args.data?.documentLoaderURL) return false;
|
|
return ACCEPTABLE_NAVIGATION_URL_REGEX.test(event.args.data.documentLoaderURL);
|
|
}
|
|
|
|
/**
|
|
* This method sorts a group of trace events that have the same timestamp. We want to...
|
|
*
|
|
* 1. Put E events first, we finish off our existing events before we start new ones.
|
|
* 2. Order B/X events by their duration, we want parents to start before child events.
|
|
* 3. If we don't have any of this to go on, just use the position in the original array (stable sort).
|
|
*
|
|
* Note that the typical group size with the same timestamp will be quite small (<10 or so events),
|
|
* and the number of groups typically ~1% of total trace, so the same ultra-performance-sensitive consideration
|
|
* given to functions that run on entire traces does not necessarily apply here.
|
|
*
|
|
* @param {number[]} tsGroupIndices
|
|
* @param {number[]} timestampSortedIndices
|
|
* @param {number} indexOfTsGroupIndicesStart
|
|
* @param {LH.TraceEvent[]} traceEvents
|
|
* @return {number[]}
|
|
*/
|
|
static _sortTimestampEventGroup(
|
|
tsGroupIndices,
|
|
timestampSortedIndices,
|
|
indexOfTsGroupIndicesStart,
|
|
traceEvents
|
|
) {
|
|
/*
|
|
* We have two different sets of indices going on here.
|
|
|
|
* 1. There's the index for an element of `traceEvents`, referred to here as an `ArrayIndex`.
|
|
* `timestampSortedIndices` is an array of `ArrayIndex` elements.
|
|
* 2. There's the index for an element of `timestampSortedIndices`, referred to here as a `TsIndex`.
|
|
* A `TsIndex` is therefore an index to an element which is itself an index.
|
|
*
|
|
* These two helper functions help resolve this layer of indirection.
|
|
* Our final return value is an array of `ArrayIndex` in their final sort order.
|
|
*/
|
|
/** @param {number} i */
|
|
const lookupArrayIndexByTsIndex = i => timestampSortedIndices[i];
|
|
/** @param {number} i */
|
|
const lookupEventByTsIndex = i => traceEvents[lookupArrayIndexByTsIndex(i)];
|
|
|
|
/** @type {Array<number>} */
|
|
const eEventIndices = [];
|
|
/** @type {Array<number>} */
|
|
const bxEventIndices = [];
|
|
/** @type {Array<number>} */
|
|
const otherEventIndices = [];
|
|
|
|
for (const tsIndex of tsGroupIndices) {
|
|
// See comment above for the distinction between `tsIndex` and `arrayIndex`.
|
|
const arrayIndex = lookupArrayIndexByTsIndex(tsIndex);
|
|
const event = lookupEventByTsIndex(tsIndex);
|
|
if (event.ph === 'E') eEventIndices.push(arrayIndex);
|
|
else if (event.ph === 'X' || event.ph === 'B') bxEventIndices.push(arrayIndex);
|
|
else otherEventIndices.push(arrayIndex);
|
|
}
|
|
|
|
/** @type {Map<number, number>} */
|
|
const effectiveDuration = new Map();
|
|
for (const index of bxEventIndices) {
|
|
const event = traceEvents[index];
|
|
if (event.ph === 'X') {
|
|
effectiveDuration.set(index, event.dur);
|
|
} else {
|
|
// Find the next available 'E' event *after* the current group of events that matches our name, pid, and tid.
|
|
let duration = Number.MAX_SAFE_INTEGER;
|
|
// To find the next "available" 'E' event, we need to account for nested events of the same name.
|
|
let additionalNestedEventsWithSameName = 0;
|
|
const startIndex = indexOfTsGroupIndicesStart + tsGroupIndices.length;
|
|
for (let j = startIndex; j < timestampSortedIndices.length; j++) {
|
|
const potentialMatchingEvent = lookupEventByTsIndex(j);
|
|
const eventMatches = potentialMatchingEvent.name === event.name &&
|
|
potentialMatchingEvent.pid === event.pid &&
|
|
potentialMatchingEvent.tid === event.tid;
|
|
|
|
// The event doesn't match, just skip it.
|
|
if (!eventMatches) continue;
|
|
|
|
if (potentialMatchingEvent.ph === 'E' && additionalNestedEventsWithSameName === 0) {
|
|
// It's the next available 'E' event for us, so set the duration and break the loop.
|
|
duration = potentialMatchingEvent.ts - event.ts;
|
|
break;
|
|
} else if (potentialMatchingEvent.ph === 'E') {
|
|
// It's an 'E' event but for a nested event. Decrement our counter and move on.
|
|
additionalNestedEventsWithSameName--;
|
|
} else if (potentialMatchingEvent.ph === 'B') {
|
|
// It's a nested 'B' event. Increment our counter and move on.
|
|
additionalNestedEventsWithSameName++;
|
|
}
|
|
}
|
|
|
|
effectiveDuration.set(index, duration);
|
|
}
|
|
}
|
|
|
|
bxEventIndices.sort((indexA, indexB) => ((effectiveDuration.get(indexB) || 0) -
|
|
(effectiveDuration.get(indexA) || 0) || (indexA - indexB)));
|
|
|
|
otherEventIndices.sort((indexA, indexB) => indexA - indexB);
|
|
|
|
return [...eEventIndices, ...bxEventIndices, ...otherEventIndices];
|
|
}
|
|
|
|
/**
|
|
* Sorts and filters trace events by timestamp and respecting the nesting structure inherent to
|
|
* parent/child event relationships.
|
|
*
|
|
* @param {LH.TraceEvent[]} traceEvents
|
|
* @param {(e: LH.TraceEvent) => boolean} filter
|
|
*/
|
|
static filteredTraceSort(traceEvents, filter) {
|
|
// create an array of the indices that we want to keep
|
|
const indices = [];
|
|
for (let srcIndex = 0; srcIndex < traceEvents.length; srcIndex++) {
|
|
if (filter(traceEvents[srcIndex])) {
|
|
indices.push(srcIndex);
|
|
}
|
|
}
|
|
|
|
// Sort by ascending timestamp first.
|
|
indices.sort((indexA, indexB) => traceEvents[indexA].ts - traceEvents[indexB].ts);
|
|
|
|
// Now we find groups with equal timestamps and order them by their nesting structure.
|
|
for (let i = 0; i < indices.length - 1; i++) {
|
|
const ts = traceEvents[indices[i]].ts;
|
|
const tsGroupIndices = [i];
|
|
for (let j = i + 1; j < indices.length; j++) {
|
|
if (traceEvents[indices[j]].ts !== ts) break;
|
|
tsGroupIndices.push(j);
|
|
}
|
|
|
|
// We didn't find any other events with the same timestamp, just keep going.
|
|
if (tsGroupIndices.length === 1) continue;
|
|
|
|
// Sort the group by other criteria and replace our index array with it.
|
|
const finalIndexOrder = TraceProcessor._sortTimestampEventGroup(
|
|
tsGroupIndices,
|
|
indices,
|
|
i,
|
|
traceEvents
|
|
);
|
|
indices.splice(i, finalIndexOrder.length, ...finalIndexOrder);
|
|
// We just sorted this set of identical timestamps, so skip over the rest of the group.
|
|
// -1 because we already have i++.
|
|
i += tsGroupIndices.length - 1;
|
|
}
|
|
|
|
// create a new array using the target indices from previous sort step
|
|
const sorted = [];
|
|
for (let i = 0; i < indices.length; i++) {
|
|
sorted.push(traceEvents[indices[i]]);
|
|
}
|
|
|
|
return sorted;
|
|
}
|
|
|
|
/**
|
|
* There should *always* be at least one top level event, having 0 typically means something is
|
|
* drastically wrong with the trace and we should just give up early and loudly.
|
|
*
|
|
* @param {LH.TraceEvent[]} events
|
|
*/
|
|
static assertHasToplevelEvents(events) {
|
|
const hasToplevelTask = events.some(this.isScheduleableTask);
|
|
if (!hasToplevelTask) {
|
|
throw new Error('Could not find any top level events');
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* Calculate duration at specified percentiles for given population of
|
|
* durations.
|
|
* If one of the durations overlaps the end of the window, the full
|
|
* duration should be in the duration array, but the length not included
|
|
* within the window should be given as `clippedLength`. For instance, if a
|
|
* 50ms duration occurs 10ms before the end of the window, `50` should be in
|
|
* the `durations` array, and `clippedLength` should be set to 40.
|
|
* @see https://docs.google.com/document/d/1b9slyaB9yho91YTOkAQfpCdULFkZM9LqsipcX3t7He8/preview
|
|
* @param {!Array<number>} durations Array of durations, sorted in ascending order.
|
|
* @param {number} totalTime Total time (in ms) of interval containing durations.
|
|
* @param {!Array<number>} percentiles Array of percentiles of interest, in ascending order.
|
|
* @param {number=} clippedLength Optional length clipped from a duration overlapping end of window. Default of 0.
|
|
* @return {!Array<{percentile: number, time: number}>}
|
|
* @private
|
|
*/
|
|
static _riskPercentiles(durations, totalTime, percentiles, clippedLength = 0) {
|
|
let busyTime = 0;
|
|
for (let i = 0; i < durations.length; i++) {
|
|
busyTime += durations[i];
|
|
}
|
|
busyTime -= clippedLength;
|
|
|
|
// Start with idle time already complete.
|
|
let completedTime = totalTime - busyTime;
|
|
let duration = 0;
|
|
let cdfTime = completedTime;
|
|
const results = [];
|
|
|
|
let durationIndex = -1;
|
|
let remainingCount = durations.length + 1;
|
|
if (clippedLength > 0) {
|
|
// If there was a clipped duration, one less in count since one hasn't started yet.
|
|
remainingCount--;
|
|
}
|
|
|
|
// Find percentiles of interest, in order.
|
|
for (const percentile of percentiles) {
|
|
// Loop over durations, calculating a CDF value for each until it is above
|
|
// the target percentile.
|
|
const percentileTime = percentile * totalTime;
|
|
while (cdfTime < percentileTime && durationIndex < durations.length - 1) {
|
|
completedTime += duration;
|
|
remainingCount -= (duration < 0 ? -1 : 1);
|
|
|
|
if (clippedLength > 0 && clippedLength < durations[durationIndex + 1]) {
|
|
duration = -clippedLength;
|
|
clippedLength = 0;
|
|
} else {
|
|
durationIndex++;
|
|
duration = durations[durationIndex];
|
|
}
|
|
|
|
// Calculate value of CDF (multiplied by totalTime) for the end of this duration.
|
|
cdfTime = completedTime + Math.abs(duration) * remainingCount;
|
|
}
|
|
|
|
// Negative results are within idle time (0ms wait by definition), so clamp at zero.
|
|
results.push({
|
|
percentile,
|
|
time: Math.max(0, (percentileTime - completedTime) / remainingCount) +
|
|
BASE_RESPONSE_LATENCY,
|
|
});
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* Calculates the maximum queueing time (in ms) of high priority tasks for
|
|
* selected percentiles within a window of the main thread.
|
|
* @see https://docs.google.com/document/d/1b9slyaB9yho91YTOkAQfpCdULFkZM9LqsipcX3t7He8/preview
|
|
* @param {Array<ToplevelEvent>} events
|
|
* @param {number} startTime Start time (in ms relative to timeOrigin) of range of interest.
|
|
* @param {number} endTime End time (in ms relative to timeOrigin) of range of interest.
|
|
* @param {!Array<number>=} percentiles Optional array of percentiles to compute. Defaults to [0.5, 0.75, 0.9, 0.99, 1].
|
|
* @return {!Array<{percentile: number, time: number}>}
|
|
*/
|
|
static getRiskToResponsiveness(
|
|
events,
|
|
startTime,
|
|
endTime,
|
|
percentiles = [0.5, 0.75, 0.9, 0.99, 1]
|
|
) {
|
|
const totalTime = endTime - startTime;
|
|
percentiles.sort((a, b) => a - b);
|
|
|
|
const ret = this.getMainThreadTopLevelEventDurations(events, startTime, endTime);
|
|
return this._riskPercentiles(ret.durations, totalTime, percentiles,
|
|
ret.clippedLength);
|
|
}
|
|
|
|
/**
|
|
* Provides durations in ms of all main thread top-level events
|
|
* @param {Array<ToplevelEvent>} topLevelEvents
|
|
* @param {number} startTime Optional start time (in ms relative to timeOrigin) of range of interest. Defaults to 0.
|
|
* @param {number} endTime Optional end time (in ms relative to timeOrigin) of range of interest. Defaults to trace end.
|
|
* @return {{durations: Array<number>, clippedLength: number}}
|
|
*/
|
|
static getMainThreadTopLevelEventDurations(topLevelEvents, startTime = 0, endTime = Infinity) {
|
|
// Find durations of all slices in range of interest.
|
|
/** @type {Array<number>} */
|
|
const durations = [];
|
|
let clippedLength = 0;
|
|
|
|
for (const event of topLevelEvents) {
|
|
if (event.end < startTime || event.start > endTime) {
|
|
continue;
|
|
}
|
|
|
|
let duration = event.duration;
|
|
let eventStart = event.start;
|
|
if (eventStart < startTime) {
|
|
// Any part of task before window can be discarded.
|
|
eventStart = startTime;
|
|
duration = event.end - startTime;
|
|
}
|
|
|
|
if (event.end > endTime) {
|
|
// Any part of task after window must be clipped but accounted for.
|
|
clippedLength = duration - (endTime - eventStart);
|
|
}
|
|
|
|
durations.push(duration);
|
|
}
|
|
durations.sort((a, b) => a - b);
|
|
|
|
return {
|
|
durations,
|
|
clippedLength,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Provides the top level events on the main thread with timestamps in ms relative to timeOrigin.
|
|
* start.
|
|
* @param {LH.Artifacts.ProcessedTrace} trace
|
|
* @param {number=} startTime Optional start time (in ms relative to timeOrigin) of range of interest. Defaults to 0.
|
|
* @param {number=} endTime Optional end time (in ms relative to timeOrigin) of range of interest. Defaults to trace end.
|
|
* @return {Array<ToplevelEvent>}
|
|
*/
|
|
static getMainThreadTopLevelEvents(trace, startTime = 0, endTime = Infinity) {
|
|
const topLevelEvents = [];
|
|
/** @type {ToplevelEvent|undefined} */
|
|
let prevToplevel = undefined;
|
|
|
|
// note: mainThreadEvents is already sorted by event start
|
|
for (const event of trace.mainThreadEvents) {
|
|
if (!this.isScheduleableTask(event) || !event.dur) continue;
|
|
|
|
const start = (event.ts - trace.timeOriginEvt.ts) / 1000;
|
|
const end = (event.ts + event.dur - trace.timeOriginEvt.ts) / 1000;
|
|
if (start > endTime || end < startTime) continue;
|
|
|
|
// Temporary fix for a Chrome bug where some RunTask events can be overlapping.
|
|
// We correct that here be ensuring each RunTask ends at least 1 microsecond before the next
|
|
// https://github.com/GoogleChrome/lighthouse/issues/15896
|
|
// https://issues.chromium.org/issues/329678173
|
|
if (prevToplevel && start < prevToplevel.end) {
|
|
prevToplevel.end = start - 0.001;
|
|
}
|
|
|
|
prevToplevel = {
|
|
start,
|
|
end,
|
|
duration: event.dur / 1000,
|
|
};
|
|
|
|
topLevelEvents.push(prevToplevel);
|
|
}
|
|
|
|
return topLevelEvents;
|
|
}
|
|
|
|
/**
|
|
* @param {LH.TraceEvent[]} events
|
|
* @return {{startingPid: number, frameId: string}}
|
|
*/
|
|
static findMainFrameIds(events) {
|
|
// Prefer the newer TracingStartedInBrowser event first, if it exists
|
|
const startedInBrowserEvt = events.find(e => e.name === 'TracingStartedInBrowser');
|
|
if (startedInBrowserEvt?.args.data?.frames) {
|
|
const mainFrame = startedInBrowserEvt.args.data.frames.find(frame => !frame.parent);
|
|
const frameId = mainFrame?.frame;
|
|
const pid = mainFrame?.processId;
|
|
|
|
if (pid && frameId) {
|
|
return {
|
|
startingPid: pid,
|
|
frameId,
|
|
};
|
|
}
|
|
}
|
|
|
|
// Support legacy browser versions that do not emit TracingStartedInBrowser event.
|
|
// The first TracingStartedInPage in the trace is definitely our renderer thread of interest
|
|
// Beware: the tracingStartedInPage event can appear slightly after a navigationStart
|
|
const startedInPageEvt = events.find(e => e.name === 'TracingStartedInPage');
|
|
if (startedInPageEvt?.args?.data) {
|
|
const frameId = startedInPageEvt.args.data.page;
|
|
if (frameId) {
|
|
return {
|
|
startingPid: startedInPageEvt.pid,
|
|
frameId,
|
|
};
|
|
}
|
|
}
|
|
|
|
// Support the case where everything else fails, see https://github.com/GoogleChrome/lighthouse/issues/7118.
|
|
// If we can't find either TracingStarted event, then we'll fallback to the first navStart that
|
|
// looks like it was loading the main frame with a real URL. Because the schema for this event
|
|
// has changed across Chrome versions, we'll be extra defensive about finding this case.
|
|
const navStartEvt = events.find(e =>
|
|
this._isNavigationStartOfInterest(e) && e.args.data?.isLoadingMainFrame
|
|
);
|
|
// Find the first resource that was requested and make sure it agrees on the id.
|
|
const firstResourceSendEvt = events.find(e => e.name === 'ResourceSendRequest');
|
|
// We know that these properties exist if we found the events, but TSC doesn't.
|
|
if (navStartEvt?.args?.data &&
|
|
firstResourceSendEvt &&
|
|
firstResourceSendEvt.pid === navStartEvt.pid &&
|
|
firstResourceSendEvt.tid === navStartEvt.tid) {
|
|
const frameId = navStartEvt.args.frame;
|
|
if (frameId) {
|
|
return {
|
|
startingPid: navStartEvt.pid,
|
|
frameId,
|
|
};
|
|
}
|
|
}
|
|
|
|
throw this.createNoTracingStartedError();
|
|
}
|
|
|
|
/**
|
|
* If there were any cross-origin navigations, there'll be more than one pid returned
|
|
* @param {{startingPid: number, frameId: string}} mainFrameInfo
|
|
* @param {LH.TraceEvent[]} keyEvents
|
|
* @return {Map<number, number>} Map where keys are process IDs and their values are thread IDs
|
|
*/
|
|
static findMainFramePidTids(mainFrameInfo, keyEvents) {
|
|
const frameProcessEvts = keyEvents.filter(evt =>
|
|
// ProcessReadyInBrowser is used when a processID isn't available when the FrameCommittedInBrowser trace event is emitted.
|
|
// In that case. FrameCommittedInBrowser has no processId, but a processPseudoId. and the ProcessReadyInBrowser event declares the proper processId.
|
|
(evt.name === 'FrameCommittedInBrowser' || evt.name === 'ProcessReadyInBrowser') &&
|
|
evt.args?.data?.frame === mainFrameInfo.frameId &&
|
|
evt?.args?.data?.processId
|
|
);
|
|
|
|
// "Modern" traces with a navigation have a FrameCommittedInBrowser event
|
|
const mainFramePids = frameProcessEvts.length
|
|
? frameProcessEvts.map(e => e?.args?.data?.processId)
|
|
// …But old traces and some timespan traces may not. In these situations, we'll assume the
|
|
// primary process ID remains constant (as there were no navigations).
|
|
: [mainFrameInfo.startingPid];
|
|
|
|
const pidToTid = new Map();
|
|
|
|
for (const pid of new Set(mainFramePids)) {
|
|
const threadEvents = keyEvents.filter(e =>
|
|
e.cat === '__metadata' &&
|
|
e.pid === pid &&
|
|
e.ph === 'M' &&
|
|
e.name === 'thread_name'
|
|
);
|
|
|
|
// While renderer tids are generally predictable, we'll doublecheck it
|
|
let threadNameEvt = threadEvents.find(e => e.args.name === 'CrRendererMain');
|
|
|
|
// `CrRendererMain` can be missing if chrome is launched with the `--single-process` flag.
|
|
// In this case, page tasks will be run in the browser thread.
|
|
if (!threadNameEvt) {
|
|
threadNameEvt = threadEvents.find(e => e.args.name === 'CrBrowserMain');
|
|
}
|
|
|
|
const tid = threadNameEvt?.tid;
|
|
|
|
if (!tid) {
|
|
throw new Error('Unable to determine tid for renderer process');
|
|
}
|
|
|
|
pidToTid.set(pid, tid);
|
|
}
|
|
return pidToTid;
|
|
}
|
|
|
|
/**
|
|
* @param {LH.TraceEvent} evt
|
|
* @return {boolean}
|
|
*/
|
|
static isScheduleableTask(evt) {
|
|
return evt.name === SCHEDULABLE_TASK_TITLE_LH ||
|
|
evt.name === SCHEDULABLE_TASK_TITLE_ALT1 ||
|
|
evt.name === SCHEDULABLE_TASK_TITLE_ALT2 ||
|
|
evt.name === SCHEDULABLE_TASK_TITLE_ALT3;
|
|
}
|
|
|
|
/**
|
|
* @param {LH.TraceEvent} evt
|
|
* @return {evt is LCPEvent}
|
|
*/
|
|
static isLCPEvent(evt) {
|
|
if (evt.name !== 'largestContentfulPaint::Invalidate' &&
|
|
evt.name !== 'largestContentfulPaint::Candidate') return false;
|
|
return Boolean(evt.args?.frame);
|
|
}
|
|
|
|
/**
|
|
* @param {LH.TraceEvent} evt
|
|
* @return {evt is LCPCandidateEvent}
|
|
*/
|
|
static isLCPCandidateEvent(evt) {
|
|
return Boolean(
|
|
evt.name === 'largestContentfulPaint::Candidate' &&
|
|
evt.args?.frame &&
|
|
evt.args.data &&
|
|
evt.args.data.size !== undefined
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The associated frame ID is set in different locations for different trace events.
|
|
* This function checks all known locations for the frame ID and returns `undefined` if it's not found.
|
|
*
|
|
* @param {LH.TraceEvent} evt
|
|
* @return {string|undefined}
|
|
*/
|
|
static getFrameId(evt) {
|
|
return evt.args?.data?.frame ||
|
|
evt.args.data?.frameID ||
|
|
evt.args.frame;
|
|
}
|
|
|
|
/**
|
|
* Returns the maximum LCP event across all frames in `events`.
|
|
* Sets `invalidated` flag if LCP of every frame is invalidated.
|
|
*
|
|
* LCP's trace event was first introduced in m78. We can't surface an LCP for older Chrome versions.
|
|
* LCP comes from a frame's latest `largestContentfulPaint::Candidate`, but it can be invalidated by a `largestContentfulPaint::Invalidate` event.
|
|
*
|
|
* @param {LH.TraceEvent[]} events
|
|
* @param {LH.TraceEvent} timeOriginEvent
|
|
* @return {{lcp: LCPEvent | undefined, invalidated: boolean}}
|
|
*/
|
|
static computeValidLCPAllFrames(events, timeOriginEvent) {
|
|
const lcpEvents = events.filter(this.isLCPEvent).reverse();
|
|
|
|
/** @type {Map<string, LCPEvent>} */
|
|
const finalLcpEventsByFrame = new Map();
|
|
for (const e of lcpEvents) {
|
|
if (e.ts <= timeOriginEvent.ts) break;
|
|
|
|
// Already found final LCP state of this frame.
|
|
const frame = e.args.frame;
|
|
if (finalLcpEventsByFrame.has(frame)) continue;
|
|
|
|
finalLcpEventsByFrame.set(frame, e);
|
|
}
|
|
|
|
/** @type {LCPCandidateEvent | undefined} */
|
|
let maxLcpAcrossFrames;
|
|
for (const lcp of finalLcpEventsByFrame.values()) {
|
|
if (!this.isLCPCandidateEvent(lcp)) continue;
|
|
if (!maxLcpAcrossFrames || lcp.args.data.size > maxLcpAcrossFrames.args.data.size) {
|
|
maxLcpAcrossFrames = lcp;
|
|
}
|
|
}
|
|
|
|
return {
|
|
lcp: maxLcpAcrossFrames,
|
|
// LCP events were found, but final LCP event of every frame was an invalidate event.
|
|
invalidated: Boolean(!maxLcpAcrossFrames && finalLcpEventsByFrame.size),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param {Array<{id: string, url: string, parent?: string}>} frames
|
|
* @return {Map<string, string>}
|
|
*/
|
|
static resolveRootFrames(frames) {
|
|
/** @type {Map<string, string>} */
|
|
const parentFrames = new Map();
|
|
for (const frame of frames) {
|
|
if (!frame.parent) continue;
|
|
parentFrames.set(frame.id, frame.parent);
|
|
}
|
|
|
|
/** @type {Map<string, string>} */
|
|
const frameIdToRootFrameId = new Map();
|
|
for (const frame of frames) {
|
|
let cur = frame.id;
|
|
while (parentFrames.has(cur)) {
|
|
cur = /** @type {string} */ (parentFrames.get(cur));
|
|
}
|
|
if (cur === undefined) {
|
|
throw new Error('Unexpected undefined frameId');
|
|
}
|
|
frameIdToRootFrameId.set(frame.id, cur);
|
|
}
|
|
|
|
return frameIdToRootFrameId;
|
|
}
|
|
|
|
/**
|
|
* Finds key trace events, identifies main process/thread, and returns timings of trace events
|
|
* in milliseconds since the time origin in addition to the standard microsecond monotonic timestamps.
|
|
* @param {LH.Trace} trace
|
|
* @param {{timeOriginDeterminationMethod?: TimeOriginDeterminationMethod}} [options]
|
|
* @return {LH.Artifacts.ProcessedTrace}
|
|
*/
|
|
static processTrace(trace, options) {
|
|
const {timeOriginDeterminationMethod = 'auto'} = options || {};
|
|
|
|
// Parse the trace for our key events and sort them by timestamp. Note: sort
|
|
// *must* be stable to keep events correctly nested.
|
|
const keyEvents = this.filteredTraceSort(trace.traceEvents, e => {
|
|
return e.cat.includes('blink.user_timing') ||
|
|
e.cat.includes('loading') ||
|
|
e.cat.includes('devtools.timeline') ||
|
|
e.cat === '__metadata';
|
|
});
|
|
|
|
// Find the inspected frame
|
|
const mainFrameInfo = this.findMainFrameIds(keyEvents);
|
|
const rendererPidToTid = this.findMainFramePidTids(mainFrameInfo, keyEvents);
|
|
|
|
// Subset all trace events to just our tab's process (incl threads other than main)
|
|
// stable-sort events to keep them correctly nested.
|
|
const processEvents = TraceProcessor
|
|
.filteredTraceSort(trace.traceEvents, e => rendererPidToTid.has(e.pid));
|
|
|
|
// TODO(paulirish): filter down frames (and subsequent actions) to the primary process tree & frame tree
|
|
|
|
/** @type {Map<string, {id: string, url: string, parent?: string}>} */
|
|
const framesById = new Map();
|
|
|
|
// Begin collection of frame tree information with TracingStartedInBrowser,
|
|
// which should be present even without navigations.
|
|
const tracingStartedFrames = keyEvents
|
|
.find(e => e.name === 'TracingStartedInBrowser')?.args?.data?.frames;
|
|
if (tracingStartedFrames) {
|
|
for (const frame of tracingStartedFrames) {
|
|
framesById.set(frame.frame, {
|
|
id: frame.frame,
|
|
url: frame.url,
|
|
parent: frame.parent,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Update known frames if FrameCommittedInBrowser events come in, typically
|
|
// with updated `url`, as well as pid, etc. Some traces (like timespans) may
|
|
// not have any committed frames.
|
|
keyEvents
|
|
.filter(/** @return {evt is FrameCommittedEvent} */ evt => {
|
|
return Boolean(
|
|
evt.name === 'FrameCommittedInBrowser' &&
|
|
evt.args.data?.frame &&
|
|
evt.args.data.url !== undefined
|
|
);
|
|
}).forEach(evt => {
|
|
framesById.set(evt.args.data.frame, {
|
|
id: evt.args.data.frame,
|
|
url: evt.args.data.url,
|
|
parent: evt.args.data.parent,
|
|
});
|
|
});
|
|
|
|
const frames = [...framesById.values()];
|
|
const frameIdToRootFrameId = this.resolveRootFrames(frames);
|
|
|
|
const inspectedTreeFrameIds = [...frameIdToRootFrameId.entries()]
|
|
.filter(([, rootFrameId]) => rootFrameId === mainFrameInfo.frameId)
|
|
.map(([child]) => child);
|
|
|
|
// Filter to just events matching the main frame ID, just to make sure.
|
|
/** @param {LH.TraceEvent} e */
|
|
function associatedToMainFrame(e) {
|
|
const frameId = TraceProcessor.getFrameId(e);
|
|
return frameId === mainFrameInfo.frameId;
|
|
}
|
|
|
|
/** @param {LH.TraceEvent} e */
|
|
function associatedToAllFrames(e) {
|
|
const frameId = TraceProcessor.getFrameId(e);
|
|
return frameId ? inspectedTreeFrameIds.includes(frameId) : false;
|
|
}
|
|
const frameEvents = keyEvents.filter(e => associatedToMainFrame(e));
|
|
|
|
// Filter to just events matching the main frame ID or any child frame IDs. The subframes
|
|
// are either in-process (same origin) or, potentially, out-of-process. (OOPIFs)
|
|
let frameTreeEvents = [];
|
|
if (frameIdToRootFrameId.has(mainFrameInfo.frameId)) {
|
|
frameTreeEvents = keyEvents.filter(e => associatedToAllFrames(e));
|
|
} else {
|
|
// In practice, there should always be TracingStartedInBrowser/FrameCommittedInBrowser events to
|
|
// define the frame tree. Unfortunately, many test traces do not that frame info due to minification.
|
|
// This ensures there is always a minimal frame tree and events so those tests don't fail.
|
|
log.warn(
|
|
'TraceProcessor',
|
|
'frameTreeEvents may be incomplete, make sure the trace has frame events'
|
|
);
|
|
frameIdToRootFrameId.set(mainFrameInfo.frameId, mainFrameInfo.frameId);
|
|
frameTreeEvents = frameEvents;
|
|
}
|
|
|
|
// Compute our time origin to use for all relative timings.
|
|
const timeOriginEvt = this.computeTimeOrigin(
|
|
{keyEvents, frameEvents, mainFrameInfo: mainFrameInfo},
|
|
timeOriginDeterminationMethod
|
|
);
|
|
|
|
const mainThreadEvents = processEvents.filter(e => e.tid === rendererPidToTid.get(e.pid));
|
|
|
|
// Ensure our traceEnd reflects all page activity.
|
|
const traceEnd = this.computeTraceEnd(trace.traceEvents, timeOriginEvt);
|
|
|
|
// This could be much more concise with object spread, but the consensus is that explicitness is
|
|
// preferred over brevity here.
|
|
return {
|
|
frames,
|
|
mainThreadEvents,
|
|
frameEvents,
|
|
frameTreeEvents,
|
|
processEvents,
|
|
mainFrameInfo,
|
|
timeOriginEvt,
|
|
timings: {
|
|
timeOrigin: 0,
|
|
traceEnd: traceEnd.timing,
|
|
},
|
|
timestamps: {
|
|
timeOrigin: timeOriginEvt.ts,
|
|
traceEnd: traceEnd.timestamp,
|
|
},
|
|
_keyEvents: keyEvents,
|
|
_rendererPidToTid: rendererPidToTid,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Finds key navigation trace events and computes timings of events in milliseconds since the time
|
|
* origin in addition to the standard microsecond monotonic timestamps.
|
|
* @param {LH.Artifacts.ProcessedTrace} processedTrace
|
|
* @return {LH.Artifacts.ProcessedNavigation}
|
|
*/
|
|
static processNavigation(processedTrace) {
|
|
const {frameEvents, frameTreeEvents, timeOriginEvt, timings, timestamps} = processedTrace;
|
|
|
|
// Compute the key frame timings for the main frame.
|
|
const frameTimings = this.computeNavigationTimingsForFrame(frameEvents, {timeOriginEvt});
|
|
|
|
// Compute FCP for all frames.
|
|
const fcpAllFramesEvt = frameTreeEvents.find(
|
|
e => e.name === 'firstContentfulPaint' && e.ts > timeOriginEvt.ts
|
|
);
|
|
|
|
if (!fcpAllFramesEvt) {
|
|
throw this.createNoFirstContentfulPaintError();
|
|
}
|
|
|
|
// Compute LCP for all frames.
|
|
const lcpAllFramesEvt = this.computeValidLCPAllFrames(frameTreeEvents, timeOriginEvt).lcp;
|
|
|
|
/** @param {number} ts */
|
|
const getTiming = ts => (ts - timeOriginEvt.ts) / 1000;
|
|
/** @param {number=} ts */
|
|
const maybeGetTiming = (ts) => ts === undefined ? undefined : getTiming(ts);
|
|
|
|
return {
|
|
timings: {
|
|
timeOrigin: timings.timeOrigin,
|
|
firstPaint: frameTimings.timings.firstPaint,
|
|
firstContentfulPaint: frameTimings.timings.firstContentfulPaint,
|
|
firstContentfulPaintAllFrames: getTiming(fcpAllFramesEvt.ts),
|
|
largestContentfulPaint: frameTimings.timings.largestContentfulPaint,
|
|
largestContentfulPaintAllFrames: maybeGetTiming(lcpAllFramesEvt?.ts),
|
|
load: frameTimings.timings.load,
|
|
domContentLoaded: frameTimings.timings.domContentLoaded,
|
|
traceEnd: timings.traceEnd,
|
|
},
|
|
timestamps: {
|
|
timeOrigin: timestamps.timeOrigin,
|
|
firstPaint: frameTimings.timestamps.firstPaint,
|
|
firstContentfulPaint: frameTimings.timestamps.firstContentfulPaint,
|
|
firstContentfulPaintAllFrames: fcpAllFramesEvt.ts,
|
|
largestContentfulPaint: frameTimings.timestamps.largestContentfulPaint,
|
|
largestContentfulPaintAllFrames: lcpAllFramesEvt?.ts,
|
|
load: frameTimings.timestamps.load,
|
|
domContentLoaded: frameTimings.timestamps.domContentLoaded,
|
|
traceEnd: timestamps.traceEnd,
|
|
},
|
|
firstPaintEvt: frameTimings.firstPaintEvt,
|
|
firstContentfulPaintEvt: frameTimings.firstContentfulPaintEvt,
|
|
firstContentfulPaintAllFramesEvt: fcpAllFramesEvt,
|
|
largestContentfulPaintEvt: frameTimings.largestContentfulPaintEvt,
|
|
largestContentfulPaintAllFramesEvt: lcpAllFramesEvt,
|
|
loadEvt: frameTimings.loadEvt,
|
|
domContentLoadedEvt: frameTimings.domContentLoadedEvt,
|
|
lcpInvalidated: frameTimings.lcpInvalidated,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Computes the last observable timestamp in a set of trace events.
|
|
*
|
|
* @param {Array<LH.TraceEvent>} events
|
|
* @param {LH.TraceEvent} timeOriginEvt
|
|
* @return {{timing: number, timestamp: number}}
|
|
*/
|
|
static computeTraceEnd(events, timeOriginEvt) {
|
|
let maxTs = -Infinity;
|
|
for (const event of events) {
|
|
maxTs = Math.max(event.ts + (event.dur || 0), maxTs);
|
|
}
|
|
|
|
return {timestamp: maxTs, timing: (maxTs - timeOriginEvt.ts) / 1000};
|
|
}
|
|
|
|
/**
|
|
* Computes the time origin using the specified method.
|
|
*
|
|
* - firstResourceSendRequest
|
|
* Uses the time that the very first network request is sent in the main frame.
|
|
* Eventually should be used in place of lastNavigationStart as the default for navigations.
|
|
* This method includes the cost of all redirects when evaluating a navigation (which matches lantern behavior).
|
|
* The only difference between firstResourceSendRequest and the first `navigationStart` is
|
|
* the unload time of `about:blank` (which is a Lighthouse implementation detail and shouldn't be included).
|
|
*
|
|
* - lastNavigationStart
|
|
* Uses the time of the last `navigationStart` event in the main frame.
|
|
* The historical time origin of Lighthouse from 2016-Present.
|
|
* This method excludes the cost of client-side redirects when evaluating a navigation.
|
|
* Can also be skewed by several hundred milliseconds or even seconds when the browser takes a long
|
|
* time to unload `about:blank`.
|
|
*
|
|
* @param {{keyEvents: Array<LH.TraceEvent>, frameEvents: Array<LH.TraceEvent>, mainFrameInfo: {frameId: string}}} traceEventSubsets
|
|
* @param {TimeOriginDeterminationMethod} method
|
|
* @return {LH.TraceEvent}
|
|
*/
|
|
static computeTimeOrigin(traceEventSubsets, method) {
|
|
const lastNavigationStart = () => {
|
|
// Our time origin will be the last frame navigation in the trace
|
|
const frameEvents = traceEventSubsets.frameEvents;
|
|
return frameEvents.filter(this._isNavigationStartOfInterest).pop();
|
|
};
|
|
|
|
const lighthouseMarker = () => {
|
|
const frameEvents = traceEventSubsets.keyEvents;
|
|
return frameEvents.find(
|
|
evt =>
|
|
evt.name === 'clock_sync' &&
|
|
evt.args.sync_id === TraceProcessor.TIMESPAN_MARKER_ID
|
|
);
|
|
};
|
|
|
|
switch (method) {
|
|
case 'firstResourceSendRequest': {
|
|
// Our time origin will be the timestamp of the first request that's sent in the frame.
|
|
const fetchStart = traceEventSubsets.keyEvents.find(event => {
|
|
if (event.name !== 'ResourceSendRequest') return false;
|
|
const data = event.args.data || {};
|
|
return data.frame === traceEventSubsets.mainFrameInfo.frameId;
|
|
});
|
|
if (!fetchStart) throw this.createNoResourceSendRequestError();
|
|
return fetchStart;
|
|
}
|
|
case 'lastNavigationStart': {
|
|
const navigationStart = lastNavigationStart();
|
|
if (!navigationStart) throw this.createNoNavstartError();
|
|
return navigationStart;
|
|
}
|
|
case 'lighthouseMarker': {
|
|
const marker = lighthouseMarker();
|
|
if (!marker) throw this.createNoLighthouseMarkerError();
|
|
return marker;
|
|
}
|
|
case 'auto': {
|
|
const marker = lighthouseMarker() || lastNavigationStart();
|
|
if (!marker) throw this.createNoNavstartError();
|
|
return marker;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Computes timings of trace events of key trace events in milliseconds since the time origin
|
|
* in addition to the standard microsecond monotonic timestamps.
|
|
* @param {Array<LH.TraceEvent>} frameEvents
|
|
* @param {{timeOriginEvt: LH.TraceEvent}} options
|
|
*/
|
|
static computeNavigationTimingsForFrame(frameEvents, options) {
|
|
const {timeOriginEvt} = options;
|
|
|
|
// Find our first paint of this frame
|
|
const firstPaint = frameEvents.find(e => e.name === 'firstPaint' && e.ts > timeOriginEvt.ts);
|
|
|
|
// FCP will follow at/after the FP. Used in so many places we require it.
|
|
const firstContentfulPaint = frameEvents.find(
|
|
e => e.name === 'firstContentfulPaint' && e.ts > timeOriginEvt.ts
|
|
);
|
|
|
|
if (!firstContentfulPaint) {
|
|
throw this.createNoFirstContentfulPaintError();
|
|
}
|
|
|
|
// This function accepts events spanning multiple frames, but this usage will only provide events from the main frame.
|
|
const lcpResult = this.computeValidLCPAllFrames(frameEvents, timeOriginEvt);
|
|
|
|
const load = frameEvents.find(e => e.name === 'loadEventEnd' && e.ts > timeOriginEvt.ts);
|
|
const domContentLoaded = frameEvents.find(
|
|
e => e.name === 'domContentLoadedEventEnd' && e.ts > timeOriginEvt.ts
|
|
);
|
|
|
|
/** @param {{ts: number}=} event */
|
|
const getTimestamp = (event) => event?.ts;
|
|
/** @type {TraceNavigationTimesForFrame} */
|
|
const timestamps = {
|
|
timeOrigin: timeOriginEvt.ts,
|
|
firstPaint: getTimestamp(firstPaint),
|
|
firstContentfulPaint: firstContentfulPaint.ts,
|
|
largestContentfulPaint: getTimestamp(lcpResult.lcp),
|
|
load: getTimestamp(load),
|
|
domContentLoaded: getTimestamp(domContentLoaded),
|
|
};
|
|
|
|
/** @param {number} ts */
|
|
const getTiming = ts => (ts - timeOriginEvt.ts) / 1000;
|
|
/** @param {number=} ts */
|
|
const maybeGetTiming = (ts) => ts === undefined ? undefined : getTiming(ts);
|
|
/** @type {TraceNavigationTimesForFrame} */
|
|
const timings = {
|
|
timeOrigin: 0,
|
|
firstPaint: maybeGetTiming(timestamps.firstPaint),
|
|
firstContentfulPaint: getTiming(timestamps.firstContentfulPaint),
|
|
largestContentfulPaint: maybeGetTiming(timestamps.largestContentfulPaint),
|
|
load: maybeGetTiming(timestamps.load),
|
|
domContentLoaded: maybeGetTiming(timestamps.domContentLoaded),
|
|
};
|
|
|
|
return {
|
|
timings,
|
|
timestamps,
|
|
timeOriginEvt: timeOriginEvt,
|
|
firstPaintEvt: firstPaint,
|
|
firstContentfulPaintEvt: firstContentfulPaint,
|
|
largestContentfulPaintEvt: lcpResult.lcp,
|
|
loadEvt: load,
|
|
domContentLoadedEvt: domContentLoaded,
|
|
lcpInvalidated: lcpResult.invalidated,
|
|
};
|
|
}
|
|
}
|
|
|
|
export {TraceProcessor};
|
|
|
|
/**
|
|
* @typedef ToplevelEvent
|
|
* @prop {number} start
|
|
* @prop {number} end
|
|
* @prop {number} duration
|
|
*/
|