chrome-devtools-frontend
Version:
Chrome DevTools UI
1,247 lines (1,141 loc) • 96.1 kB
text/typescript
/*
* Copyright (C) 2012 Google Inc. All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are
* met:
*
* * Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* * Redistributions in binary form must reproduce the above
* copyright notice, this list of conditions and the following disclaimer
* in the documentation and/or other materials provided with the
* distribution.
* * Neither the name of Google Inc. nor the names of its
* contributors may be used to endorse or promote products derived from
* this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
* OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
// TODO(crbug.com/1172300) Ignored during the jsdoc to ts migration)
/* eslint-disable @typescript-eslint/no-explicit-any */
import * as Common from '../../core/common/common.js';
import * as i18n from '../../core/i18n/i18n.js';
import * as Platform from '../../core/platform/platform.js';
import * as Root from '../../core/root/root.js';
import * as SDK from '../../core/sdk/sdk.js';
import * as TraceEngine from '../trace/trace.js';
import type * as Protocol from '../../generated/protocol.js';
import {TimelineJSProfileProcessor} from './TimelineJSProfile.js';
const UIStrings = {
/**
*@description Text for the name of a thread of the page
*@example {1} PH1
*/
threadS: 'Thread {PH1}',
/**
*@description Title of a worker in the timeline flame chart of the Performance panel
*@example {https://google.com} PH1
*/
workerS: '`Worker` — {PH1}',
/**
*@description Title of a worker in the timeline flame chart of the Performance panel
*/
dedicatedWorker: 'Dedicated `Worker`',
/**
*@description Title of a worker in the timeline flame chart of the Performance panel
*@example {FormatterWorker} PH1
*@example {https://google.com} PH2
*/
workerSS: '`Worker`: {PH1} — {PH2}',
/**
*@description Title of a bidder auction worklet with known URL in the timeline flame chart of the Performance panel
*@example {https://google.com} PH1
*/
bidderWorkletS: 'Bidder Worklet — {PH1}',
/**
*@description Title of a seller auction worklet with known URL in the timeline flame chart of the Performance panel
*@example {https://google.com} PH1
*/
sellerWorkletS: 'Seller Worklet — {PH1}',
/**
*@description Title of an auction worklet with known URL in the timeline flame chart of the Performance panel
*@example {https://google.com} PH1
*/
unknownWorkletS: 'Auction Worklet — {PH1}',
/**
*@description Title of a bidder auction worklet in the timeline flame chart of the Performance panel
*/
bidderWorklet: 'Bidder Worklet',
/**
*@description Title of a seller auction worklet in the timeline flame chart of the Performance panel
*/
sellerWorklet: 'Seller Worklet',
/**
*@description Title of an auction worklet in the timeline flame chart of the Performance panel
*/
unknownWorklet: 'Auction Worklet',
/**
*@description Title of control thread of a service process for an auction worklet in the timeline flame chart of the Performance panel
*/
workletService: 'Auction Worklet Service',
/**
*@description Title of control thread of a service process for an auction worklet with known URL in the timeline flame chart of the Performance panel
* @example {https://google.com} PH1
*/
workletServiceS: 'Auction Worklet Service — {PH1}',
};
const str_ = i18n.i18n.registerUIStrings('models/timeline_model/TimelineModel.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
export class TimelineModelImpl {
private isGenericTraceInternal!: boolean;
private tracksInternal!: Track[];
private namedTracks!: Map<TrackType, Track>;
private inspectedTargetEventsInternal!: SDK.TracingModel.Event[];
private timeMarkerEventsInternal!: SDK.TracingModel.Event[];
private sessionId!: string|null;
private mainFrameNodeId!: number|null;
private pageFrames!: Map<Protocol.Page.FrameId, PageFrame>;
private auctionWorklets!: Map<string, AuctionWorklet>;
private cpuProfilesInternal!: SDK.CPUProfileDataModel.CPUProfileDataModel[];
private workerIdByThread!: WeakMap<SDK.TracingModel.Thread, string>;
private requestsFromBrowser!: Map<string, SDK.TracingModel.Event>;
private mainFrame!: PageFrame;
private minimumRecordTimeInternal: number;
private maximumRecordTimeInternal: number;
private totalBlockingTimeInternal: number;
private estimatedTotalBlockingTime: number;
private asyncEventTracker!: TimelineAsyncEventTracker;
private invalidationTracker!: InvalidationTracker;
private layoutInvalidate!: {
[x: string]: SDK.TracingModel.Event|null,
};
private lastScheduleStyleRecalculation!: {
[x: string]: SDK.TracingModel.Event,
};
private paintImageEventByPixelRefId!: {
[x: string]: SDK.TracingModel.Event,
};
private lastPaintForLayer!: {
[x: string]: SDK.TracingModel.Event,
};
private lastRecalculateStylesEvent!: SDK.TracingModel.Event|null;
private currentScriptEvent!: SDK.TracingModel.Event|null;
private eventStack!: SDK.TracingModel.Event[];
private browserFrameTracking!: boolean;
private persistentIds!: boolean;
private legacyCurrentPage!: any;
private currentTaskLayoutAndRecalcEvents: SDK.TracingModel.Event[];
private tracingModelInternal: SDK.TracingModel.TracingModel|null;
private mainFrameLayerTreeId?: any;
#isFreshRecording = false;
constructor() {
this.minimumRecordTimeInternal = 0;
this.maximumRecordTimeInternal = 0;
this.totalBlockingTimeInternal = 0;
this.estimatedTotalBlockingTime = 0;
this.reset();
this.resetProcessingState();
this.currentTaskLayoutAndRecalcEvents = [];
this.tracingModelInternal = null;
}
/**
* Iterates events in a tree hierarchically, from top to bottom,
* calling back on every event's start and end in the order
* dictated by the corresponding timestamp.
*
* Events are assumed to be in ascendent order by timestamp.
*
* For example, given this tree, the following callbacks
* are expected to be made in the following order
* |---------------A---------------|
* |------B------||-------D------|
* |---C---|
*
* 1. Start A
* 3. Start B
* 4. Start C
* 5. End C
* 6. End B
* 7. Start D
* 8. End D
* 9. End A
*
* By default, async events are filtered. This behaviour can be
* overriden making use of the filterAsyncEvents parameter.
*/
static forEachEvent(
events: SDK.TracingModel.CompatibleTraceEvent[],
onStartEvent: (arg0: SDK.TracingModel.CompatibleTraceEvent) => void,
onEndEvent: (arg0: SDK.TracingModel.CompatibleTraceEvent) => void,
onInstantEvent?:
((arg0: SDK.TracingModel.CompatibleTraceEvent, arg1: SDK.TracingModel.CompatibleTraceEvent|null) => void),
startTime?: number, endTime?: number, filter?: ((arg0: SDK.TracingModel.CompatibleTraceEvent) => boolean),
ignoreAsyncEvents = true): void {
startTime = startTime || 0;
endTime = endTime || Infinity;
const stack: SDK.TracingModel.CompatibleTraceEvent[] = [];
const startEvent = TimelineModelImpl.topLevelEventEndingAfter(events, startTime);
for (let i = startEvent; i < events.length; ++i) {
const e = events[i];
const {endTime: eventEndTime, startTime: eventStartTime, duration: eventDuration} =
SDK.TracingModel.timesForEventInMilliseconds(e);
const eventPhase = SDK.TracingModel.phaseForEvent(e);
if ((eventEndTime || eventStartTime) < startTime) {
continue;
}
if (eventStartTime >= endTime) {
break;
}
const canIgnoreAsyncEvent = ignoreAsyncEvents && TraceEngine.Types.TraceEvents.isAsyncPhase(eventPhase);
if (canIgnoreAsyncEvent || TraceEngine.Types.TraceEvents.isFlowPhase(eventPhase)) {
continue;
}
let last = stack[stack.length - 1];
let lastEventEndTime = last && SDK.TracingModel.timesForEventInMilliseconds(last).endTime;
while (last && lastEventEndTime !== undefined && lastEventEndTime <= eventStartTime) {
stack.pop();
onEndEvent(last);
last = stack[stack.length - 1];
lastEventEndTime = last && SDK.TracingModel.timesForEventInMilliseconds(last).endTime;
}
if (filter && !filter(e)) {
continue;
}
if (eventDuration) {
onStartEvent(e);
stack.push(e);
} else {
onInstantEvent && onInstantEvent(e, stack[stack.length - 1] || null);
}
}
while (stack.length) {
const last = stack.pop();
if (last) {
onEndEvent(last);
}
}
}
private static topLevelEventEndingAfter(events: SDK.TracingModel.CompatibleTraceEvent[], time: number): number {
let index =
Platform.ArrayUtilities.upperBound(
events, time, (time, event) => time - SDK.TracingModel.timesForEventInMilliseconds(event).startTime) -
1;
while (index > 0 && !SDK.TracingModel.TracingModel.isTopLevelEvent(events[index])) {
index--;
}
return Math.max(index, 0);
}
mainFrameID(): string {
return this.mainFrame.frameId;
}
/**
* Determines if an event is potentially a marker event. A marker event here
* is a single moment in time that we want to highlight on the timeline, such as
* the LCP point. This method does not filter out events: for example, it treats
* every LCP Candidate event as a potential marker event. The logic to pick the
* right candidate to use is implemeneted in the TimelineFlameChartDataProvider.
**/
isMarkerEvent(event: SDK.TracingModel.CompatibleTraceEvent): boolean {
switch (event.name) {
case RecordType.TimeStamp:
return true;
case RecordType.MarkFirstPaint:
case RecordType.MarkFCP:
return Boolean(this.mainFrame) && event.args.frame === this.mainFrame.frameId && Boolean(event.args.data);
case RecordType.MarkDOMContent:
case RecordType.MarkLoad:
case RecordType.MarkLCPCandidate:
case RecordType.MarkLCPInvalidate:
return Boolean(event.args['data']['isOutermostMainFrame'] ?? event.args['data']['isMainFrame']);
default:
return false;
}
}
isInteractiveTimeEvent(event: SDK.TracingModel.Event): boolean {
return event.name === RecordType.InteractiveTime;
}
isLayoutShiftEvent(event: SDK.TracingModel.Event): boolean {
return event.name === RecordType.LayoutShift;
}
isParseHTMLEvent(event: SDK.TracingModel.Event): boolean {
return event.name === RecordType.ParseHTML;
}
static isJsFrameEvent(event: SDK.TracingModel.CompatibleTraceEvent): boolean {
return event.name === RecordType.JSFrame || event.name === RecordType.JSIdleFrame ||
event.name === RecordType.JSSystemFrame;
}
static globalEventId(event: SDK.TracingModel.Event, field: string): string {
const data = event.args['data'] || event.args['beginData'];
const id = data && data[field];
if (!id) {
return '';
}
return `${event.thread.process().id()}.${id}`;
}
static eventFrameId(event: SDK.TracingModel.Event): Protocol.Page.FrameId|null {
const data = event.args['data'] || event.args['beginData'];
return data && data['frame'] || null;
}
cpuProfiles(): SDK.CPUProfileDataModel.CPUProfileDataModel[] {
return this.cpuProfilesInternal;
}
totalBlockingTime(): {
time: number,
estimated: boolean,
} {
if (this.totalBlockingTimeInternal === -1) {
return {time: this.estimatedTotalBlockingTime, estimated: true};
}
return {time: this.totalBlockingTimeInternal, estimated: false};
}
targetByEvent(event: SDK.TracingModel.CompatibleTraceEvent): SDK.Target.Target|null {
let thread;
if (event instanceof SDK.TracingModel.Event) {
thread = event.thread;
} else {
const process = this.tracingModelInternal?.getProcessById(event.pid);
thread = process?.threadById(event.tid);
}
if (!thread) {
return null;
}
// FIXME: Consider returning null for loaded traces.
const workerId = this.workerIdByThread.get(thread);
const primaryPageTarget = SDK.TargetManager.TargetManager.instance().primaryPageTarget();
return workerId ? SDK.TargetManager.TargetManager.instance().targetById(workerId) : primaryPageTarget;
}
navStartTimes(): Map<string, SDK.TracingModel.PayloadEvent> {
if (!this.tracingModelInternal) {
return new Map();
}
return this.tracingModelInternal.navStartTimes();
}
isFreshRecording(): boolean {
return this.#isFreshRecording;
}
setEvents(tracingModel: SDK.TracingModel.TracingModel, isFreshRecording: boolean = false): void {
this.#isFreshRecording = isFreshRecording;
this.reset();
this.resetProcessingState();
this.tracingModelInternal = tracingModel;
this.minimumRecordTimeInternal = tracingModel.minimumRecordTime();
this.maximumRecordTimeInternal = tracingModel.maximumRecordTime();
// Remove LayoutShift events from the main thread list of events because they are
// represented in the experience track. This is done prior to the main thread being processed for its own events.
const layoutShiftEvents = [];
for (const process of tracingModel.sortedProcesses()) {
if (process.name() !== 'Renderer') {
continue;
}
for (const thread of process.sortedThreads()) {
const shifts = thread.removeEventsByName(RecordType.LayoutShift);
layoutShiftEvents.push(...shifts);
}
}
this.processSyncBrowserEvents(tracingModel);
if (this.browserFrameTracking) {
this.processThreadsForBrowserFrames(tracingModel);
} else {
// The next line is for loading legacy traces recorded before M67.
// TODO(alph): Drop the support at some point.
const metadataEvents = this.processMetadataEvents(tracingModel);
this.isGenericTraceInternal = !metadataEvents;
if (metadataEvents) {
this.processMetadataAndThreads(tracingModel, metadataEvents);
} else {
this.processGenericTrace(tracingModel);
}
}
this.inspectedTargetEventsInternal.sort(SDK.TracingModel.Event.compareStartTime);
this.processAsyncBrowserEvents(tracingModel);
this.resetProcessingState();
}
private processGenericTrace(tracingModel: SDK.TracingModel.TracingModel): void {
let browserMainThread = SDK.TracingModel.TracingModel.browserMainThread(tracingModel);
if (!browserMainThread && tracingModel.sortedProcesses().length) {
browserMainThread = tracingModel.sortedProcesses()[0].sortedThreads()[0];
}
for (const process of tracingModel.sortedProcesses()) {
for (const thread of process.sortedThreads()) {
this.processThreadEvents(
tracingModel, thread, thread === browserMainThread, false, true, WorkletType.NotWorklet, null);
}
}
}
private processMetadataAndThreads(tracingModel: SDK.TracingModel.TracingModel, metadataEvents: MetadataEvents): void {
let startTime = 0;
for (let i = 0, length = metadataEvents.page.length; i < length; i++) {
const metaEvent = metadataEvents.page[i];
const process = metaEvent.thread.process();
const endTime = i + 1 < length ? metadataEvents.page[i + 1].startTime : Infinity;
if (startTime === endTime) {
continue;
}
this.legacyCurrentPage = metaEvent.args['data'] && metaEvent.args['data']['page'];
for (const thread of process.sortedThreads()) {
let workerUrl: Platform.DevToolsPath.UrlString|null = null;
if (thread.name() === TimelineModelImpl.WorkerThreadName ||
thread.name() === TimelineModelImpl.WorkerThreadNameLegacy) {
const workerMetaEvent = metadataEvents.workers.find(e => {
if (e.args['data']['workerThreadId'] !== thread.id()) {
return false;
}
// This is to support old traces.
if (e.args['data']['sessionId'] === this.sessionId) {
return true;
}
const frameId = TimelineModelImpl.eventFrameId(e);
return frameId ? Boolean(this.pageFrames.get(frameId)) : false;
});
if (!workerMetaEvent) {
continue;
}
const workerId = workerMetaEvent.args['data']['workerId'];
if (workerId) {
this.workerIdByThread.set(thread, workerId);
}
workerUrl = workerMetaEvent.args['data']['url'] || Platform.DevToolsPath.EmptyUrlString;
}
this.processThreadEvents(
tracingModel, thread, thread === metaEvent.thread, Boolean(workerUrl), true, WorkletType.NotWorklet,
workerUrl);
}
startTime = endTime;
}
}
private processThreadsForBrowserFrames(tracingModel: SDK.TracingModel.TracingModel): void {
const processDataByPid = new Map<number, {
from: number,
to: number,
main: boolean,
workletType: WorkletType,
url: Platform.DevToolsPath.UrlString,
}[]>();
for (const frame of this.pageFrames.values()) {
for (let i = 0; i < frame.processes.length; i++) {
const pid = frame.processes[i].processId;
let data = processDataByPid.get(pid);
if (!data) {
data = [];
processDataByPid.set(pid, data);
}
const to = i === frame.processes.length - 1 ? (frame.deletedTime || Infinity) : frame.processes[i + 1].time;
data.push({
from: frame.processes[i].time,
to: to,
main: !frame.parent,
url: frame.processes[i].url,
workletType: WorkletType.NotWorklet,
});
}
}
for (const auctionWorklet of this.auctionWorklets.values()) {
const pid = auctionWorklet.processId;
let data = processDataByPid.get(pid);
if (!data) {
data = [];
processDataByPid.set(pid, data);
}
data.push({
from: auctionWorklet.startTime,
to: auctionWorklet.endTime,
main: false,
workletType: auctionWorklet.workletType,
url:
(auctionWorklet.host ? 'https://' + auctionWorklet.host as Platform.DevToolsPath.UrlString :
Platform.DevToolsPath.EmptyUrlString),
});
}
const allMetadataEvents = tracingModel.devToolsMetadataEvents();
for (const process of tracingModel.sortedProcesses()) {
const processData = processDataByPid.get(process.id());
if (!processData) {
continue;
}
// Sort ascending by range starts, followed by range ends
processData.sort((a, b) => a.from - b.from || a.to - b.to);
let lastUrl: Platform.DevToolsPath.UrlString|null = null;
let lastMainUrl: Platform.DevToolsPath.UrlString|null = null;
let hasMain = false;
let allWorklet = true;
// false: not set, true: inconsistent.
let workletUrl: Platform.DevToolsPath.UrlString|boolean = false;
// NotWorklet used for not set.
let workletType: WorkletType = WorkletType.NotWorklet;
for (const item of processData) {
if (item.main) {
hasMain = true;
}
if (item.url) {
if (item.main) {
lastMainUrl = item.url;
}
lastUrl = item.url;
}
// Worklet identification
if (item.workletType === WorkletType.NotWorklet) {
allWorklet = false;
} else {
// Update combined workletUrl, checking for inconsistencies.
if (workletUrl === false) {
workletUrl = item.url;
} else if (workletUrl !== item.url) {
workletUrl = true; // Process used for different things.
}
if (workletType === WorkletType.NotWorklet) {
workletType = item.workletType;
} else if (workletType !== item.workletType) {
workletType = WorkletType.UnknownWorklet;
}
}
}
for (const thread of process.sortedThreads()) {
if (thread.name() === TimelineModelImpl.RendererMainThreadName) {
this.processThreadEvents(
tracingModel, thread, true /* isMainThread */, false /* isWorker */, hasMain, WorkletType.NotWorklet,
hasMain ? lastMainUrl : lastUrl);
} else if (
thread.name() === TimelineModelImpl.WorkerThreadName ||
thread.name() === TimelineModelImpl.WorkerThreadNameLegacy) {
const workerMetaEvent = allMetadataEvents.find(e => {
if (e.name !== TimelineModelImpl.DevToolsMetadataEvent.TracingSessionIdForWorker) {
return false;
}
if (e.thread.process() !== process) {
return false;
}
if (e.args['data']['workerThreadId'] !== thread.id()) {
return false;
}
const frameId = TimelineModelImpl.eventFrameId(e);
return frameId ? Boolean(this.pageFrames.get(frameId)) : false;
});
if (!workerMetaEvent) {
continue;
}
this.workerIdByThread.set(thread, workerMetaEvent.args['data']['workerId'] || '');
this.processThreadEvents(
tracingModel, thread, false /* isMainThread */, true /* isWorker */, false /* forMainFrame */,
WorkletType.NotWorklet, workerMetaEvent.args['data']['url'] || Platform.DevToolsPath.EmptyUrlString);
} else {
let urlForOther: Platform.DevToolsPath.UrlString|null = null;
let workletTypeForOther: WorkletType = WorkletType.NotWorklet;
if (thread.name() === TimelineModelImpl.AuctionWorkletThreadName ||
thread.name().endsWith(TimelineModelImpl.UtilityMainThreadNameSuffix)) {
if (typeof workletUrl !== 'boolean') {
urlForOther = workletUrl;
}
workletTypeForOther = workletType;
} else {
// For processes that only do auction worklet things, skip other threads.
if (allWorklet) {
continue;
}
}
this.processThreadEvents(
tracingModel, thread, false /* isMainThread */, false /* isWorker */, false /* forMainFrame */,
workletTypeForOther, urlForOther);
}
}
}
}
private processMetadataEvents(tracingModel: SDK.TracingModel.TracingModel): MetadataEvents|null {
const metadataEvents = tracingModel.devToolsMetadataEvents();
const pageDevToolsMetadataEvents = [];
const workersDevToolsMetadataEvents = [];
for (const event of metadataEvents) {
if (event.name === TimelineModelImpl.DevToolsMetadataEvent.TracingStartedInPage) {
pageDevToolsMetadataEvents.push(event);
if (event.args['data'] && event.args['data']['persistentIds']) {
this.persistentIds = true;
}
const frames = ((event.args['data'] && event.args['data']['frames']) || [] as PageFrame[]);
frames.forEach((payload: PageFrame) => this.addPageFrame(event, payload));
this.mainFrame = this.rootFrames()[0];
} else if (event.name === TimelineModelImpl.DevToolsMetadataEvent.TracingSessionIdForWorker) {
workersDevToolsMetadataEvents.push(event);
} else if (event.name === TimelineModelImpl.DevToolsMetadataEvent.TracingStartedInBrowser) {
console.assert(!this.mainFrameNodeId, 'Multiple sessions in trace');
this.mainFrameNodeId = event.args['frameTreeNodeId'];
}
}
if (!pageDevToolsMetadataEvents.length) {
return null;
}
const sessionId =
pageDevToolsMetadataEvents[0].args['sessionId'] || pageDevToolsMetadataEvents[0].args['data']['sessionId'];
this.sessionId = sessionId;
const mismatchingIds = new Set<any>();
function checkSessionId(event: SDK.TracingModel.Event): boolean {
let args = event.args;
// FIXME: put sessionId into args["data"] for TracingStartedInPage event.
if (args['data']) {
args = args['data'];
}
const id = args['sessionId'];
if (id === sessionId) {
return true;
}
mismatchingIds.add(id);
return false;
}
const result = {
page: pageDevToolsMetadataEvents.filter(checkSessionId).sort(SDK.TracingModel.Event.compareStartTime),
workers: workersDevToolsMetadataEvents.sort(SDK.TracingModel.Event.compareStartTime),
};
if (mismatchingIds.size) {
Common.Console.Console.instance().error(
'Timeline recording was started in more than one page simultaneously. Session id mismatch: ' +
this.sessionId + ' and ' + [...mismatchingIds] + '.');
}
return result;
}
private processSyncBrowserEvents(tracingModel: SDK.TracingModel.TracingModel): void {
const browserMain = SDK.TracingModel.TracingModel.browserMainThread(tracingModel);
if (browserMain) {
browserMain.events().forEach(this.processBrowserEvent, this);
}
}
private processAsyncBrowserEvents(tracingModel: SDK.TracingModel.TracingModel): void {
const browserMain = SDK.TracingModel.TracingModel.browserMainThread(tracingModel);
if (browserMain) {
this.processAsyncEvents(browserMain);
}
}
private resetProcessingState(): void {
this.asyncEventTracker = new TimelineAsyncEventTracker();
this.invalidationTracker = new InvalidationTracker();
this.layoutInvalidate = {};
this.lastScheduleStyleRecalculation = {};
this.paintImageEventByPixelRefId = {};
this.lastPaintForLayer = {};
this.lastRecalculateStylesEvent = null;
this.currentScriptEvent = null;
this.eventStack = [];
this.browserFrameTracking = false;
this.persistentIds = false;
this.legacyCurrentPage = null;
}
private extractCpuProfileDataModel(tracingModel: SDK.TracingModel.TracingModel, thread: SDK.TracingModel.Thread):
SDK.CPUProfileDataModel.CPUProfileDataModel|null {
const events = thread.events();
let cpuProfile;
let target: (SDK.Target.Target|null)|null = null;
// Check for legacy CpuProfile event format first.
// 'CpuProfile' is currently used by https://webpack.js.org/plugins/profiling-plugin/ and our createFakeTraceFromCpuProfile
let cpuProfileEvent = events.at(-1);
if (cpuProfileEvent && cpuProfileEvent.name === RecordType.CpuProfile) {
const eventData = cpuProfileEvent.args['data'];
cpuProfile = (eventData && eventData['cpuProfile'] as Protocol.Profiler.Profile | null);
target = this.targetByEvent(cpuProfileEvent);
}
if (!cpuProfile) {
cpuProfileEvent = events.find(e => e.name === RecordType.Profile);
if (!cpuProfileEvent) {
return null;
}
target = this.targetByEvent(cpuProfileEvent);
// Profile groups are created right after a trace is loaded (in
// tracing model).
// They are created using events with the "P" phase (samples),
// which includes ProfileChunks with the samples themselves but
// also "Profile" events with metadata of the profile.
// A group is created for each unique profile in each unique
// thread.
const profileGroup = tracingModel.profileGroup(cpuProfileEvent);
if (!profileGroup) {
Common.Console.Console.instance().error('Invalid CPU profile format.');
return null;
}
cpuProfile = ({
startTime: cpuProfileEvent.startTime * 1000,
endTime: 0,
nodes: [],
samples: [],
timeDeltas: [],
lines: [],
} as any);
for (const profileEvent of profileGroup.children) {
const eventData = profileEvent.args['data'];
if ('startTime' in eventData) {
// Do not use |eventData['startTime']| as it is in CLOCK_MONOTONIC domain,
// but use |profileEvent.startTime| (|ts| in the trace event) which has
// been translated to Perfetto's clock domain.
//
// Also convert from ms to us.
cpuProfile.startTime = profileEvent.startTime * 1000;
}
if ('endTime' in eventData) {
// Do not use |eventData['endTime']| as it is in CLOCK_MONOTONIC domain,
// but use |profileEvent.startTime| (|ts| in the trace event) which has
// been translated to Perfetto's clock domain.
//
// Despite its name, |profileEvent.startTime| was recorded right after
// |eventData['endTime']| within v8 and is a reasonable substitute.
//
// Also convert from ms to us.
cpuProfile.endTime = profileEvent.startTime * 1000;
}
const nodesAndSamples = eventData['cpuProfile'] || {};
const samples = nodesAndSamples['samples'] || [];
const lines = eventData['lines'] || Array(samples.length).fill(0);
cpuProfile.nodes.push(...(nodesAndSamples['nodes'] || []));
cpuProfile.lines.push(...lines);
if (cpuProfile.samples) {
cpuProfile.samples.push(...samples);
}
if (cpuProfile.timeDeltas) {
cpuProfile.timeDeltas.push(...(eventData['timeDeltas'] || []));
}
if (cpuProfile.samples && cpuProfile.timeDeltas && cpuProfile.samples.length !== cpuProfile.timeDeltas.length) {
Common.Console.Console.instance().error('Failed to parse CPU profile.');
return null;
}
}
if (!cpuProfile.endTime && cpuProfile.timeDeltas) {
const timeDeltas: number[] = cpuProfile.timeDeltas;
cpuProfile.endTime = timeDeltas.reduce((x, y) => x + y, cpuProfile.startTime);
}
}
try {
const profile = (cpuProfile as Protocol.Profiler.Profile);
const jsProfileModel = new SDK.CPUProfileDataModel.CPUProfileDataModel(profile, target);
this.cpuProfilesInternal.push(jsProfileModel);
return jsProfileModel;
} catch (e) {
Common.Console.Console.instance().error('Failed to parse CPU profile.');
}
return null;
}
private injectJSFrameEvents(tracingModel: SDK.TracingModel.TracingModel, thread: SDK.TracingModel.Thread):
SDK.TracingModel.Event[] {
const jsProfileModel = this.extractCpuProfileDataModel(tracingModel, thread);
let events = thread.events();
const jsSamples = jsProfileModel ?
TimelineJSProfileProcessor.generateConstructedEventsFromCpuProfileDataModel(jsProfileModel, thread) :
null;
if (jsSamples && jsSamples.length) {
events = Platform.ArrayUtilities.mergeOrdered(events, jsSamples, SDK.TracingModel.Event.orderedCompareStartTime);
}
if (jsSamples ||
events.some(
e => e.name === RecordType.JSSample || e.name === RecordType.JSSystemSample ||
e.name === RecordType.JSIdleSample)) {
const jsFrameEvents = TimelineJSProfileProcessor.generateJSFrameEvents(events, {
showAllEvents: Root.Runtime.experiments.isEnabled('timelineShowAllEvents'),
showRuntimeCallStats: Root.Runtime.experiments.isEnabled('timelineV8RuntimeCallStats'),
showNativeFunctions: Common.Settings.Settings.instance().moduleSetting('showNativeFunctionsInJSProfile').get(),
});
if (jsFrameEvents && jsFrameEvents.length) {
events =
Platform.ArrayUtilities.mergeOrdered(jsFrameEvents, events, SDK.TracingModel.Event.orderedCompareStartTime);
}
}
return events;
}
private static nameAuctionWorklet(workletType: WorkletType, url: Platform.DevToolsPath.UrlString|null): string {
switch (workletType) {
case WorkletType.BidderWorklet:
return url ? i18nString(UIStrings.bidderWorkletS, {PH1: url}) : i18nString(UIStrings.bidderWorklet);
case WorkletType.SellerWorklet:
return url ? i18nString(UIStrings.sellerWorkletS, {PH1: url}) : i18nString(UIStrings.sellerWorklet);
default:
return url ? i18nString(UIStrings.unknownWorkletS, {PH1: url}) : i18nString(UIStrings.unknownWorklet);
}
}
private processThreadEvents(
tracingModel: SDK.TracingModel.TracingModel, thread: SDK.TracingModel.Thread, isMainThread: boolean,
isWorker: boolean, forMainFrame: boolean, workletType: WorkletType,
url: Platform.DevToolsPath.UrlString|null): void {
const track = new Track();
track.name = thread.name() || i18nString(UIStrings.threadS, {PH1: thread.id()});
track.type = TrackType.Other;
track.thread = thread;
if (isMainThread) {
track.type = TrackType.MainThread;
track.url = url || Platform.DevToolsPath.EmptyUrlString;
track.forMainFrame = forMainFrame;
} else if (isWorker) {
track.type = TrackType.Worker;
track.url = url || Platform.DevToolsPath.EmptyUrlString;
track.name = track.url ? i18nString(UIStrings.workerS, {PH1: track.url}) : i18nString(UIStrings.dedicatedWorker);
} else if (thread.name().startsWith('CompositorTileWorker')) {
track.type = TrackType.Raster;
} else if (thread.name() === TimelineModelImpl.AuctionWorkletThreadName) {
track.url = url || Platform.DevToolsPath.EmptyUrlString;
track.name = TimelineModelImpl.nameAuctionWorklet(workletType, url);
} else if (
workletType !== WorkletType.NotWorklet &&
thread.name().endsWith(TimelineModelImpl.UtilityMainThreadNameSuffix)) {
track.url = url || Platform.DevToolsPath.EmptyUrlString;
track.name = url ? i18nString(UIStrings.workletServiceS, {PH1: url}) : i18nString(UIStrings.workletService);
}
this.tracksInternal.push(track);
const events = this.injectJSFrameEvents(tracingModel, thread);
this.eventStack = [];
const eventStack = this.eventStack;
// Get the worker name from the target.
if (isWorker) {
const cpuProfileEvent = events.find(event => event.name === RecordType.Profile);
if (cpuProfileEvent) {
const target = this.targetByEvent(cpuProfileEvent);
if (target) {
track.name = i18nString(UIStrings.workerSS, {PH1: target.name(), PH2: track.url});
}
}
}
for (let i = 0; i < events.length; i++) {
const event = events[i];
// There may be several TTI events, only take the first one.
if (this.isInteractiveTimeEvent(event) && this.totalBlockingTimeInternal === -1) {
this.totalBlockingTimeInternal = event.args['args']['total_blocking_time_ms'];
}
const isLongRunningTask = event.name === RecordType.Task && event.duration && event.duration > 50;
if (isMainThread && isLongRunningTask && event.duration) {
// We only track main thread events that are over 50ms, and the amount of time in the
// event (over 50ms) is what constitutes the blocking time. An event of 70ms, therefore,
// contributes 20ms to TBT.
this.estimatedTotalBlockingTime += event.duration - 50;
}
let last: SDK.TracingModel.Event = eventStack[eventStack.length - 1];
while (last && last.endTime !== undefined && last.endTime <= event.startTime) {
eventStack.pop();
last = eventStack[eventStack.length - 1];
}
if (!this.processEvent(event)) {
continue;
}
if (!TraceEngine.Types.TraceEvents.isAsyncPhase(event.phase) && event.duration) {
if (eventStack.length) {
const parent = eventStack[eventStack.length - 1];
if (parent) {
parent.selfTime -= event.duration;
if (parent.selfTime < 0) {
this.fixNegativeDuration(parent, event);
}
}
}
event.selfTime = event.duration;
if (!eventStack.length) {
track.tasks.push(event);
}
eventStack.push(event);
}
if (this.isMarkerEvent(event)) {
this.timeMarkerEventsInternal.push(event);
}
track.events.push(event);
this.inspectedTargetEventsInternal.push(event);
}
this.processAsyncEvents(thread);
}
private fixNegativeDuration(event: SDK.TracingModel.Event, child: SDK.TracingModel.Event): void {
const epsilon = 1e-3;
if (event.selfTime < -epsilon) {
console.error(
`Children are longer than parent at ${event.startTime} ` +
`(${(child.startTime - this.minimumRecordTime()).toFixed(3)} by ${(-event.selfTime).toFixed(3)}`);
}
event.selfTime = 0;
}
private processAsyncEvents(thread: SDK.TracingModel.Thread): void {
const asyncEvents = thread.asyncEvents();
const groups = new Map<TrackType, SDK.TracingModel.AsyncEvent[]>();
function group(type: TrackType): SDK.TracingModel.AsyncEvent[] {
if (!groups.has(type)) {
groups.set(type, []);
}
return groups.get(type) as SDK.TracingModel.AsyncEvent[];
}
for (let i = 0; i < asyncEvents.length; ++i) {
const asyncEvent = asyncEvents[i];
if (asyncEvent.name === RecordType.Animation) {
group(TrackType.Animation).push(asyncEvent);
continue;
}
}
for (const [type, events] of groups) {
const track = this.ensureNamedTrack(type);
track.thread = thread;
track.asyncEvents =
Platform.ArrayUtilities.mergeOrdered(track.asyncEvents, events, SDK.TracingModel.Event.compareStartTime);
}
}
private processEvent(event: SDK.TracingModel.Event): boolean {
const eventStack = this.eventStack;
if (!eventStack.length) {
if (this.currentTaskLayoutAndRecalcEvents && this.currentTaskLayoutAndRecalcEvents.length) {
const totalTime = this.currentTaskLayoutAndRecalcEvents.reduce((time, event) => {
return event.duration === undefined ? time : time + event.duration;
}, 0);
if (totalTime > TimelineModelImpl.Thresholds.ForcedLayout) {
for (const e of this.currentTaskLayoutAndRecalcEvents) {
const timelineData = EventOnTimelineData.forEvent(e);
timelineData.warning = e.name === RecordType.Layout ? TimelineModelImpl.WarningType.ForcedLayout :
TimelineModelImpl.WarningType.ForcedStyle;
}
}
}
this.currentTaskLayoutAndRecalcEvents = [];
}
if (this.currentScriptEvent) {
if (this.currentScriptEvent.endTime !== undefined && event.startTime > this.currentScriptEvent.endTime) {
this.currentScriptEvent = null;
}
}
const eventData = event.args['data'] || event.args['beginData'] || {};
const timelineData = EventOnTimelineData.forEvent(event);
if (eventData['stackTrace']) {
timelineData.stackTrace = eventData['stackTrace'].map((callFrameOrProfileNode: Protocol.Runtime.CallFrame) => {
// `callFrameOrProfileNode` can also be a `SDK.ProfileTreeModel.ProfileNode` for JSSample; that class
// has accessors to mimic a `CallFrame`, but apparently we don't adjust stack traces in that case. Whether
// we should is unclear.
if (event.name !== RecordType.JSSample && event.name !== RecordType.JSSystemSample &&
event.name !== RecordType.JSIdleSample) {
// We need to copy the data so we can safely modify it below.
const frame = {...callFrameOrProfileNode};
// TraceEvents come with 1-based line & column numbers. The frontend code
// requires 0-based ones. Adjust the values.
--frame.lineNumber;
--frame.columnNumber;
return frame;
}
return callFrameOrProfileNode;
});
}
let pageFrameId = TimelineModelImpl.eventFrameId(event);
const last = eventStack[eventStack.length - 1];
if (!pageFrameId && last) {
pageFrameId = EventOnTimelineData.forEvent(last).frameId;
}
timelineData.frameId = pageFrameId || (this.mainFrame && this.mainFrame.frameId) || '';
this.asyncEventTracker.processEvent(event);
switch (event.name) {
case RecordType.ResourceSendRequest:
case RecordType.WebSocketCreate: {
timelineData.setInitiator(eventStack[eventStack.length - 1] || null);
timelineData.url = eventData['url'];
break;
}
case RecordType.ScheduleStyleRecalculation: {
this.lastScheduleStyleRecalculation[eventData['frame']] = event;
break;
}
case RecordType.UpdateLayoutTree:
case RecordType.RecalculateStyles: {
this.invalidationTracker.didRecalcStyle(event);
if (event.args['beginData']) {
timelineData.setInitiator(this.lastScheduleStyleRecalculation[event.args['beginData']['frame']]);
}
this.lastRecalculateStylesEvent = event;
if (this.currentScriptEvent) {
this.currentTaskLayoutAndRecalcEvents.push(event);
}
break;
}
case RecordType.ScheduleStyleInvalidationTracking:
case RecordType.StyleRecalcInvalidationTracking:
case RecordType.StyleInvalidatorInvalidationTracking:
case RecordType.LayoutInvalidationTracking: {
this.invalidationTracker.addInvalidation(new InvalidationTrackingEvent(event, timelineData));
break;
}
case RecordType.InvalidateLayout: {
// Consider style recalculation as a reason for layout invalidation,
// but only if we had no earlier layout invalidation records.
let layoutInitator: (SDK.TracingModel.Event|null)|SDK.TracingModel.Event = event;
const frameId = eventData['frame'];
if (!this.layoutInvalidate[frameId] && this.lastRecalculateStylesEvent &&
this.lastRecalculateStylesEvent.endTime !== undefined &&
this.lastRecalculateStylesEvent.endTime > event.startTime) {
layoutInitator = EventOnTimelineData.forEvent(this.lastRecalculateStylesEvent).initiator();
}
this.layoutInvalidate[frameId] = layoutInitator;
break;
}
case RecordType.Layout: {
this.invalidationTracker.didLayout(event);
const frameId = event.args['beginData']['frame'];
timelineData.setInitiator(this.layoutInvalidate[frameId]);
// In case we have no closing Layout event, endData is not available.
if (event.args['endData']) {
if (event.args['endData']['layoutRoots']) {
for (let i = 0; i < event.args['endData']['layoutRoots'].length; ++i) {
timelineData.backendNodeIds.push(event.args['endData']['layoutRoots'][i]['nodeId']);
}
} else {
timelineData.backendNodeIds.push(event.args['endData']['rootNode']);
}
}
this.layoutInvalidate[frameId] = null;
if (this.currentScriptEvent) {
this.currentTaskLayoutAndRecalcEvents.push(event);
}
break;
}
case RecordType.Task: {
if (event.duration !== undefined && event.duration > TimelineModelImpl.Thresholds.LongTask) {
timelineData.warning = TimelineModelImpl.WarningType.LongTask;
}
break;
}
case RecordType.EventDispatch: {
if (event.duration !== undefined && event.duration > TimelineModelImpl.Thresholds.RecurringHandler) {
timelineData.warning = TimelineModelImpl.WarningType.LongHandler;
}
break;
}
case RecordType.TimerFire:
case RecordType.FireAnimationFrame: {
if (event.duration !== undefined && event.duration > TimelineModelImpl.Thresholds.RecurringHandler) {
timelineData.warning = TimelineModelImpl.WarningType.LongRecurringHandler;
}
break;
}
// @ts-ignore fallthrough intended.
case RecordType.FunctionCall: {
// Compatibility with old format.
if (typeof eventData['scriptName'] === 'string') {
eventData['url'] = eventData['scriptName'];
}
if (typeof eventData['scriptLine'] === 'number') {
eventData['lineNumber'] = eventData['scriptLine'];
}
}
case RecordType.EvaluateScript:
case RecordType.CompileScript:
// @ts-ignore fallthrough intended.
case RecordType.CacheScript: {
if (typeof eventData['lineNumber'] === 'number') {
--eventData['lineNumber'];
}
if (typeof eventData['columnNumber'] === 'number') {
--eventData['columnNumber'];
}
}
case RecordType.RunMicrotasks: {
// Microtasks technically are not necessarily scripts, but for purpose of
// forced sync style recalc or layout detection they are.
if (!this.currentScriptEvent) {
this.currentScriptEvent = event;
}
break;
}
case RecordType.SetLayerTreeId: {
// This is to support old traces.
if (this.sessionId && eventData['sessionId'] && this.sessionId === eventData['sessionId']) {
this.mainFrameLayerTreeId = eventData['layerTreeId'];
break;
}
// We currently only show layer tree for the main frame.
const frameId = TimelineModelImpl.eventFrameId(event);
const pageFrame = frameId ? this.pageFrames.get(frameId) : null;
if (!pageFrame || pageFrame.parent) {
return false;
}
this.mainFrameLayerTreeId = eventData['layerTreeId'];
break;
}
case RecordType.Paint: {
this.invalidationTracker.didPaint = true;
// With CompositeAfterPaint enabled, paint events are no longer
// associated with a Node, and nodeId will not be present.
if ('nodeId' in eventData) {
timelineData.backendNodeIds.push(eventData['nodeId']);
}
// Only keep layer paint events, skip paints for subframes that get painted to the same layer as parent.
if (!eventData['layerId']) {
break;
}
const layerId = eventData['layerId'];
this.lastPaintForLayer[layerId] = event;
break;
}
case RecordType.DisplayItemListSnapshot:
case RecordType.PictureSnapshot: {
// If we get a snapshot, we try to find the last Paint event for the
// current layer, and store the snapshot as the relevant picture for
// that event, thus creating a relationship between the snapshot and
// the last Paint event for the current timestamp.
const layerUpdateEvent = this.findAncestorEvent(RecordType.UpdateLayer);
if (!layerUpdateEvent || layerUpdateEvent.args['layerTreeId'] !== this.mainFrameLayerTreeId) {
break;
}
const paintEvent = this.lastPaintForLayer[layerUpdateEvent.args['layerId']];
if (paintEvent) {
EventOnTimelineData.forEvent(paintEvent).picture = (event as SDK.TracingModel.ObjectSnapshot);
}
break;
}
case RecordType.ScrollLayer: {
timelineData.backendNodeIds.push(eventData['nodeId']);
break;
}
case RecordType.PaintImage: {
timelineData.backendNodeIds.push(eventData['nodeId']);
timelineData.url = eventData['url'];
break;
}
case RecordType.DecodeImage:
case RecordType.ResizeImage: {
let paintImageEvent = this.findAncestorEvent(RecordType.PaintImage);
if (!paintImageEvent) {
const decodeLazyPixelRefEvent = this.findAncestorEvent(RecordType.DecodeLazyPixelRef);
paintImageEvent =
decodeLazyPixelRefEvent && this.paintImageEventByPixelRefId[decodeLazyPixelRefEvent.args['LazyPixelRef']];
}
if (!paintImageEvent) {
break;
}
const paintImageData = EventOnTimelineData.forEvent(paintImageEvent);
timelineData.backendNodeIds.push(paintImageData.backendNodeIds[0]);
timelineData.url = paintImageData.url;
break;
}
case RecordType.DrawLazyPixelRef: {
const paintImageEvent = this.findAncestorEvent(RecordType.PaintImage);
if (!paintImageEvent) {
break;
}
this.paintImageEventByPixelRefId[event.args['LazyPixelRef']] = paintImageEvent;
const paintImageData = EventOnTimelineData.forEvent(paintImageEvent);
timelineData.backendNodeIds.push(paintImageData.backendNodeIds[0]);
timelineData.url = paintImageData.url;
break;
}
case RecordType.FrameStartedLoading: {
if (timelineData.frameId !== event.args['frame']) {
return false;
}
break;
}
case RecordType.MarkLCPCandidate: {
timelineData.backendNodeIds.push(eventData['nodeId']);
break;
}
case RecordType.MarkDOMContent:
case RecordType.MarkLoad: {
const frameId = TimelineModelImpl.eventFrameId(event);
if (!frameId || !this.pageFrames.has(frameId)) {
return false;
}
break;
}
case RecordType.CommitLoad: {
if (this.browserFrameTracking) {
break;
}
const frameId = TimelineModelImpl.eventFrameId(event);
const isOutermostMainFrame = Boolean(eventData['isOutermostMainFrame'] ?? eventData['isMainFrame']);
const pageFrame = frameId ? this.pageFrames.get(frameId) : null;