UNPKG

chrome-devtools-frontend

Version:
1,205 lines (1,067 loc) • 57.2 kB
/* * Copyright (C) 2014 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. */ /* eslint-disable rulesdir/no-imperative-dom-api */ import * as Common from '../../core/common/common.js'; import * as i18n from '../../core/i18n/i18n.js'; import * as Root from '../../core/root/root.js'; import * as Bindings from '../../models/bindings/bindings.js'; import * as Trace from '../../models/trace/trace.js'; import * as PerfUI from '../../ui/legacy/components/perf_ui/perf_ui.js'; import * as UI from '../../ui/legacy/legacy.js'; import * as ThemeSupport from '../../ui/legacy/theme_support/theme_support.js'; import {CompatibilityTracksAppender, type DrawOverride, type TrackAppenderName} from './CompatibilityTracksAppender.js'; import {initiatorsDataToDraw} from './Initiators.js'; import {ModificationsManager} from './ModificationsManager.js'; import {ThreadAppender} from './ThreadAppender.js'; import timelineFlamechartPopoverStyles from './timelineFlamechartPopover.css.js'; import {FlameChartStyle, Selection} from './TimelineFlameChartView.js'; import { selectionFromEvent, selectionIsRange, selectionsEqual, type TimelineSelection, } from './TimelineSelection.js'; import {buildPersistedConfig, keyForTraceConfig} from './TrackConfiguration.js'; import * as Utils from './utils/utils.js'; const UIStrings = { /** *@description Text for rendering frames */ frames: 'Frames', /** *@description Text in Timeline Flame Chart Data Provider of the Performance panel */ idleFrame: 'Idle frame', /** *@description Text in Timeline Frame Chart Data Provider of the Performance panel */ droppedFrame: 'Dropped frame', /** *@description Text in Timeline Frame Chart Data Provider of the Performance panel */ partiallyPresentedFrame: 'Partially-presented frame', /** *@description Text for a rendering frame */ frame: 'Frame', /** *@description Text for Hiding a function from the Flame Chart */ hideFunction: 'Hide function', /** *@description Text for Hiding all children of a function from the Flame Chart */ hideChildren: 'Hide children', /** *@description Text for Hiding all child entries that are identical to the selected entry from the Flame Chart */ hideRepeatingChildren: 'Hide repeating children', /** *@description Text for remove script from ignore list from the Flame Chart */ removeScriptFromIgnoreList: 'Remove script from ignore list', /** *@description Text for add script to ignore list from the Flame Chart */ addScriptToIgnoreList: 'Add script to ignore list', /** *@description Text for an action that shows all of the hidden children of an entry */ resetChildren: 'Reset children', /** *@description Text for an action that shows all of the hidden entries of the Flame Chart */ resetTrace: 'Reset trace', } as const; const str_ = i18n.i18n.registerUIStrings('panels/timeline/TimelineFlameChartDataProvider.ts', UIStrings); const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); export class TimelineFlameChartDataProvider extends Common.ObjectWrapper.ObjectWrapper<EventTypes> implements PerfUI.FlameChart.FlameChartDataProvider { private droppedFramePattern: CanvasPattern|null; private partialFramePattern: CanvasPattern|null; private timelineDataInternal: PerfUI.FlameChart.FlameChartTimelineData|null = null; private currentLevel = 0; private compatibilityTracksAppender: CompatibilityTracksAppender|null = null; private parsedTrace: Trace.Handlers.Types.ParsedTrace|null = null; #minimumBoundary = 0; private timeSpan = 0; private readonly framesGroupStyle: PerfUI.FlameChart.GroupStyle; private readonly screenshotsGroupStyle: PerfUI.FlameChart.GroupStyle; // Contains all the entries that are DRAWN onto the track. Entries that have // been hidden - either by a user action, or because they aren't visible at // all - will not appear in this array and it will change per-render. For // example, if a user collapses an icicle in the flamechart, those entries // that are now hidden will no longer be in this array. // This also includes entrys that used to be special cased (e.g. // TimelineFrames) that are now of type Types.Events.Event and so the old // `TimelineFlameChartEntry` type has been removed in faovur of using // Trace.Types.Events.Event directly. See crrev.com/c/5973695 for details. private entryData: Trace.Types.Events.Event[] = []; private entryTypeByLevel: EntryType[] = []; private entryIndexToTitle: string[] = []; #lastInitiatorEntryIndex = -1; private lastSelection: Selection|null = null; readonly #font = `${PerfUI.Font.DEFAULT_FONT_SIZE} ${PerfUI.Font.getFontFamilyForCanvas()}`; #eventIndexByEvent = new WeakMap<Trace.Types.Events.Event, number|null>(); #entityMapper: Utils.EntityMapper.EntityMapper|null = null; /** * When we create initiator chains for a selected event, we store those * chains in this map so that if the user reselects the same event we do not * have to recalculate. This is reset when the trace changes. */ #initiatorsCache = new Map<number, PerfUI.FlameChart.FlameChartInitiatorData[]>(); #persistedGroupConfigSetting: Common.Settings.Setting<PerfUI.FlameChart.PersistedConfigPerTrace>|null = null; constructor() { super(); this.reset(); [this.droppedFramePattern, this.partialFramePattern] = this.preparePatternCanvas(); this.framesGroupStyle = this.buildGroupStyle({useFirstLineForOverview: true}); this.screenshotsGroupStyle = this.buildGroupStyle({useFirstLineForOverview: true, nestingLevel: 1, collapsible: false, itemsHeight: 150}); ThemeSupport.ThemeSupport.instance().addEventListener(ThemeSupport.ThemeChangeEvent.eventName, () => { const headers = [ this.framesGroupStyle, this.screenshotsGroupStyle, ]; for (const header of headers) { header.color = ThemeSupport.ThemeSupport.instance().getComputedValue('--sys-color-on-surface'); header.backgroundColor = ThemeSupport.ThemeSupport.instance().getComputedValue('--sys-color-cdt-base-container'); } }); Utils.ImageCache.emitter.addEventListener( 'screenshot-loaded', () => this.dispatchEventToListeners(Events.DATA_CHANGED)); Common.Settings.Settings.instance() .moduleSetting('skip-stack-frames-pattern') .addChangeListener(this.#onIgnoreListChanged.bind(this)); Common.Settings.Settings.instance() .moduleSetting('skip-content-scripts') .addChangeListener(this.#onIgnoreListChanged.bind(this)); Common.Settings.Settings.instance() .moduleSetting('automatically-ignore-list-known-third-party-scripts') .addChangeListener(this.#onIgnoreListChanged.bind(this)); Common.Settings.Settings.instance() .moduleSetting('enable-ignore-listing') .addChangeListener(this.#onIgnoreListChanged.bind(this)); Common.Settings.Settings.instance() .moduleSetting('skip-anonymous-scripts') .addChangeListener(this.#onIgnoreListChanged.bind(this)); } handleTrackConfigurationChange(groups: readonly PerfUI.FlameChart.Group[], indexesInVisualOrder: number[]): void { if (!this.#persistedGroupConfigSetting) { return; } if (!this.parsedTrace) { return; } const persistedDataForTrace = buildPersistedConfig(groups, indexesInVisualOrder); const traceKey = keyForTraceConfig(this.parsedTrace); const setting = this.#persistedGroupConfigSetting.get(); setting[traceKey] = persistedDataForTrace; this.#persistedGroupConfigSetting.set(setting); } setPersistedGroupConfigSetting(setting: Common.Settings.Setting<PerfUI.FlameChart.PersistedConfigPerTrace>): void { this.#persistedGroupConfigSetting = setting; } hasTrackConfigurationMode(): boolean { return true; } getPossibleActions(entryIndex: number, groupIndex: number): PerfUI.FlameChart.PossibleFilterActions|void { const data = this.timelineData(); if (!data) { return; } const group = data.groups.at(groupIndex); // Early exit here if there is no group or: // 1. The group is not expanded: it needs to be expanded to allow the // context menu actions to occur. // 2. The group does not have the showStackContextMenu flag which indicates // that it does not show entries that support the stack actions. if (!group || !group.expanded || !group.showStackContextMenu) { return; } // Check which actions are possible on an entry. // If an action would not change the entries (for example it has no children to collapse), we do not need to show it. return this.findPossibleContextMenuActions(entryIndex); } customizedContextMenu(mouseEvent: MouseEvent, entryIndex: number, groupIndex: number): UI.ContextMenu.ContextMenu |undefined { const entry = this.eventByIndex(entryIndex); if (!entry) { return; } const possibleActions = this.getPossibleActions(entryIndex, groupIndex); // This action and its 'execute' is defined in `freestyler-meta` const PERF_AI_ACTION_ID = 'drjones.performance-panel-context'; const perfAIEntryPointEnabled = Boolean(entry && this.parsedTrace && UI.ActionRegistry.ActionRegistry.instance().hasAction(PERF_AI_ACTION_ID)); if (!possibleActions && !perfAIEntryPointEnabled) { // Early exit: no possible actions (e.g. collapsing children) and no AI // entrypoint, so we don't need to do anything. return; } const contextMenu = new UI.ContextMenu.ContextMenu(mouseEvent); if (perfAIEntryPointEnabled && this.parsedTrace) { const aiCallTree = Utils.AICallTree.AICallTree.fromEvent(entry, this.parsedTrace); if (aiCallTree) { const action = UI.ActionRegistry.ActionRegistry.instance().getAction(PERF_AI_ACTION_ID); contextMenu.footerSection().appendItem(action.title(), () => { const event = this.eventByIndex(entryIndex); if (!event || !this.parsedTrace) { return; } // The other side of setFlavor is handleTraceEntryNodeFlavorChange() in FreestylerPanel UI.Context.Context.instance().setFlavor(Utils.AICallTree.AICallTree, aiCallTree); return action.execute(); }, {jslogContext: PERF_AI_ACTION_ID}); } } if (!possibleActions) { // All the code below here adds possible actions to the context menu, // some of which may be marked as disabled. If we didn't get any possible // actions, rather than add them all and mark all of them as disabled, we // early exit + don't add any of them. return contextMenu; } const hideEntryOption = contextMenu.defaultSection().appendItem(i18nString(UIStrings.hideFunction), () => { this.modifyTree(PerfUI.FlameChart.FilterAction.MERGE_FUNCTION, entryIndex); }, { disabled: !possibleActions?.[PerfUI.FlameChart.FilterAction.MERGE_FUNCTION], jslogContext: 'hide-function', }); hideEntryOption.setAccelerator(UI.KeyboardShortcut.Keys.H, [UI.KeyboardShortcut.Modifiers.None]); hideEntryOption.setIsDevToolsPerformanceMenuItem(true); const hideChildrenOption = contextMenu.defaultSection().appendItem(i18nString(UIStrings.hideChildren), () => { this.modifyTree(PerfUI.FlameChart.FilterAction.COLLAPSE_FUNCTION, entryIndex); }, { disabled: !possibleActions?.[PerfUI.FlameChart.FilterAction.COLLAPSE_FUNCTION], jslogContext: 'hide-children', }); hideChildrenOption.setAccelerator(UI.KeyboardShortcut.Keys.C, [UI.KeyboardShortcut.Modifiers.None]); hideChildrenOption.setIsDevToolsPerformanceMenuItem(true); const hideRepeatingChildrenOption = contextMenu.defaultSection().appendItem(i18nString(UIStrings.hideRepeatingChildren), () => { this.modifyTree(PerfUI.FlameChart.FilterAction.COLLAPSE_REPEATING_DESCENDANTS, entryIndex); }, { disabled: !possibleActions?.[PerfUI.FlameChart.FilterAction.COLLAPSE_REPEATING_DESCENDANTS], jslogContext: 'hide-repeating-children', }); hideRepeatingChildrenOption.setAccelerator(UI.KeyboardShortcut.Keys.R, [UI.KeyboardShortcut.Modifiers.None]); hideRepeatingChildrenOption.setIsDevToolsPerformanceMenuItem(true); const resetChildrenOption = contextMenu.defaultSection().appendItem(i18nString(UIStrings.resetChildren), () => { this.modifyTree(PerfUI.FlameChart.FilterAction.RESET_CHILDREN, entryIndex); }, { disabled: !possibleActions?.[PerfUI.FlameChart.FilterAction.RESET_CHILDREN], jslogContext: 'reset-children', }); resetChildrenOption.setAccelerator(UI.KeyboardShortcut.Keys.U, [UI.KeyboardShortcut.Modifiers.None]); resetChildrenOption.setIsDevToolsPerformanceMenuItem(true); contextMenu.defaultSection().appendItem(i18nString(UIStrings.resetTrace), () => { this.modifyTree(PerfUI.FlameChart.FilterAction.UNDO_ALL_ACTIONS, entryIndex); }, { disabled: !possibleActions?.[PerfUI.FlameChart.FilterAction.UNDO_ALL_ACTIONS], jslogContext: 'reset-trace', }); if (!this.parsedTrace || Trace.Types.Events.isLegacyTimelineFrame(entry)) { return contextMenu; } const url = Utils.SourceMapsResolver.SourceMapsResolver.resolvedURLForEntry(this.parsedTrace, entry); if (!url) { return contextMenu; } if (Utils.IgnoreList.isIgnoreListedEntry(entry)) { contextMenu.defaultSection().appendItem(i18nString(UIStrings.removeScriptFromIgnoreList), () => { Bindings.IgnoreListManager.IgnoreListManager.instance().unIgnoreListURL(url); this.#onIgnoreListChanged(); }, { jslogContext: 'remove-from-ignore-list', }); } else { contextMenu.defaultSection().appendItem(i18nString(UIStrings.addScriptToIgnoreList), () => { Bindings.IgnoreListManager.IgnoreListManager.instance().ignoreListURL(url); this.#onIgnoreListChanged(); }, { jslogContext: 'add-to-ignore-list', }); } return contextMenu; } #onIgnoreListChanged(): void { this.timelineData(/* rebuild= */ true); this.dispatchEventToListeners(Events.DATA_CHANGED); } entryHasAnnotations(entryIndex: number): boolean { const event = this.eventByIndex(entryIndex); if (!event) { return false; } const annotations = ModificationsManager.activeManager()?.annotationsForEntry(event); return annotations ? annotations.length > 0 : false; } deleteAnnotationsForEntry(entryIndex: number): void { const event = this.eventByIndex(entryIndex); if (!event) { return; } ModificationsManager.activeManager()?.deleteEntryAnnotations(event); } modifyTree(action: PerfUI.FlameChart.FilterAction, entryIndex: number): void { const entry = this.entryData[entryIndex]; ModificationsManager.activeManager()?.getEntriesFilter().applyFilterAction({type: action, entry}); this.timelineData(true); this.buildFlowForInitiator(entryIndex); this.dispatchEventToListeners(Events.DATA_CHANGED); } findPossibleContextMenuActions(entryIndex: number): PerfUI.FlameChart.PossibleFilterActions|void { const entry = this.entryData[entryIndex]; return ModificationsManager.activeManager()?.getEntriesFilter().findPossibleActions(entry); } handleFlameChartTransformKeyboardEvent(event: KeyboardEvent, entryIndex: number, groupIndex: number): void { const possibleActions = this.getPossibleActions(entryIndex, groupIndex); if (!possibleActions) { return; } let handled = false; if (event.code === 'KeyH' && possibleActions[PerfUI.FlameChart.FilterAction.MERGE_FUNCTION]) { this.modifyTree(PerfUI.FlameChart.FilterAction.MERGE_FUNCTION, entryIndex); handled = true; } else if (event.code === 'KeyC' && possibleActions[PerfUI.FlameChart.FilterAction.COLLAPSE_FUNCTION]) { this.modifyTree(PerfUI.FlameChart.FilterAction.COLLAPSE_FUNCTION, entryIndex); handled = true; } else if ( event.code === 'KeyR' && possibleActions[PerfUI.FlameChart.FilterAction.COLLAPSE_REPEATING_DESCENDANTS]) { this.modifyTree(PerfUI.FlameChart.FilterAction.COLLAPSE_REPEATING_DESCENDANTS, entryIndex); handled = true; } else if (event.code === 'KeyU') { this.modifyTree(PerfUI.FlameChart.FilterAction.RESET_CHILDREN, entryIndex); handled = true; } if (handled) { event.consume(true); } } private buildGroupStyle(extra: Object): PerfUI.FlameChart.GroupStyle { const defaultGroupStyle = { padding: 4, height: 17, collapsible: true, color: ThemeSupport.ThemeSupport.instance().getComputedValue('--sys-color-on-surface'), backgroundColor: ThemeSupport.ThemeSupport.instance().getComputedValue('--sys-color-cdt-base-container'), nestingLevel: 0, shareHeaderLine: true, }; return Object.assign(defaultGroupStyle, extra); } setModel(parsedTrace: Trace.Handlers.Types.ParsedTrace, entityMapper: Utils.EntityMapper.EntityMapper): void { this.reset(); this.parsedTrace = parsedTrace; const {traceBounds} = parsedTrace.Meta; const minTime = Trace.Helpers.Timing.microToMilli(traceBounds.min); const maxTime = Trace.Helpers.Timing.microToMilli(traceBounds.max); this.#minimumBoundary = minTime; this.timeSpan = minTime === maxTime ? 1000 : maxTime - this.#minimumBoundary; this.#entityMapper = entityMapper; } /** * Instances and caches a CompatibilityTracksAppender using the * internal flame chart data and the trace parsed data coming from the * trace engine. * The model data must have been set to the data provider instance before * attempting to instance the CompatibilityTracksAppender. */ compatibilityTracksAppenderInstance(forceNew = false): CompatibilityTracksAppender { if (!this.compatibilityTracksAppender || forceNew) { if (!this.parsedTrace) { throw new Error( 'Attempted to instantiate a CompatibilityTracksAppender without having set the trace parse data first.'); } this.timelineDataInternal = this.#instantiateTimelineData(); this.compatibilityTracksAppender = new CompatibilityTracksAppender( this.timelineDataInternal, this.parsedTrace, this.entryData, this.entryTypeByLevel, this.#entityMapper); } return this.compatibilityTracksAppender; } /** * Returns the instance of the timeline flame chart data, without * adding data to it. In case the timeline data hasn't been instanced * creates a new instance and returns it. */ #instantiateTimelineData(): PerfUI.FlameChart.FlameChartTimelineData { if (!this.timelineDataInternal) { this.timelineDataInternal = PerfUI.FlameChart.FlameChartTimelineData.createEmpty(); } return this.timelineDataInternal; } /** * Builds the flame chart data whilst allowing for a custom filtering of track appenders. * This is ONLY to be used in test environments. */ buildWithCustomTracksForTest(options?: { /** * Filters the track by the given name. Only tracks that match this filter will be drawn. */ filterTracks?: (name: string, trackIndex: number) => boolean, /** * Choose if a given track is expanded based on the name */ expandTracks?: (name: string, trackIndex: number) => boolean, }): void { const compatAppender = this.compatibilityTracksAppenderInstance(); // Make sure the instance exists in tests const appenders = compatAppender.allVisibleTrackAppenders(); let visibleTrackIndexCounter = 0; for (const appender of appenders) { const trackName = appender instanceof ThreadAppender ? appender.trackName() : appender.appenderName; const shouldIncludeTrack = options?.filterTracks?.call(null, trackName, visibleTrackIndexCounter) ?? true; if (!shouldIncludeTrack) { continue; } const shouldExpandTrack = options?.expandTracks?.call(null, trackName, visibleTrackIndexCounter) ?? true; this.currentLevel = appender.appendTrackAtLevel(this.currentLevel, shouldExpandTrack); visibleTrackIndexCounter++; } } groupTreeEvents(group: PerfUI.FlameChart.Group): Trace.Types.Events.Event[]|null { return this.compatibilityTracksAppender?.groupEventsForTreeView(group) ?? null; } mainFrameNavigationStartEvents(): readonly Trace.Types.Events.NavigationStart[] { if (!this.parsedTrace) { return []; } return this.parsedTrace.Meta.mainFrameNavigations; } entryTitle(entryIndex: number): string|null { const entryType = this.#entryTypeForIndex(entryIndex); if (entryType === EntryType.SCREENSHOT) { return ''; } if (entryType === EntryType.TRACK_APPENDER) { const timelineData = (this.timelineDataInternal as PerfUI.FlameChart.FlameChartTimelineData); const eventLevel = timelineData.entryLevels[entryIndex]; const event = (this.entryData[entryIndex]); return this.compatibilityTracksAppender?.titleForEvent(event, eventLevel) || null; } let title: Common.UIString.LocalizedString|string = this.entryIndexToTitle[entryIndex]; if (!title) { title = `Unexpected entryIndex ${entryIndex}`; console.error(title); } return title; } textColor(index: number): string { const event = this.entryData[index]; return Utils.IgnoreList.isIgnoreListedEntry(event) ? '#888' : FlameChartStyle.textColor; } entryFont(_index: number): string|null { return this.#font; } /** * Clear the cache and rebuild the timeline data This should be called * when the trace file is the same but we want to rebuild the timeline * data. Some possible example: when we hide/unhide an event, or the * ignore list is changed etc. */ rebuildTimelineData(): void { this.currentLevel = 0; this.entryData = []; this.entryTypeByLevel = []; this.entryIndexToTitle = []; this.#eventIndexByEvent = new Map(); if (this.timelineDataInternal) { this.compatibilityTracksAppender?.setFlameChartDataAndEntryData( this.timelineDataInternal, this.entryData, this.entryTypeByLevel); this.compatibilityTracksAppender?.threadAppenders().forEach( threadAppender => threadAppender.setHeaderAppended(false)); } } /** * Reset all data other than the UI elements. * This should be called when * - initialized the data provider * - a new trace file is coming (when `setModel()` is called) * etc. */ reset(): void { this.currentLevel = 0; this.entryData = []; this.entryTypeByLevel = []; this.entryIndexToTitle = []; this.#eventIndexByEvent = new Map(); this.#minimumBoundary = 0; this.timeSpan = 0; this.compatibilityTracksAppender?.reset(); this.compatibilityTracksAppender = null; this.timelineDataInternal = null; this.parsedTrace = null; this.#entityMapper = null; this.#lastInitiatorEntryIndex = -1; this.#initiatorsCache.clear(); } maxStackDepth(): number { return this.currentLevel; } /** * Builds the flame chart data using the tracks appender (which use * the new trace engine). The result built data is cached and returned. */ timelineData(rebuild = false): PerfUI.FlameChart.FlameChartTimelineData { if (!rebuild && this.timelineDataInternal && this.timelineDataInternal.entryLevels.length !== 0) { // If the flame chart data is built already and we don't want to rebuild, we can return the cached data. // |entryLevels.length| is used to check if the cached data is not empty (correctly built), return this.timelineDataInternal; } this.timelineDataInternal = PerfUI.FlameChart.FlameChartTimelineData.createEmpty(); if (rebuild) { // This function will interact with the |compatibilityTracksAppender|, which needs the reference of // |timelineDataInternal|, so make sure this is called after the correct |timelineDataInternal|. this.rebuildTimelineData(); } this.currentLevel = 0; if (this.parsedTrace) { this.compatibilityTracksAppender = this.compatibilityTracksAppenderInstance(); // Note for readers: NodeJS CpuProfiles are purposefully NOT generic. // We wrap them in a `TracingStartedInPage` event, which causes them to // be treated like "real" Chrome traces. This is by design! if (this.parsedTrace.Meta.traceIsGeneric) { this.#processGenericTrace(); } else { this.#processInspectorTrace(); } } return this.timelineDataInternal; } #processGenericTrace(): void { if (!this.compatibilityTracksAppender) { return; } const appendersByProcess = this.compatibilityTracksAppender.allThreadAppendersByProcess(); for (const [pid, threadAppenders] of appendersByProcess) { const processGroupStyle = this.buildGroupStyle({shareHeaderLine: false}); const processName = this.parsedTrace?.Meta.processNames.get(pid)?.args.name || 'Process'; this.appendHeader(`${processName} (${pid})`, processGroupStyle, true, false); for (const appender of threadAppenders) { appender.setHeaderNestingLevel(1); this.currentLevel = appender.appendTrackAtLevel(this.currentLevel); } } } #processInspectorTrace(): void { // In CPU Profiles the trace data will not have frames nor // screenshots, so we can keep this call as it will be a no-op in // these cases. this.#appendFramesAndScreenshotsTrack(); const weight = (track: {type?: string, forMainFrame?: boolean, appenderName?: TrackAppenderName}): number => { switch (track.appenderName) { case 'Animations': return 0; case 'Timings': return 1; case 'Interactions': return 2; case 'LayoutShifts': return 3; case 'Extension': return 4; case 'Thread': return 5; case 'ServerTimings': return 6; case 'GPU': return 7; case 'Thread_AuctionWorklet': return 8; default: return 9; } }; const allTrackAppenders = this.compatibilityTracksAppender ? this.compatibilityTracksAppender.allVisibleTrackAppenders() : []; allTrackAppenders.sort((a, b) => weight(a) - weight(b)); for (const appender of allTrackAppenders) { if (!this.parsedTrace) { continue; } this.currentLevel = appender.appendTrackAtLevel(this.currentLevel); // If there is not a selected group, we want to default to selecting the // main thread track. Therefore in this check we look to see if the // current appender is a ThreadAppender and represnets the Main Thread. // If it is, we mark the group as selected. if (this.timelineDataInternal && !this.timelineDataInternal.selectedGroup) { if (appender instanceof ThreadAppender && (appender.threadType === Trace.Handlers.Threads.ThreadType.MAIN_THREAD || appender.threadType === Trace.Handlers.Threads.ThreadType.CPU_PROFILE)) { const group = this.compatibilityTracksAppender?.groupForAppender(appender); if (group) { this.timelineDataInternal.selectedGroup = group; } } } } if (this.timelineDataInternal?.selectedGroup) { this.timelineDataInternal.selectedGroup.expanded = true; } } minimumBoundary(): number { return this.#minimumBoundary; } totalTime(): number { return this.timeSpan; } search(visibleWindow: Trace.Types.Timing.TraceWindowMicro, filter?: Trace.Extras.TraceFilter.TraceFilter): PerfUI.FlameChart.DataProviderSearchResult[] { const results: PerfUI.FlameChart.DataProviderSearchResult[] = []; this.timelineData(); for (let i = 0; i < this.entryData.length; ++i) { const entry = this.entryData[i]; if (!entry) { continue; } if (Trace.Types.Events.isLegacyTimelineFrame(entry)) { continue; } if (Trace.Types.Events.isLegacyScreenshot(entry)) { // Screenshots are represented as trace events, but you can't search for them, so skip. continue; } if (!Trace.Helpers.Timing.eventIsInBounds(entry, visibleWindow)) { continue; } if (!filter || filter.accept(entry, this.parsedTrace || undefined)) { const startTimeMilli = Trace.Helpers.Timing.microToMilli(entry.ts); results.push({index: i, startTimeMilli, provider: 'main'}); } } return results; } getEntryTypeForLevel(level: number): EntryType { return this.entryTypeByLevel[level]; } /** * The frames and screenshots track is special cased because it is rendered * differently to the rest of the tracks and not as a series of events. This * is why it is not done via the appender system; we track frames & * screenshots as a different EntryType to the TrackAppender entries, * because then when it comes to drawing we can decorate them differently. **/ #appendFramesAndScreenshotsTrack(): void { if (!this.parsedTrace) { return; } const filmStrip = Trace.Extras.FilmStrip.fromParsedTrace(this.parsedTrace); const hasScreenshots = filmStrip.frames.length > 0; const hasFrames = this.parsedTrace.Frames.frames.length > 0; if (!hasFrames && !hasScreenshots) { return; } this.framesGroupStyle.collapsible = hasScreenshots; const expanded = Root.Runtime.Runtime.queryParam('flamechart-force-expand') === 'frames'; this.appendHeader(i18nString(UIStrings.frames), this.framesGroupStyle, false /* selectable */, expanded); this.entryTypeByLevel[this.currentLevel] = EntryType.FRAME; for (const frame of this.parsedTrace.Frames.frames) { this.#appendFrame(frame); } ++this.currentLevel; if (!hasScreenshots) { return; } this.#appendScreenshots(filmStrip); } #appendScreenshots(filmStrip: Trace.Extras.FilmStrip.Data): void { if (!this.timelineDataInternal || !this.parsedTrace) { return; } this.appendHeader('', this.screenshotsGroupStyle, false /* selectable */); this.entryTypeByLevel[this.currentLevel] = EntryType.SCREENSHOT; let prevTimestamp: Trace.Types.Timing.Milli|undefined = undefined; for (const filmStripFrame of filmStrip.frames) { const screenshotTimeInMilliSeconds = Trace.Helpers.Timing.microToMilli(filmStripFrame.screenshotEvent.ts); this.entryData.push(filmStripFrame.screenshotEvent); (this.timelineDataInternal.entryLevels as number[]).push(this.currentLevel); (this.timelineDataInternal.entryStartTimes as number[]).push(screenshotTimeInMilliSeconds); if (prevTimestamp) { (this.timelineDataInternal.entryTotalTimes as number[]).push(screenshotTimeInMilliSeconds - prevTimestamp); } prevTimestamp = screenshotTimeInMilliSeconds; } if (filmStrip.frames.length && prevTimestamp !== undefined) { const maxRecordTimeMillis = Trace.Helpers.Timing.traceWindowMilliSeconds(this.parsedTrace.Meta.traceBounds).max; // Set the total time of the final screenshot so it takes up the remainder of the trace. (this.timelineDataInternal.entryTotalTimes as number[]).push(maxRecordTimeMillis - prevTimestamp); } ++this.currentLevel; } #entryTypeForIndex(entryIndex: number): EntryType { const level = this.timelineData().entryLevels[entryIndex]; return this.entryTypeByLevel[level]; } preparePopoverElement(entryIndex: number): Element|null { let time = ''; let title; let warningElements: Element[] = []; let timeElementClassName = 'popoverinfo-time'; const additionalContent: HTMLElement[] = []; const entryType = this.#entryTypeForIndex(entryIndex); if (entryType === EntryType.TRACK_APPENDER) { if (!this.compatibilityTracksAppender) { return null; } const event = (this.entryData[entryIndex]); const timelineData = (this.timelineDataInternal as PerfUI.FlameChart.FlameChartTimelineData); const eventLevel = timelineData.entryLevels[entryIndex]; const popoverInfo = this.compatibilityTracksAppender.popoverInfo(event, eventLevel); title = popoverInfo.title; time = popoverInfo.formattedTime; warningElements = popoverInfo.warningElements || warningElements; if (popoverInfo.additionalElements?.length) { additionalContent.push(...popoverInfo.additionalElements); } this.dispatchEventToListeners(Events.FLAME_CHART_ITEM_HOVERED, event); } else if (entryType === EntryType.FRAME) { const frame = (this.entryData[entryIndex] as Trace.Types.Events.LegacyTimelineFrame); time = i18n.TimeUtilities.preciseMillisToString(Trace.Helpers.Timing.microToMilli(frame.duration), 1); if (frame.idle) { title = i18nString(UIStrings.idleFrame); } else if (frame.dropped) { title = frame.isPartial ? i18nString(UIStrings.partiallyPresentedFrame) : i18nString(UIStrings.droppedFrame); timeElementClassName = 'popoverinfo-warning'; } else { title = i18nString(UIStrings.frame); } } else { this.dispatchEventToListeners(Events.FLAME_CHART_ITEM_HOVERED, null); return null; } const popoverElement = document.createElement('div'); const root = UI.UIUtils.createShadowRootWithCoreStyles(popoverElement, {cssFile: timelineFlamechartPopoverStyles}); const popoverContents = root.createChild('div', 'timeline-flamechart-popover'); popoverContents.createChild('span', timeElementClassName).textContent = time; popoverContents.createChild('span', 'popoverinfo-title').textContent = title; for (const warningElement of warningElements) { warningElement.classList.add('popoverinfo-warning'); popoverContents.appendChild(warningElement); } for (const elem of additionalContent) { popoverContents.appendChild(elem); } return popoverElement; } preparePopoverForCollapsedArrow(entryIndex: number): Element|null { const element = document.createElement('div'); const root = UI.UIUtils.createShadowRootWithCoreStyles(element, {cssFile: timelineFlamechartPopoverStyles}); const entry = this.entryData[entryIndex]; const hiddenEntriesAmount = ModificationsManager.activeManager()?.getEntriesFilter().findHiddenDescendantsAmount(entry); if (!hiddenEntriesAmount) { return null; } const contents = root.createChild('div', 'timeline-flamechart-popover'); contents.createChild('span', 'popoverinfo-title').textContent = hiddenEntriesAmount + ' hidden'; return element; } getDrawOverride(entryIndex: number): DrawOverride|undefined { const entryType = this.#entryTypeForIndex(entryIndex); if (entryType !== EntryType.TRACK_APPENDER) { return; } const timelineData = (this.timelineDataInternal as PerfUI.FlameChart.FlameChartTimelineData); const eventLevel = timelineData.entryLevels[entryIndex]; const event = (this.entryData[entryIndex]); return this.compatibilityTracksAppender?.getDrawOverride(event, eventLevel); } #entryColorForFrame(entryIndex: number): string { const frame = (this.entryData[entryIndex] as Trace.Types.Events.LegacyTimelineFrame); if (frame.idle) { return 'white'; } if (frame.dropped) { if (frame.isPartial) { // For partially presented frame boxes, paint a yellow background with // a sparse white dashed-line pattern overlay. return '#f0e442'; } // For dropped frame boxes, paint a red background with a dense white // solid-line pattern overlay. return '#f08080'; } return '#d7f0d1'; } entryColor(entryIndex: number): string { const entryType = this.#entryTypeForIndex(entryIndex); if (entryType === EntryType.FRAME) { return this.#entryColorForFrame(entryIndex); } if (entryType === EntryType.TRACK_APPENDER) { const timelineData = (this.timelineDataInternal as PerfUI.FlameChart.FlameChartTimelineData); const eventLevel = timelineData.entryLevels[entryIndex]; const event = (this.entryData[entryIndex]); return this.compatibilityTracksAppender?.colorForEvent(event, eventLevel) || ''; } return ''; } private preparePatternCanvas(): Array<CanvasPattern|null> { // Set the candy stripe pattern to 17px so it repeats well. const size = 17; const droppedFrameCanvas = document.createElement('canvas'); const partialFrameCanvas = document.createElement('canvas'); droppedFrameCanvas.width = droppedFrameCanvas.height = size; partialFrameCanvas.width = partialFrameCanvas.height = size; const ctx = droppedFrameCanvas.getContext('2d', {willReadFrequently: true}) as CanvasRenderingContext2D; // Make a dense solid-line pattern. ctx.translate(size * 0.5, size * 0.5); ctx.rotate(Math.PI * 0.25); ctx.translate(-size * 0.5, -size * 0.5); ctx.fillStyle = 'rgb(255, 255, 255)'; for (let x = -size; x < size * 2; x += 3) { ctx.fillRect(x, -size, 1, size * 3); } const droppedFramePattern = ctx.createPattern(droppedFrameCanvas, 'repeat'); const ctx2 = partialFrameCanvas.getContext('2d', {willReadFrequently: true}) as CanvasRenderingContext2D; // Make a sparse dashed-line pattern. ctx2.strokeStyle = 'rgb(255, 255, 255)'; ctx2.lineWidth = 2; ctx2.beginPath(); ctx2.moveTo(17, 0); ctx2.lineTo(10, 7); ctx2.moveTo(8, 9); ctx2.lineTo(2, 15); ctx2.stroke(); const partialFramePattern = ctx.createPattern(partialFrameCanvas, 'repeat'); return [droppedFramePattern, partialFramePattern]; } private drawFrame( entryIndex: number, context: CanvasRenderingContext2D, barX: number, barY: number, barWidth: number, barHeight: number, transformColor: (color: string) => string): void { const hPadding = 1; const frame = this.entryData[entryIndex] as Trace.Types.Events.LegacyTimelineFrame; barX += hPadding; barWidth -= 2 * hPadding; context.fillStyle = transformColor(this.entryColor(entryIndex)); if (frame.dropped) { context.fillRect(barX, barY, barWidth, barHeight); if (frame.isPartial) { // For partially presented frame boxes, paint a yellow background with // a sparse white dashed-line pattern overlay. context.fillStyle = this.partialFramePattern || context.fillStyle; } else { // For dropped frame boxes, paint a red background with a dense white // solid-line pattern overlay. context.fillStyle = this.droppedFramePattern || context.fillStyle; } } context.fillRect(barX, barY, barWidth, barHeight); const frameDurationText = i18n.TimeUtilities.preciseMillisToString(Trace.Helpers.Timing.microToMilli(frame.duration), 1); const textWidth = context.measureText(frameDurationText).width; if (textWidth <= barWidth) { context.fillStyle = this.textColor(entryIndex); context.fillText(frameDurationText, barX + (barWidth - textWidth) / 2, barY + barHeight - 4); } } private async drawScreenshot( entryIndex: number, context: CanvasRenderingContext2D, barX: number, barY: number, barWidth: number, barHeight: number): Promise<void> { const screenshot = (this.entryData[entryIndex] as Trace.Types.Events.LegacySyntheticScreenshot); const image = Utils.ImageCache.getOrQueue(screenshot); if (!image) { return; } const imageX = barX + 1; const imageY = barY + 1; const imageHeight = barHeight - 2; const scale = imageHeight / image.naturalHeight; const imageWidth = Math.floor(image.naturalWidth * scale); context.save(); context.beginPath(); context.rect(barX, barY, barWidth, barHeight); context.clip(); context.drawImage(image, imageX, imageY, imageWidth, imageHeight); context.strokeStyle = '#ccc'; context.strokeRect(imageX - 0.5, imageY - 0.5, Math.min(barWidth - 1, imageWidth + 1), imageHeight); context.restore(); } decorateEntry( entryIndex: number, context: CanvasRenderingContext2D, text: string|null, barX: number, barY: number, barWidth: number, barHeight: number, unclippedBarX: number, timeToPixelRatio: number, transformColor: (color: string) => string): boolean { const entryType = this.#entryTypeForIndex(entryIndex); if (entryType === EntryType.FRAME) { this.drawFrame(entryIndex, context, barX, barY, barWidth, barHeight, transformColor); return true; } if (entryType === EntryType.SCREENSHOT) { void this.drawScreenshot(entryIndex, context, barX, barY, barWidth, barHeight); return true; } if (entryType === EntryType.TRACK_APPENDER) { const entry = this.entryData[entryIndex]; if (Trace.Types.Events.isSyntheticInteraction(entry)) { this.#drawInteractionEventWithWhiskers( context, entryIndex, text, entry, barX, barY, unclippedBarX, barWidth, barHeight, timeToPixelRatio); return true; } } return false; } /** * Draws the left and right whiskers around an interaction in the timeline. * @param context - the canvas that will be drawn onto * @param entryIndex * @param entryTitle - the title of the entry * @param entry - the entry itself * @param barX - the starting X pixel position of the bar representing this event. This is clipped: if the bar is off the left side of the screen, this value will be 0 * @param barY - the starting Y pixel position of the bar representing this event. * @param unclippedBarXStartPixel - the starting X pixel position of the bar representing this event, not clipped. This means if the bar is off the left of the screen this will be a negative number. * @param barWidth - the width of the full bar in pixels * @param barHeight - the height of the full bar in pixels * @param timeToPixelRatio - the ratio required to convert a millisecond time to a pixel value. **/ #drawInteractionEventWithWhiskers( context: CanvasRenderingContext2D, entryIndex: number, entryTitle: string|null, entry: Trace.Types.Events.SyntheticInteractionPair, barX: number, barY: number, unclippedBarXStartPixel: number, barWidth: number, barHeight: number, timeToPixelRatio: number): void { /** * An interaction is drawn with whiskers as so: * |----------[=======]-------------| * => The left whisker is the event's start time (event.ts) * => The box start is the event's processingStart time * => The box end is the event's processingEnd time * => The right whisker is the event's end time (event.ts + event.dur) * * When we draw the event in the InteractionsAppender, we draw a huge box * that spans the entire of the above. So here we need to draw over the * rectangle that is outside of {processingStart, processingEnd} and * replace it with the whiskers. * TODO(crbug.com/1495248): rework how we draw whiskers to avoid this inefficiency */ const beginTime = Trace.Helpers.Timing.microToMilli(entry.ts); const entireBarEndXPixel = barX + barWidth; function timeToPixel(time: Trace.Types.Timing.Micro): number { const timeMilli = Trace.Helpers.Timing.microToMilli(time); return Math.floor(unclippedBarXStartPixel + (timeMilli - beginTime) * timeToPixelRatio); } context.save(); // Clear portions of initial rect to prepare for the ticks. context.fillStyle = ThemeSupport.ThemeSupport.instance().getComputedValue('--sys-color-cdt-base-container'); let desiredBoxStartX = timeToPixel(entry.processingStart); const desiredBoxEndX = timeToPixel(entry.processingEnd); // If the entry has no processing duration, ensure the box is 1px wide so at least it is visible. if (entry.processingEnd - entry.processingStart === 0) { desiredBoxStartX -= 1; } context.fillRect(barX, barY - 0.5, desiredBoxStartX - barX, barHeight); context.fillRect(desiredBoxEndX, barY - 0.5, entireBarEndXPixel - desiredBoxEndX, barHeight); // Draws left and right whiskers function drawTick(begin: number, end: number, y: number): void { const tickHeightPx = 6; context.moveTo(begin, y - tickHeightPx / 2); context.lineTo(begin, y + tickHeightPx / 2); context.moveTo(begin, y); context.lineTo(end, y); } // The left whisker starts at the enty timestamp, and continues until the start of the box (processingStart). const leftWhiskerX = timeToPixel(entry.ts); // The right whisker ends at (entry.ts + entry.dur). We draw the line from the end of the box (processingEnd). const rightWhiskerX = timeToPixel(Trace.Types.Timing.Micro(entry.ts + entry.dur)); context.beginPath(); context.lineWidth = 1; context.strokeStyle = '#ccc'; const lineY = Math.floor(barY + barHeight / 2) + 0.5; const leftTick = leftWhiskerX + 0.5; const rightTick = rightWhiskerX - 0.5; drawTick(leftTick, desiredBoxStartX, lineY); drawTick(rightTick, desiredBoxEndX, lineY); context.stroke(); if (entryTitle) { // BarX will be set to 0 if the start of the box if off the screen to the // left. If this happens, the desiredBoxStartX will be negative. In that // case, we fallback to the BarX. This ensures that even if the box // starts off-screen, we draw the text at the first visible on screen // pixels, so the user can still see the event's title. const textStartX = desiredBoxStartX > 0 ? desiredBoxStartX : barX; context.font = this.#font; const textWidth = UI.UIUtils.measureTextWidth(context, entryTitle); // These numbers are duplicated from FlameChart.ts. const textPadding = 5; const textBaseline = 5; // Only draw the text if it can fit in the amount of box that is visible. if (textWidth <= desiredBoxEndX - textStartX + textPadding) { context.fillStyle = this.textColor(entryIndex); context.fillText(entryTitle, textStartX + textPadding, barY + barHeight - textBaseline); } } context.restore(); } forceDecoration(entryIndex: number): boolean { const entryType = this.#entryTypeForIndex(entryIndex); if (entryType === EntryType.FRAME) { return true; } if (entryType === EntryType.SCREENSHOT) { return true; } const event = (this.entryData[entryIndex]); if (Trace.Types.Events.isSyntheticInteraction(event)) { // We draw interactions with whiskers, which are done via the // decorateEntry() method, hence we always want to force these to be // decorated. return true; } return Boolean(this.parsedTrace?.Warnings.perEvent.get(event)); } private appendHeader(title: string, style: PerfUI.FlameChart.GroupStyle, selectable: boolean, expanded?: boolean): PerfUI.FlameChart.Group { const group = ({startLevel: this.currentLevel, name: title, style, selectable, expanded} as PerfUI.FlameChart.Group); (this.timelineDataInternal as PerfUI.FlameChart.FlameChartTimelineData).groups.push(group); return group; } #appendFrame(frame: Trace.Types.Events.LegacyTimelineFrame): void { const index = this.entryData.length; this.entryData.push(frame); const durationMilliseconds = Trace.Helpers.Timing.microToMilli(frame.duration); this.entryIndexToTitle[index] = i18n.TimeUtilities.millisToString(durationMilliseconds, true); if (!this.timelineDataInternal) { return; } this.timelineDataInternal.entryLevels[index] = this.currentLevel; this.timelineDataInternal.entryTotalTimes[index] = durationMilliseconds; this.timelineDataInternal.entryStartTimes[index] = Trace.Helpers.Timing.microToMilli(frame.startTime); } createSelection(entryIndex: number): TimelineSelection|null { const entry = this.entryData[entryIndex]; const timelineSelection: TimelineSelection|null = entry ? selectionFromEvent(entry) : null; if (timelineSelection) { this.lastSelection = new Selection(timelineSelection, entryIndex); } return timelineSelection; } formatValue(value: number, precision?: number): string { return i18n.TimeUtilities.preciseMillisToString(value, precision); } groupForEvent