| // Copyright 2021 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| /* eslint-disable @devtools/no-imperative-dom-api */ |
| |
| /* |
| * Copyright (C) 2013 Google Inc. All rights reserved. |
| * Copyright (C) 2012 Intel 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. |
| */ |
| |
| import '../../ui/kit/kit.js'; |
| |
| 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 type * as Protocol from '../../generated/protocol.js'; |
| import * as Bindings from '../../models/bindings/bindings.js'; |
| import * as TextUtils from '../../models/text_utils/text_utils.js'; |
| import * as Trace from '../../models/trace/trace.js'; |
| import * as SourceMapsResolver from '../../models/trace_source_maps_resolver/trace_source_maps_resolver.js'; |
| import * as TraceBounds from '../../services/trace_bounds/trace_bounds.js'; |
| import * as Tracing from '../../services/tracing/tracing.js'; |
| import * as CodeHighlighter from '../../ui/components/code_highlighter/code_highlighter.js'; |
| // eslint-disable-next-line @devtools/es-modules-import |
| import codeHighlighterStyles from '../../ui/components/code_highlighter/codeHighlighter.css.js'; |
| import * as uiI18n from '../../ui/i18n/i18n.js'; |
| import * as PerfUI from '../../ui/legacy/components/perf_ui/perf_ui.js'; |
| // eslint-disable-next-line @devtools/es-modules-import |
| import imagePreviewStyles from '../../ui/legacy/components/utils/imagePreview.css.js'; |
| import * as LegacyComponents from '../../ui/legacy/components/utils/utils.js'; |
| import * as UI from '../../ui/legacy/legacy.js'; |
| import * as ThemeSupport from '../../ui/legacy/theme_support/theme_support.js'; |
| import {html, render} from '../../ui/lit/lit.js'; |
| import * as PanelsCommon from '../common/common.js'; |
| |
| import {getDurationString} from './AppenderUtils.js'; |
| import * as TimelineComponents from './components/components.js'; |
| import * as Extensions from './extensions/extensions.js'; |
| import {ModificationsManager} from './ModificationsManager.js'; |
| import {targetForEvent} from './TargetForEvent.js'; |
| import {TimelinePanel} from './TimelinePanel.js'; |
| import {selectionFromEvent} from './TimelineSelection.js'; |
| import * as Utils from './utils/utils.js'; |
| |
| const UIStrings = { |
| /** |
| * @description Text that only contain a placeholder |
| * @example {100ms (at 200ms)} PH1 |
| */ |
| emptyPlaceholder: '{PH1}', // eslint-disable-line @devtools/l10n-no-locked-or-placeholder-only-phrase |
| /** |
| * @description Text for timestamps of items |
| */ |
| timestamp: 'Timestamp', |
| /** |
| * @description Text shown next to the interaction event's ID in the detail view. |
| */ |
| interactionID: 'ID', |
| /** |
| * @description Text shown next to the interaction event's input delay time in the detail view. |
| */ |
| inputDelay: 'Input delay', |
| /** |
| * @description Text shown next to the interaction event's thread processing duration in the detail view. |
| */ |
| processingDuration: 'Processing duration', |
| /** |
| * @description Text shown next to the interaction event's presentation delay time in the detail view. |
| */ |
| presentationDelay: 'Presentation delay', |
| /** |
| * @description Text shown when the user has selected an event that represents script compiliation. |
| */ |
| compile: 'Compile', |
| /** |
| * @description Text shown when the user selects an event that represents script parsing. |
| */ |
| parse: 'Parse', |
| /** |
| * @description Text with two placeholders separated by a colon |
| * @example {Node removed} PH1 |
| * @example {div#id1} PH2 |
| */ |
| sS: '{PH1}: {PH2}', |
| /** |
| * @description Text that is usually a hyperlink to more documentation |
| */ |
| learnMore: 'Learn more', |
| /** |
| * @description Text referring to the status of the browser's compilation cache. |
| */ |
| compilationCacheStatus: 'Compilation cache status', |
| /** |
| * @description Text referring to the size of the browser's compiliation cache. |
| */ |
| compilationCacheSize: 'Compilation cache size', |
| /** |
| * @description Text in Timeline UIUtils of the Performance panel. "Compilation |
| * cache" refers to the code cache described at |
| * https://v8.dev/blog/code-caching-for-devs . This label is followed by the |
| * type of code cache data used, either "normal" or "full" as described in the |
| * linked article. |
| */ |
| compilationCacheKind: 'Compilation cache kind', |
| /** |
| * @description Text used to inform the user that the script they are looking |
| * at was loaded from the browser's cache. |
| */ |
| scriptLoadedFromCache: 'script loaded from cache', |
| /** |
| * @description Text to inform the user that the script they are looking at |
| * was unable to be loaded from the browser's cache. |
| */ |
| failedToLoadScriptFromCache: 'failed to load script from cache', |
| /** |
| * @description Text to inform the user that the script they are looking at was not eligible to be loaded from the browser's cache. |
| */ |
| scriptNotEligibleToBeLoadedFromCache: 'script not eligible', |
| /** |
| * @description Label in the summary view in the Performance panel for a number which indicates how much managed memory has been reclaimed by performing Garbage Collection |
| */ |
| collected: 'Collected', |
| /** |
| * @description Text for a programming function |
| */ |
| function: 'Function', |
| /** |
| * @description Text for referring to the ID of a timer. |
| */ |
| timerId: 'Timer ID', |
| /** |
| * @description Text for referring to a timer that has timed-out and therefore is being removed. |
| */ |
| timeout: 'Timeout', |
| /** |
| * @description Text used to refer to a positive timeout value that schedules the idle callback once elapsed, even if no idle time is available. |
| */ |
| requestIdleCallbackTimeout: 'Timeout', |
| /** |
| * @description Text used to indicate that a timer is repeating (e.g. every X seconds) rather than a one off. |
| */ |
| repeats: 'Repeats', |
| /** |
| * @description Text for referring to the ID of a callback function installed by an event. |
| */ |
| callbackId: 'Callback ID', |
| /** |
| * @description Text for a module, the programming concept |
| */ |
| module: 'Module', |
| /** |
| * @description Label for a group of JavaScript files |
| */ |
| script: 'Script', |
| /** |
| * @description Text used to tell a user that a compilation trace event was streamed. |
| */ |
| streamed: 'Streamed', |
| /** |
| * @description Text to indicate if a compilation event was eager. |
| */ |
| eagerCompile: 'Compiling all functions eagerly', |
| /** |
| * @description Text to refer to the URL associated with a given event. |
| */ |
| url: 'Url', |
| /** |
| * @description Text to indicate to the user the size of the cache (as a filesize - e.g. 5mb). |
| */ |
| producedCacheSize: 'Produced cache size', |
| /** |
| * @description Text to indicate to the user the amount of the cache (as a filesize - e.g. 5mb) that has been used. |
| */ |
| consumedCacheSize: 'Consumed cache size', |
| /** |
| * @description Related node label in Timeline UIUtils of the Performance panel |
| */ |
| layerRoot: 'Layer root', |
| /** |
| * @description Related node label in Timeline UIUtils of the Performance panel |
| */ |
| ownerElement: 'Owner element', |
| /** |
| * @description Text used to show the user the URL of the image they are viewing. |
| */ |
| imageUrl: 'Image URL', |
| /** |
| * @description Text used to show the user that the URL they are viewing is loading a CSS stylesheet. |
| */ |
| stylesheetUrl: 'Stylesheet URL', |
| /** |
| * @description Text used next to a number to show the user how many elements were affected. |
| */ |
| elementsAffected: 'Elements affected', |
| /** |
| * @description Text used next to a number to show the user how many nodes required the browser to update and re-layout the page. |
| */ |
| nodesThatNeedLayout: 'Nodes that need layout', |
| /** |
| * @description Text used to show the amount in a subset - e.g. "2 of 10". |
| * @example {2} PH1 |
| * @example {10} PH2 |
| */ |
| sOfS: '{PH1} of {PH2}', |
| /** |
| * @description Related node label in Timeline UIUtils of the Performance panel |
| */ |
| layoutRoot: 'Layout root', |
| /** |
| * @description Text used when viewing an event that can have a custom message attached. |
| */ |
| message: 'Message', |
| /** |
| * @description Text used to tell the user they are viewing an event that has a function embedded in it, which is referred to as the "callback function". |
| */ |
| callbackFunction: 'Callback function', |
| /** |
| * @description Text used to show the relevant range of a file - e.g. "lines 2-10". |
| */ |
| range: 'Range', |
| /** |
| * @description Text used to refer to the amount of time some event or code was given to complete within. |
| */ |
| allottedTime: 'Allotted time', |
| /** |
| * @description Text used to tell a user that a particular event or function was automatically run by a timeout. |
| */ |
| invokedByTimeout: 'Invoked by timeout', |
| /** |
| * @description Text that refers to some types |
| */ |
| type: 'Type', |
| /** |
| * @description Text for the size of something |
| */ |
| size: 'Size', |
| /** |
| * @description Text for the details of something |
| */ |
| details: 'Details', |
| /** |
| * @description Text to indicate an item is a warning |
| */ |
| warning: 'Warning', |
| /** |
| * @description Text that indicates a particular HTML element or node is related to what the user is viewing. |
| */ |
| relatedNode: 'Related node', |
| /** |
| * @description Text for previewing items |
| */ |
| preview: 'Preview', |
| /** |
| * @description Text used to refer to the total time summed up across multiple events. |
| */ |
| aggregatedTime: 'Aggregated time', |
| /** |
| * @description Text for the duration of something |
| */ |
| duration: 'Duration', |
| /** |
| * @description Text for the stack trace of the initiator of something. The Initiator is the event or factor that directly triggered or precipitated a subsequent action. |
| */ |
| initiatorStackTrace: 'Initiator stack trace', |
| /** |
| * @description Text for the event initiated by another one |
| */ |
| initiatedBy: 'Initiated by', |
| /** |
| * @description Text for the event that is an initiator for another one |
| */ |
| initiatorFor: 'Initiator for', |
| /** |
| * @description Text for the underlying data behing a specific flamechart selection. Trace events are the browser instrumentation that are emitted as JSON objects. |
| */ |
| traceEvent: 'Trace event', |
| /** |
| * @description Call site stack label in Timeline UIUtils of the Performance panel |
| */ |
| timerInstalled: 'Timer installed', |
| /** |
| * @description Call site stack label in Timeline UIUtils of the Performance panel |
| */ |
| animationFrameRequested: 'Animation frame requested', |
| /** |
| * @description Call site stack label in Timeline UIUtils of the Performance panel |
| */ |
| idleCallbackRequested: 'Idle callback requested', |
| /** |
| * @description Call site stack label in Timeline UIUtils of the Performance panel |
| */ |
| firstLayoutInvalidation: 'First layout invalidation', |
| /** |
| * @description Label in front of CSS property (eg `opacity`) being animated or a CSS animation name (eg `layer-4-fade-in-out`) |
| */ |
| animating: 'Animating', |
| /** |
| * @description Label in front of reasons why a CSS animation wasn't composited (aka hardware accelerated) |
| */ |
| compositingFailed: 'Compositing failed', |
| /** Descriptive reason for why a user-provided animation failed to be optimized by the browser due to accelerated animations being disabled. Shown in a table with a list of other potential failure reasons. */ |
| compositingFailedAcceleratedAnimationsDisabled: 'Accelerated animations disabled', |
| /** Descriptive reason for why a user-provided animation failed to be optimized by the browser due to DevTools suppressing the effect. Shown in a table with a list of other potential failure reasons. */ |
| compositingFailedEffectSuppressedByDevtools: 'Effect suppressed by DevTools ', |
| /** Descriptive reason for why a user-provided animation failed to be optimized by the browser due to the animation or effect being invalid. Shown in a table with a list of other potential failure reasons. */ |
| compositingFailedInvalidAnimationOrEffect: 'Invalid animation or effect', |
| /** Descriptive reason for why a user-provided animation failed to be optimized by the browser due to an effect having unsupported timing parameters. Shown in a table with a list of other potential failure reasons. */ |
| compositingFailedEffectHasUnsupportedTimingParams: 'Effect has unsupported timing parameters', |
| /** Descriptive reason for why a user-provided animation failed to be optimized by the browser due to an effect having a composite mode which is not `replace`. Shown in a table with a list of other potential failure reasons. */ |
| compositingFailedEffectHasNonReplaceCompositeMode: 'Effect has composite mode other than "replace"', |
| /** Descriptive reason for why a user-provided animation failed to be optimized by the browser due to the target being in an invalid compositing state. Shown in a table with a list of other potential failure reasons. */ |
| compositingFailedTargetHasInvalidCompositingState: 'Target has invalid compositing state', |
| /** Descriptive reason for why a user-provided animation failed to be optimized by the browser due to another animation on the same target being incompatible. Shown in a table with a list of other potential failure reasons. */ |
| compositingFailedTargetHasIncompatibleAnimations: 'Target has another animation which is incompatible', |
| /** Descriptive reason for why a user-provided animation failed to be optimized by the browser due to the target having a CSS offset. Shown in a table with a list of other potential failure reasons. */ |
| compositingFailedTargetHasCSSOffset: 'Target has CSS offset', |
| /** Descriptive reason for why a user-provided animation failed to be optimized by the browser due to the animation affecting non-CSS properties. Shown in a table with a list of other potential failure reasons. */ |
| compositingFailedAnimationAffectsNonCSSProperties: 'Animation affects non-CSS properties', |
| /** Descriptive reason for why a user-provided animation failed to be optimized by the browser due to the transform-related property not being able to be animated on the target. Shown in a table with a list of other potential failure reasons. */ |
| compositingFailedTransformRelatedPropertyCannotBeAcceleratedOnTarget: |
| 'Transform-related property cannot be accelerated on target', |
| /** Descriptive reason for why a user-provided animation failed to be optimized by the browser due to a `transform` property being dependent on the size of the element itself. Shown in a table with a list of other potential failure reasons. */ |
| compositingFailedTransformDependsBoxSize: 'Transform-related property depends on box size', |
| /** Descriptive reason for why a user-provided animation failed to be optimized by the browser due to a `filter` property possibly moving pixels. Shown in a table with a list of other potential failure reasons. */ |
| compositingFailedFilterRelatedPropertyMayMovePixels: 'Filter-related property may move pixels', |
| /** |
| * @description [ICU Syntax] Descriptive reason for why a user-provided animation failed to be optimized by the browser due to the animated CSS property not being supported on the compositor. Shown in a table with a list of other potential failure reasons. |
| * @example {height, width} properties |
| */ |
| compositingFailedUnsupportedCSSProperty: `{propertyCount, plural, |
| =1 {Unsupported CSS property: {properties}} |
| other {Unsupported CSS properties: {properties}} |
| }`, |
| /** Descriptive reason for why a user-provided animation failed to be optimized by the browser due to mixing keyframe value types. Shown in a table with a list of other potential failure reasons. */ |
| compositingFailedMixedKeyframeValueTypes: 'Mixed keyframe value types', |
| /** Descriptive reason for why a user-provided animation failed to be optimized by the browser due to the timeline source being in an invalid compositing state. Shown in a table with a list of other potential failure reasons. */ |
| compositingFailedTimelineSourceHasInvalidCompositingState: 'Timeline source has invalid compositing state', |
| /** Descriptive reason for why a user-provided animation failed to be optimized by the browser due to the animation having no visible change. Shown in a table with a list of other potential failure reasons. */ |
| compositingFailedAnimationHasNoVisibleChange: 'Animation has no visible change', |
| /** Descriptive reason for why a user-provided animation failed to be optimized by the browser due to an effect affecting an important property. Shown in a table with a list of other potential failure reasons. */ |
| compositingFailedAffectsImportantProperty: 'Effect affects a property with !important', |
| /** Descriptive reason for why a user-provided animation failed to be optimized by the browser due to the SVG target having an independent transfrom property. Shown in a table with a list of other potential failure reasons. */ |
| compositingFailedSVGTargetHasIndependentTransformProperty: 'SVG target has independent transform property', |
| /** Descriptive reason for why a user-provided animation failed to be optimized by the browser due to an unknown reason. Shown in a table with a list of other potential failure reasons. */ |
| compositingFailedUnknownReason: 'Unknown Reason', |
| |
| /** |
| * @description Text for the execution "stack trace". It is not technically a stack trace, because it points to the beginning of each function |
| * and not to each call site, so we call it a function stack instead to avoid confusion. |
| */ |
| functionStack: 'Function stack', |
| /** |
| * @description Text used to show any invalidations for a particular event that caused the browser to have to do more work to update the page. |
| * @example {2} PH1 |
| */ |
| invalidations: 'Invalidations ({PH1} total)', |
| /** |
| * @description Text in Timeline UIUtils of the Performance panel. Phrase is followed by a number of milliseconds. |
| * Some events or tasks might have been only started, but have not ended yet. Such events or tasks are considered |
| * "pending". |
| */ |
| pendingFor: 'Pending for', |
| /** |
| * @description Noun label for a stack trace which indicates the first time some condition was invalidated. |
| */ |
| firstInvalidated: 'First invalidated', |
| /** |
| * @description Title of the paint profiler, old name of the performance pane |
| */ |
| paintProfiler: 'Paint profiler', |
| /** |
| * @description Text in Timeline Flame Chart View of the Performance panel |
| * @example {Frame} PH1 |
| * @example {10ms} PH2 |
| */ |
| sAtS: '{PH1} at {PH2}', |
| /** |
| * @description Text used next to a time to indicate that the particular event took that much time itself. In context this might look like "3ms blink.console (self)" |
| * @example {blink.console} PH1 |
| */ |
| sSelf: '{PH1} (self)', |
| /** |
| * @description Text used next to a time to indicate that the event's children took that much time. In context this might look like "3ms blink.console (children)" |
| * @example {blink.console} PH1 |
| */ |
| sChildren: '{PH1} (children)', |
| /** |
| * @description Text used to show the user how much time the browser spent on rendering (drawing the page onto the screen). |
| */ |
| timeSpentInRendering: 'Time spent in rendering', |
| /** |
| * @description Text for a rendering frame |
| */ |
| frame: 'Frame', |
| /** |
| * @description Text used to refer to the duration of an event at a given offset - e.g. "2ms at 10ms" which can be read as "2ms starting after 10ms". |
| * @example {10ms} PH1 |
| * @example {10ms} PH2 |
| */ |
| sAtSParentheses: '{PH1} (at {PH2})', |
| /** |
| * @description Text of a DOM element in Timeline UIUtils of the Performance panel |
| */ |
| UnknownNode: '[ unknown node ]', |
| /** |
| * @description Text used to refer to a particular element and the file it was referred to in. |
| * @example {node} PH1 |
| * @example {app.js} PH2 |
| */ |
| invalidationWithCallFrame: '{PH1} at {PH2}', |
| /** |
| * @description Text indicating that something is outside of the Performace Panel Timeline Minimap range |
| */ |
| outsideBreadcrumbRange: '(outside of the breadcrumb range)', |
| /** |
| * @description Text indicating that something is hidden from the Performace Panel Timeline |
| */ |
| entryIsHidden: '(entry is hidden)', |
| /** |
| * @description Title of a row in the details view for a `Recalculate Styles` event that contains more info about selector stats tracing. |
| */ |
| selectorStatsTitle: 'Selector stats', |
| /** |
| * @description Info text that explains to the user how to enable selector stats tracing. |
| * @example {Setting Name} PH1 |
| */ |
| sSelectorStatsInfo: 'Select "{PH1}" to collect detailed CSS selector matching statistics.', |
| /** |
| * @description Label for a numeric value that was how long to wait before a function was run. |
| */ |
| delay: 'Delay', |
| /** |
| * @description Label for a string that describes the priority at which a task was scheduled, like 'background' for low-priority tasks, and 'user-blocking' for high priority. |
| */ |
| priority: 'Priority', |
| /** |
| * @description Label for the a source URL. |
| */ |
| source: 'Source', |
| /** |
| * @description Label for a URL origin. |
| */ |
| origin: 'Origin', |
| } as const; |
| const str_ = i18n.i18n.registerUIStrings('panels/timeline/TimelineUIUtils.ts', UIStrings); |
| const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); |
| |
| /** Look for scheme:// plus text and exclude any punctuation at the end. **/ |
| export const URL_REGEX = /(?:[a-zA-Z][a-zA-Z0-9+.-]{2,}:\/\/)[^\s"]{2,}[^\s"'\)\}\],:;.!?]/u; |
| |
| let eventDispatchDesciptors: EventDispatchTypeDescriptor[]; |
| |
| let colorGenerator: Common.Color.Generator; |
| |
| interface LinkifyLocationOptions { |
| scriptId: Protocol.Runtime.ScriptId|null; |
| url: string; |
| lineNumber: number; |
| target: SDK.Target.Target|null; |
| linkifier: LegacyComponents.Linkifier.Linkifier; |
| isFreshOrEnhanced?: boolean; |
| columnNumber?: number; |
| omitOrigin?: boolean; |
| } |
| |
| type TimeRangeCategoryStats = Record<string, number>; |
| |
| const {SamplesIntegrator} = Trace.Helpers.SamplesIntegrator; |
| |
| export class TimelineUIUtils { |
| /** |
| * use getGetDebugModeEnabled() to query this variable. |
| */ |
| static debugModeEnabled: boolean|undefined = undefined; |
| static getGetDebugModeEnabled(): boolean { |
| if (TimelineUIUtils.debugModeEnabled === undefined) { |
| TimelineUIUtils.debugModeEnabled = |
| Root.Runtime.experiments.isEnabled(Root.ExperimentNames.ExperimentName.TIMELINE_DEBUG_MODE); |
| } |
| return TimelineUIUtils.debugModeEnabled; |
| } |
| static frameDisplayName(frame: Protocol.Runtime.CallFrame): string { |
| const maybeResolvedData = SourceMapsResolver.SourceMapsResolver.resolvedCodeLocationForCallFrame(frame); |
| const functionName = maybeResolvedData?.name || frame.functionName; |
| if (!SamplesIntegrator.isNativeRuntimeFrame(frame)) { |
| return UI.UIUtils.beautifyFunctionName(functionName); |
| } |
| const nativeGroup = SamplesIntegrator.nativeGroup(functionName); |
| switch (nativeGroup) { |
| case SamplesIntegrator.NativeGroups.COMPILE: |
| return i18nString(UIStrings.compile); |
| case SamplesIntegrator.NativeGroups.PARSE: |
| return i18nString(UIStrings.parse); |
| } |
| return functionName; |
| } |
| |
| static testContentMatching( |
| traceEvent: Trace.Types.Events.Event, regExp: RegExp, handlerData?: Trace.Handlers.Types.HandlerData): boolean { |
| const title = TimelineUIUtils.eventStyle(traceEvent).title; |
| const tokens = [title]; |
| |
| if (Trace.Types.Events.isProfileCall(traceEvent)) { |
| // In the future this case will not be possible - wherever we call this |
| // function we will be able to pass in the data from the new engine. But |
| // currently this is called in a variety of places including from the |
| // legacy model which does not have a reference to the new engine's data. |
| // So if we are missing the data, we just fallback to the name from the |
| // callFrame. |
| if (!handlerData?.Samples) { |
| tokens.push(traceEvent.callFrame.functionName); |
| } else { |
| tokens.push(Trace.Handlers.ModelHandlers.Samples.getProfileCallFunctionName(handlerData.Samples, traceEvent)); |
| } |
| } |
| if (handlerData) { |
| const url = Trace.Handlers.Helpers.getNonResolvedURL(traceEvent, handlerData); |
| if (url) { |
| tokens.push(url); |
| } |
| } |
| if (TimelineUIUtils.getGetDebugModeEnabled()) { |
| // When in debug mode append top level properties (like timestamp) |
| // and deeply nested properties. |
| appendObjectProperties(traceEvent as unknown as ContentObject, 4); |
| } else { |
| appendObjectProperties(traceEvent.args as ContentObject, 2); |
| } |
| const result = tokens.join('|').match(regExp); |
| return result ? result.length > 0 : false; |
| |
| interface ContentObject { |
| [x: string]: number|string|ContentObject; |
| } |
| function appendObjectProperties(object: ContentObject, depth: number): void { |
| if (!depth) { |
| return; |
| } |
| for (const key in object) { |
| const value = object[key]; |
| if (typeof value === 'string') { |
| tokens.push(value); |
| } else if (typeof value === 'number') { |
| tokens.push(String(value)); |
| } else if (typeof value === 'object' && value !== null) { |
| appendObjectProperties(value, depth - 1); |
| } |
| } |
| } |
| } |
| |
| static eventStyle(event: Trace.Types.Events.Event): Trace.Styles.TimelineRecordStyle { |
| if (Trace.Types.Events.isProfileCall(event) && event.callFrame.functionName === '(idle)') { |
| return new Trace.Styles.TimelineRecordStyle(event.name, Trace.Styles.getCategoryStyles().idle); |
| } |
| |
| if (event.cat === Trace.Types.Events.Categories.Console || event.cat === Trace.Types.Events.Categories.UserTiming) { |
| return new Trace.Styles.TimelineRecordStyle(event.name, Trace.Styles.getCategoryStyles()['scripting']); |
| } |
| |
| return Trace.Styles.getEventStyle(event.name as Trace.Types.Events.Name) ?? |
| new Trace.Styles.TimelineRecordStyle(event.name, Trace.Styles.getCategoryStyles().other); |
| } |
| |
| static eventColor(event: Trace.Types.Events.Event): string { |
| if (Trace.Types.Events.isProfileCall(event)) { |
| const frame = event.callFrame; |
| if (TimelineUIUtils.isUserFrame(frame)) { |
| // TODO(andoli): This should use the resolved (sourcemapped) URL |
| return TimelineUIUtils.colorForId(frame.url); |
| } |
| } |
| if (Trace.Types.Extensions.isSyntheticExtensionEntry(event)) { |
| return Extensions.ExtensionUI.extensionEntryColor(event); |
| } |
| const themeSupport = ThemeSupport.ThemeSupport.instance(); |
| let parsedColor = themeSupport.getComputedValue(TimelineUIUtils.eventStyle(event).category.cssVariable); |
| // This event is considered idle time but still rendered as a scripting event here |
| // to connect the StreamingCompileScriptParsing events it belongs to. |
| if (event.name === Trace.Types.Events.Name.STREAMING_COMPILE_SCRIPT_WAITING) { |
| parsedColor = themeSupport.getComputedValue(Trace.Styles.getCategoryStyles().scripting.cssVariable); |
| if (!parsedColor) { |
| throw new Error('Unable to parse color from getCategoryStyles().scripting.color'); |
| } |
| } |
| return parsedColor; |
| } |
| |
| static eventTitle(event: Trace.Types.Events.Event): string { |
| // Profile call events do not have a args.data property, thus, we |
| // need to check for profile calls in the beginning of this |
| // function. |
| if (Trace.Types.Events.isProfileCall(event)) { |
| const maybeResolvedData = SourceMapsResolver.SourceMapsResolver.resolvedCodeLocationForEntry(event); |
| const displayName = maybeResolvedData?.name || TimelineUIUtils.frameDisplayName(event.callFrame); |
| return displayName; |
| } |
| if (event.name === 'EventTiming' && Trace.Types.Events.isSyntheticInteraction(event)) { |
| // TODO(crbug.com/365047728): replace this entire method with this call. |
| return Trace.Name.forEntry(event); |
| } |
| const title = TimelineUIUtils.eventStyle(event).title; |
| if (Trace.Helpers.Trace.eventHasCategory(event, Trace.Types.Events.Categories.Console)) { |
| return title; |
| } |
| if (Trace.Types.Events.isConsoleTimeStamp(event) && event.args.data) { |
| return i18nString(UIStrings.sS, {PH1: title, PH2: event.args.data.name ?? event.args.data.message}); |
| } |
| if (Trace.Types.Events.isAnimation(event) && event.args.data.name) { |
| return i18nString(UIStrings.sS, {PH1: title, PH2: event.args.data.name}); |
| } |
| if (Trace.Types.Events.isDispatch(event)) { |
| return i18nString(UIStrings.sS, {PH1: title, PH2: event.args.data.type}); |
| } |
| return title; |
| } |
| |
| static isUserFrame(frame: Protocol.Runtime.CallFrame): boolean { |
| return frame.scriptId !== '0' && !(frame.url?.startsWith('native ')); |
| } |
| |
| static async buildDetailsNodeForTraceEvent( |
| event: Trace.Types.Events.Event, target: SDK.Target.Target|null, linkifier: LegacyComponents.Linkifier.Linkifier, |
| isFreshOrEnhanced = false, parsedTrace: Trace.TraceModel.ParsedTrace): Promise<Node|null> { |
| let details: HTMLElement|HTMLSpanElement|(Element | null)|Text|null = null; |
| let detailsText; |
| // TODO(40287735): update this code with type-safe data checks. |
| // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| const unsafeEventArgs = event.args as Record<string, any>; |
| // TODO(40287735): update this code with type-safe data checks. |
| // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| const unsafeEventData = event.args?.data as Record<string, any>; |
| |
| switch (event.name) { |
| case Trace.Types.Events.Name.PAINT_IMAGE: |
| case Trace.Types.Events.Name.DECODE_IMAGE: |
| case Trace.Types.Events.Name.DECODE_LAZY_PIXEL_REF: |
| case Trace.Types.Events.Name.XHR_READY_STATE_CHANGED: |
| case Trace.Types.Events.Name.XHR_LOAD: |
| case Trace.Types.Events.Name.RESOURCE_WILL_SEND_REQUEST: |
| case Trace.Types.Events.Name.RESOURCE_SEND_REQUEST: |
| case Trace.Types.Events.Name.RESOURCE_RECEIVE_DATA: |
| case Trace.Types.Events.Name.RESOURCE_RECEIVE_RESPONSE: |
| case Trace.Types.Events.Name.RESOURCE_FINISH: { |
| const url = Trace.Handlers.Helpers.getNonResolvedURL(event, parsedTrace.data); |
| if (url) { |
| const options = { |
| tabStop: true, |
| showColumnNumber: false, |
| inlineFrameIndex: 0, |
| }; |
| details = LegacyComponents.Linkifier.Linkifier.linkifyURL(url, options); |
| } |
| break; |
| } |
| |
| case Trace.Types.Events.Name.FUNCTION_CALL: { |
| details = document.createElement('span'); |
| |
| // FunctionCall events have an args.data that could be a CallFrame, if all the details are present, so we check for that. |
| const callFrame = Trace.Helpers.Trace.getZeroIndexedStackTraceInEventPayload(event)?.at(0); |
| if (Trace.Types.Events.isFunctionCall(event) && callFrame) { |
| UI.UIUtils.createTextChild( |
| details, |
| TimelineUIUtils.frameDisplayName( |
| {...callFrame, scriptId: String(callFrame.scriptId) as Protocol.Runtime.ScriptId})); |
| } |
| const location = this.linkifyLocation({ |
| scriptId: unsafeEventData['scriptId'], |
| url: unsafeEventData['url'], |
| lineNumber: callFrame?.lineNumber || 0, |
| columnNumber: callFrame?.columnNumber, |
| target, |
| isFreshOrEnhanced, |
| linkifier, |
| omitOrigin: true, |
| }); |
| if (location) { |
| UI.UIUtils.createTextChild(details, ' @ '); |
| details.appendChild(location); |
| } |
| break; |
| } |
| |
| case Trace.Types.Events.Name.COMPILE_MODULE: |
| case Trace.Types.Events.Name.CACHE_MODULE: { |
| details = this.linkifyLocation({ |
| scriptId: null, |
| url: unsafeEventArgs['fileName'], |
| lineNumber: 0, |
| columnNumber: 0, |
| target, |
| isFreshOrEnhanced, |
| linkifier, |
| }); |
| break; |
| } |
| |
| case Trace.Types.Events.Name.BACKGROUND_DESERIALIZE: |
| case Trace.Types.Events.Name.STREAMING_COMPILE_SCRIPT: { |
| const url = unsafeEventData['url']; |
| if (url) { |
| details = this.linkifyLocation({ |
| scriptId: null, |
| url, |
| lineNumber: 0, |
| columnNumber: 0, |
| target, |
| isFreshOrEnhanced, |
| linkifier, |
| omitOrigin: true, |
| }); |
| } |
| break; |
| } |
| |
| default: { |
| /** |
| * Some events have a stack trace which is extracted by default at @see TimelineUIUtils.generateCauses |
| * thus, we prevent extracting the stack trace again here. |
| */ |
| if (Trace.Helpers.Trace.eventHasCategory(event, Trace.Types.Events.Categories.Console) || |
| Trace.Types.Events.isUserTiming(event) || Trace.Types.Extensions.isSyntheticExtensionEntry(event) || |
| Trace.Types.Events.isProfileCall(event)) { |
| detailsText = null; |
| } else { |
| details = this.linkifyTopCallFrame(event, target, linkifier, isFreshOrEnhanced) ?? null; |
| } |
| break; |
| } |
| } |
| |
| if (!details && detailsText) { |
| details = document.createTextNode(detailsText); |
| } |
| return details; |
| } |
| |
| static linkifyLocation(linkifyOptions: LinkifyLocationOptions): Element|null { |
| const {scriptId, url, lineNumber, columnNumber, isFreshOrEnhanced, linkifier, target, omitOrigin} = linkifyOptions; |
| const options = { |
| lineNumber, |
| columnNumber, |
| showColumnNumber: true, |
| inlineFrameIndex: 0, |
| className: 'timeline-details', |
| tabStop: true, |
| omitOrigin, |
| }; |
| if (isFreshOrEnhanced) { |
| return linkifier.linkifyScriptLocation( |
| target, scriptId, url as Platform.DevToolsPath.UrlString, lineNumber, options); |
| } |
| return LegacyComponents.Linkifier.Linkifier.linkifyURL(url as Platform.DevToolsPath.UrlString, options); |
| } |
| |
| static linkifyTopCallFrame( |
| event: Trace.Types.Events.Event, target: SDK.Target.Target|null, linkifier: LegacyComponents.Linkifier.Linkifier, |
| isFreshOrEnhanced = false): Element|null { |
| let frame = Trace.Helpers.Trace.getZeroIndexedStackTraceInEventPayload(event)?.[0]; |
| if (Trace.Types.Events.isProfileCall(event)) { |
| frame = event.callFrame; |
| } |
| if (!frame) { |
| return null; |
| } |
| const options = { |
| className: 'timeline-details', |
| tabStop: true, |
| inlineFrameIndex: 0, |
| showColumnNumber: true, |
| columnNumber: frame.columnNumber, |
| lineNumber: frame.lineNumber, |
| }; |
| if (isFreshOrEnhanced) { |
| return linkifier.maybeLinkifyConsoleCallFrame(target, frame, {showColumnNumber: true, inlineFrameIndex: 0}); |
| } |
| return LegacyComponents.Linkifier.Linkifier.linkifyURL(frame.url as Platform.DevToolsPath.UrlString, options); |
| } |
| |
| static buildDetailsNodeForMarkerEvents(event: Trace.Types.Events.MarkerEvent): HTMLElement { |
| let link = 'https://web.dev/user-centric-performance-metrics/'; |
| let name = 'page performance metrics'; |
| switch (event.name) { |
| case Trace.Types.Events.Name.MARK_LCP_CANDIDATE: |
| link = 'https://web.dev/lcp/'; |
| name = 'Largest Contentful Paint'; |
| break; |
| case Trace.Types.Events.Name.MARK_LCP_CANDIDATE_FOR_SOFT_NAVIGATION: |
| link = 'https://developer.chrome.com/docs/web-platform/soft-navigations-experiment'; |
| name = 'Soft Largest Contentful Paint'; |
| break; |
| case Trace.Types.Events.Name.SOFT_NAVIGATION_START: |
| link = 'https://developer.chrome.com/docs/web-platform/soft-navigations-experiment'; |
| name = 'Soft Navigations'; |
| break; |
| case Trace.Types.Events.Name.MARK_FCP: |
| link = 'https://web.dev/first-contentful-paint/'; |
| name = 'First Contentful Paint'; |
| break; |
| default: |
| break; |
| } |
| |
| const div = document.createElement('div'); |
| // eslint-disable-next-line @devtools/no-lit-render-outside-of-view |
| render(html`<devtools-link href=${link}>${i18nString(UIStrings.learnMore)}</devtools-link> about ${name}.`, div); |
| return div; |
| } |
| |
| static buildConsumeCacheDetails( |
| eventData: { |
| consumedCacheSize?: number, |
| cacheRejected?: boolean, |
| cacheKind?: string, |
| }, |
| contentHelper: TimelineDetailsContentHelper): void { |
| if (typeof eventData.consumedCacheSize === 'number') { |
| contentHelper.appendTextRow( |
| i18nString(UIStrings.compilationCacheStatus), i18nString(UIStrings.scriptLoadedFromCache)); |
| contentHelper.appendTextRow( |
| i18nString(UIStrings.compilationCacheSize), i18n.ByteUtilities.bytesToString(eventData.consumedCacheSize)); |
| const cacheKind = eventData.cacheKind; |
| if (cacheKind) { |
| contentHelper.appendTextRow(i18nString(UIStrings.compilationCacheKind), cacheKind); |
| } |
| } else if ('cacheRejected' in eventData && eventData['cacheRejected']) { |
| // Version mismatch or similar. |
| contentHelper.appendTextRow( |
| i18nString(UIStrings.compilationCacheStatus), i18nString(UIStrings.failedToLoadScriptFromCache)); |
| } else { |
| contentHelper.appendTextRow( |
| i18nString(UIStrings.compilationCacheStatus), i18nString(UIStrings.scriptNotEligibleToBeLoadedFromCache)); |
| } |
| } |
| |
| static maybeCreateLinkElement(url: string): Element|null { |
| const parsedURL = new Common.ParsedURL.ParsedURL(url); |
| if (!parsedURL.scheme) { |
| return null; |
| } |
| |
| const splitResult = Common.ParsedURL.ParsedURL.splitLineAndColumn(url); |
| if (!splitResult) { |
| return null; |
| } |
| const {url: rawURL, lineNumber, columnNumber} = splitResult; |
| |
| const options = { |
| lineNumber, |
| columnNumber, |
| showColumnNumber: true, |
| omitOrigin: true, |
| }; |
| |
| return LegacyComponents.Linkifier.Linkifier.linkifyURL(rawURL as Platform.DevToolsPath.UrlString, options); |
| } |
| |
| /** |
| * Takes an input string and parses it to look for links. It does this by |
| * looking for URLs in the input string. The returned fragment will contain |
| * the same string but with any links wrapped in clickable links. The text |
| * of the link is the URL, so the visible string to the user is unchanged. |
| */ |
| static parseStringForLinks(rawString: string): DocumentFragment { |
| const results = TextUtils.TextUtils.Utils.splitStringByRegexes(rawString, [URL_REGEX]); |
| const nodes = results.map(result => { |
| if (result.regexIndex === -1) { |
| return result.value; |
| } |
| return TimelineUIUtils.maybeCreateLinkElement(result.value) ?? result.value; |
| }); |
| |
| const frag = document.createDocumentFragment(); |
| frag.append(...nodes); |
| return frag; |
| } |
| |
| static async buildTraceEventDetails( |
| parsedTrace: Trace.TraceModel.ParsedTrace, |
| event: Trace.Types.Events.Event, |
| linkifier: LegacyComponents.Linkifier.Linkifier, |
| canShowPieChart: boolean, |
| entityMapper: Trace.EntityMapper.EntityMapper|null, |
| ): Promise<DocumentFragment> { |
| const maybeTarget = targetForEvent(parsedTrace, event); |
| const {duration} = Trace.Helpers.Timing.eventTimingsMicroSeconds(event); |
| const selfTime = getEventSelfTime(event, parsedTrace); |
| const relatedNodesMap = await Utils.EntryNodes.relatedDOMNodesForEvent( |
| parsedTrace, |
| event, |
| ); |
| let entityAppended = false; |
| |
| if (maybeTarget) { |
| // @ts-expect-error TODO(crbug.com/1011811): Remove symbol usage. |
| if (typeof event[previewElementSymbol] === 'undefined') { |
| let previewElement: (Element|null)|null = null; |
| const url = Trace.Handlers.Helpers.getNonResolvedURL(event, parsedTrace.data); |
| if (url) { |
| previewElement = await LegacyComponents.ImagePreview.ImagePreview.build(url, false, { |
| imageAltText: LegacyComponents.ImagePreview.ImagePreview.defaultAltTextForImageURL(url), |
| align: LegacyComponents.ImagePreview.Align.START, |
| }); |
| } else if (Trace.Types.Events.isPaint(event)) { |
| previewElement = await TimelineUIUtils.buildPicturePreviewContent(parsedTrace, event, maybeTarget); |
| } |
| // @ts-expect-error TODO(crbug.com/1011811): Remove symbol usage. |
| event[previewElementSymbol] = previewElement; |
| } |
| } |
| |
| // This message may vary per event.name; |
| let relatedNodeLabel; |
| |
| const contentHelper = new TimelineDetailsContentHelper(targetForEvent(parsedTrace, event), linkifier); |
| |
| const defaultColorForEvent = this.eventColor(event); |
| const isMarker = parsedTrace && isMarkerEvent(parsedTrace, event); |
| const color = isMarker ? TimelineUIUtils.markerStyleForEvent(event).color : defaultColorForEvent; |
| |
| contentHelper.addSection(TimelineUIUtils.eventTitle(event), color, event); |
| |
| // TODO: as part of the removal of the old engine, produce a typesafe way |
| // to look up args and data for events. |
| // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| const unsafeEventArgs = event.args as Record<string, any>; |
| // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| const unsafeEventData = event.args?.data as Record<string, any>; |
| const initiator = parsedTrace.data.Initiators.eventToInitiator.get(event) ?? null; |
| const initiatorFor = parsedTrace.data.Initiators.initiatorToEvents.get(event) ?? null; |
| |
| let url: Platform.DevToolsPath.UrlString|null = null; |
| |
| if (parsedTrace) { |
| const warnings = TimelineComponents.DetailsView.buildWarningElementsForEvent(event, parsedTrace); |
| for (const warning of warnings) { |
| contentHelper.appendElementRow(i18nString(UIStrings.warning), warning, true); |
| } |
| } |
| |
| // Add timestamp to user timings, including custom extensibility markers |
| if (Trace.Helpers.Trace.eventHasCategory(event, Trace.Types.Events.Categories.UserTiming) || |
| Trace.Types.Extensions.isSyntheticExtensionEntry(event)) { |
| const adjustedEventTimeStamp = timeStampForEventAdjustedForClosestNavigationIfPossible( |
| event, |
| parsedTrace, |
| ); |
| contentHelper.appendTextRow( |
| i18nString(UIStrings.timestamp), i18n.TimeUtilities.preciseMillisToString(adjustedEventTimeStamp, 1)); |
| } |
| |
| // Only show total time and self time for events with non-zero durations. |
| if (duration !== 0 && !Number.isNaN(duration)) { |
| const timeStr = getDurationString(duration, selfTime); |
| contentHelper.appendTextRow(i18nString(UIStrings.duration), timeStr); |
| } |
| |
| if (Trace.Types.Events.isPerformanceMark(event) && event.args.data?.detail) { |
| const detailContainer = TimelineUIUtils.renderObjectJson(JSON.parse(event.args.data?.detail)); |
| contentHelper.appendElementRow(i18nString(UIStrings.details), detailContainer); |
| } |
| if (Trace.Types.Events.isSyntheticUserTiming(event) && event.args?.data?.beginEvent.args.detail) { |
| const detailContainer = TimelineUIUtils.renderObjectJson(JSON.parse(event.args?.data?.beginEvent.args.detail)); |
| contentHelper.appendElementRow(i18nString(UIStrings.details), detailContainer); |
| } |
| |
| if (parsedTrace.data.Meta.traceIsGeneric) { |
| TimelineUIUtils.renderEventJson(event, contentHelper); |
| return contentHelper.fragment; |
| } |
| |
| if (Trace.Types.Events.isNavigationStart(event)) { |
| url = (event.args.data?.documentLoaderURL ?? event.args.data?.url) as Platform.DevToolsPath.UrlString; |
| if (url) { |
| contentHelper.appendElementRow(i18nString(UIStrings.url), LegacyComponents.Linkifier.Linkifier.linkifyURL(url)); |
| } |
| } |
| |
| if (Trace.Types.Events.isSoftNavigationStart(event)) { |
| url = event.args.context.URL as Platform.DevToolsPath.UrlString; |
| if (url) { |
| contentHelper.appendElementRow(i18nString(UIStrings.url), LegacyComponents.Linkifier.Linkifier.linkifyURL(url)); |
| } |
| contentHelper.appendElementRow( |
| i18nString(UIStrings.details), TimelineUIUtils.buildDetailsNodeForMarkerEvents(event)); |
| } |
| |
| if (Trace.Types.Events.isV8Compile(event)) { |
| url = event.args.data?.url as Platform.DevToolsPath.UrlString; |
| if (url) { |
| const {lineNumber, columnNumber} = Trace.Helpers.Trace.getZeroIndexedLineAndColumnForEvent(event); |
| contentHelper.appendLocationRow( |
| i18nString(UIStrings.script), url, lineNumber || 0, columnNumber, undefined, true); |
| const originWithEntity = this.getOriginWithEntity(entityMapper, parsedTrace, event); |
| if (originWithEntity) { |
| contentHelper.appendElementRow(i18nString(UIStrings.origin), originWithEntity); |
| } |
| entityAppended = true; |
| } |
| const isEager = Boolean(event.args.data?.eager); |
| if (isEager) { |
| contentHelper.appendTextRow(i18nString(UIStrings.eagerCompile), true); |
| } |
| |
| const isStreamed = Boolean(event.args.data?.streamed); |
| contentHelper.appendTextRow( |
| i18nString(UIStrings.streamed), |
| isStreamed + (isStreamed ? '' : `: ${event.args.data?.notStreamedReason || ''}`)); |
| if (event.args.data) { |
| TimelineUIUtils.buildConsumeCacheDetails(event.args.data, contentHelper); |
| } |
| } |
| |
| if (Trace.Types.Extensions.isSyntheticExtensionEntry(event)) { |
| // isSyntheticExtensionEntries can be any of: perf.measure, perf.mark or console.timeStamp. |
| const userDetail = structuredClone(event.userDetail) as Record<string, Trace.Types.Extensions.JsonValue>; |
| if (userDetail && Object.keys(userDetail).length) { |
| // E.g. console.timeStamp(name, start, end, track, trackGroup, color, |
| // {url: 'foo-extension://node/1', description: 'Node'}); |
| const hasExclusiveLink = typeof userDetail === 'object' && typeof userDetail.url === 'string' && |
| typeof userDetail.description === 'string'; |
| if (hasExclusiveLink && Boolean(Root.Runtime.hostConfig.devToolsDeepLinksViaExtensibilityApi?.enabled)) { |
| const linkElement = this.maybeCreateLinkElement(String(userDetail.url)); |
| if (linkElement) { |
| contentHelper.appendElementRow(String(userDetail.description), linkElement); |
| // Now remove so we don't render them in renderObjectJson. |
| delete userDetail.url; |
| delete userDetail.description; |
| } |
| } |
| if (Object.keys(userDetail).length) { |
| // E.g., performance.measure(name, {detail: {important: 42, devtools: {}}}) |
| const detailContainer = TimelineUIUtils.renderObjectJson(userDetail); |
| contentHelper.appendElementRow(i18nString(UIStrings.details), detailContainer); |
| } |
| } |
| |
| // E.g. performance.measure(name, {detail: {devtools: {properties: ['Key', 'Value']}}}) |
| if (event.devtoolsObj.properties) { |
| for (const [key, value] of event.devtoolsObj.properties || []) { |
| const renderedValue = typeof value === 'string' ? TimelineUIUtils.parseStringForLinks(value) : |
| TimelineUIUtils.renderObjectJson(value); |
| contentHelper.appendElementRow(key, renderedValue); |
| } |
| } |
| } |
| |
| const isFreshOrEnhanced = |
| Boolean(parsedTrace && Tracing.FreshRecording.Tracker.instance().recordingIsFreshOrEnhanced(parsedTrace)); |
| |
| switch (event.name) { |
| case Trace.Types.Events.Name.GC: |
| case Trace.Types.Events.Name.MAJOR_GC: |
| case Trace.Types.Events.Name.MINOR_GC: { |
| const delta = unsafeEventArgs['usedHeapSizeBefore'] - unsafeEventArgs['usedHeapSizeAfter']; |
| contentHelper.appendTextRow(i18nString(UIStrings.collected), i18n.ByteUtilities.bytesToString(delta)); |
| break; |
| } |
| |
| case Trace.Types.Events.Name.PROFILE_CALL: { |
| const profileCall = event as Trace.Types.Events.SyntheticProfileCall; |
| const resolvedURL = SourceMapsResolver.SourceMapsResolver.resolvedURLForEntry(parsedTrace, profileCall); |
| if (!resolvedURL) { |
| break; |
| } |
| const callFrame = profileCall.callFrame; |
| // Render the URL with its location content. |
| contentHelper.appendLocationRow( |
| i18nString(UIStrings.source), resolvedURL, callFrame.lineNumber || 0, callFrame.columnNumber, undefined, |
| true); |
| const originWithEntity = this.getOriginWithEntity(entityMapper, parsedTrace, profileCall); |
| if (originWithEntity) { |
| contentHelper.appendElementRow(i18nString(UIStrings.origin), originWithEntity); |
| } |
| entityAppended = true; |
| break; |
| } |
| case Trace.Types.Events.Name.FUNCTION_CALL: { |
| const detailsNode = await TimelineUIUtils.buildDetailsNodeForTraceEvent( |
| event, targetForEvent(parsedTrace, event), linkifier, isFreshOrEnhanced, parsedTrace); |
| if (detailsNode) { |
| contentHelper.appendElementRow(i18nString(UIStrings.function), detailsNode); |
| const originWithEntity = this.getOriginWithEntity(entityMapper, parsedTrace, event); |
| if (originWithEntity) { |
| contentHelper.appendElementRow(i18nString(UIStrings.origin), originWithEntity); |
| } |
| entityAppended = true; |
| } |
| break; |
| } |
| |
| case Trace.Types.Events.Name.TIMER_FIRE: |
| case Trace.Types.Events.Name.TIMER_INSTALL: |
| case Trace.Types.Events.Name.TIMER_REMOVE: { |
| contentHelper.appendTextRow(i18nString(UIStrings.timerId), unsafeEventData.timerId); |
| |
| if (event.name === Trace.Types.Events.Name.TIMER_INSTALL) { |
| contentHelper.appendTextRow( |
| i18nString(UIStrings.timeout), i18n.TimeUtilities.millisToString(unsafeEventData['timeout'])); |
| contentHelper.appendTextRow(i18nString(UIStrings.repeats), !unsafeEventData['singleShot']); |
| } |
| break; |
| } |
| |
| case Trace.Types.Events.Name.SCHEDULE_POST_TASK_CALLBACK: |
| case Trace.Types.Events.Name.RUN_POST_TASK_CALLBACK: { |
| contentHelper.appendTextRow( |
| i18nString(UIStrings.delay), i18n.TimeUtilities.millisToString(unsafeEventData['delay'])); |
| contentHelper.appendTextRow(i18nString(UIStrings.priority), unsafeEventData['priority']); |
| break; |
| } |
| |
| case Trace.Types.Events.Name.FIRE_ANIMATION_FRAME: { |
| contentHelper.appendTextRow(i18nString(UIStrings.callbackId), unsafeEventData['id']); |
| break; |
| } |
| |
| case Trace.Types.Events.Name.COMPILE_MODULE: { |
| contentHelper.appendLocationRow(i18nString(UIStrings.module), unsafeEventArgs['fileName'], 0); |
| break; |
| } |
| case Trace.Types.Events.Name.COMPILE_SCRIPT: { |
| // This case is handled above |
| break; |
| } |
| |
| case Trace.Types.Events.Name.CACHE_MODULE: { |
| url = unsafeEventData && unsafeEventData['url'] as Platform.DevToolsPath.UrlString; |
| contentHelper.appendTextRow( |
| i18nString(UIStrings.compilationCacheSize), |
| i18n.ByteUtilities.bytesToString(unsafeEventData['producedCacheSize'])); |
| break; |
| } |
| |
| case Trace.Types.Events.Name.CACHE_SCRIPT: { |
| url = unsafeEventData && unsafeEventData['url'] as Platform.DevToolsPath.UrlString; |
| if (url) { |
| const {lineNumber, columnNumber} = Trace.Helpers.Trace.getZeroIndexedLineAndColumnForEvent(event); |
| contentHelper.appendLocationRow( |
| i18nString(UIStrings.script), url, lineNumber || 0, columnNumber, undefined, true); |
| const originWithEntity = this.getOriginWithEntity(entityMapper, parsedTrace, event); |
| if (originWithEntity) { |
| contentHelper.appendElementRow(i18nString(UIStrings.origin), originWithEntity); |
| } |
| entityAppended = true; |
| } |
| contentHelper.appendTextRow( |
| i18nString(UIStrings.compilationCacheSize), |
| i18n.ByteUtilities.bytesToString(unsafeEventData['producedCacheSize'])); |
| break; |
| } |
| |
| case Trace.Types.Events.Name.EVALUATE_SCRIPT: { |
| url = unsafeEventData && unsafeEventData['url'] as Platform.DevToolsPath.UrlString; |
| if (url) { |
| const {lineNumber, columnNumber} = Trace.Helpers.Trace.getZeroIndexedLineAndColumnForEvent(event); |
| contentHelper.appendLocationRow( |
| i18nString(UIStrings.script), url, lineNumber || 0, columnNumber, undefined, true); |
| const originWithEntity = this.getOriginWithEntity(entityMapper, parsedTrace, event); |
| if (originWithEntity) { |
| contentHelper.appendElementRow(i18nString(UIStrings.origin), originWithEntity); |
| } |
| entityAppended = true; |
| } |
| break; |
| } |
| |
| case Trace.Types.Events.Name.WASM_STREAM_FROM_RESPONSE_CALLBACK: |
| case Trace.Types.Events.Name.WASM_COMPILED_MODULE: |
| case Trace.Types.Events.Name.WASM_CACHED_MODULE: |
| case Trace.Types.Events.Name.WASM_MODULE_CACHE_HIT: |
| case Trace.Types.Events.Name.WASM_MODULE_CACHE_INVALID: { |
| if (unsafeEventData) { |
| url = unsafeEventArgs['url'] as Platform.DevToolsPath.UrlString; |
| if (url) { |
| contentHelper.appendTextRow(i18nString(UIStrings.url), url); |
| } |
| const producedCachedSize = unsafeEventArgs['producedCachedSize']; |
| if (producedCachedSize) { |
| contentHelper.appendTextRow(i18nString(UIStrings.producedCacheSize), producedCachedSize); |
| } |
| const consumedCachedSize = unsafeEventArgs['consumedCachedSize']; |
| if (consumedCachedSize) { |
| contentHelper.appendTextRow(i18nString(UIStrings.consumedCacheSize), consumedCachedSize); |
| } |
| } |
| break; |
| } |
| |
| case Trace.Types.Events.Name.PAINT: |
| case Trace.Types.Events.Name.PAINT_SETUP: |
| case Trace.Types.Events.Name.RASTERIZE: |
| case Trace.Types.Events.Name.SCROLL_LAYER: { |
| relatedNodeLabel = i18nString(UIStrings.layerRoot); |
| break; |
| } |
| |
| case Trace.Types.Events.Name.PAINT_IMAGE: |
| case Trace.Types.Events.Name.DECODE_LAZY_PIXEL_REF: |
| case Trace.Types.Events.Name.DECODE_IMAGE: |
| case Trace.Types.Events.Name.DRAW_LAZY_PIXEL_REF: { |
| relatedNodeLabel = i18nString(UIStrings.ownerElement); |
| url = Trace.Handlers.Helpers.getNonResolvedURL(event, parsedTrace.data); |
| if (url) { |
| const options = { |
| tabStop: true, |
| showColumnNumber: false, |
| inlineFrameIndex: 0, |
| }; |
| contentHelper.appendElementRow( |
| i18nString(UIStrings.imageUrl), LegacyComponents.Linkifier.Linkifier.linkifyURL(url, options)); |
| } |
| break; |
| } |
| |
| case Trace.Types.Events.Name.PARSE_AUTHOR_STYLE_SHEET: { |
| url = unsafeEventData['styleSheetUrl'] as Platform.DevToolsPath.UrlString; |
| if (url) { |
| const options = { |
| tabStop: true, |
| showColumnNumber: false, |
| inlineFrameIndex: 0, |
| }; |
| contentHelper.appendElementRow( |
| i18nString(UIStrings.stylesheetUrl), LegacyComponents.Linkifier.Linkifier.linkifyURL(url, options)); |
| } |
| break; |
| } |
| |
| case Trace.Types.Events.Name.RECALC_STYLE: { |
| contentHelper.appendTextRow(i18nString(UIStrings.elementsAffected), unsafeEventArgs['elementCount']); |
| |
| const selectorStatsSetting = |
| Common.Settings.Settings.instance().createSetting('timeline-capture-selector-stats', false); |
| if (!selectorStatsSetting.get()) { |
| const note = document.createElement('span'); |
| note.textContent = i18nString(UIStrings.sSelectorStatsInfo, {PH1: selectorStatsSetting.title()}); |
| contentHelper.appendElementRow(i18nString(UIStrings.selectorStatsTitle), note); |
| } |
| |
| break; |
| } |
| |
| case Trace.Types.Events.Name.LAYOUT: { |
| const beginData = unsafeEventArgs['beginData']; |
| contentHelper.appendTextRow( |
| i18nString(UIStrings.nodesThatNeedLayout), |
| i18nString(UIStrings.sOfS, {PH1: beginData['dirtyObjects'], PH2: beginData['totalObjects']})); |
| relatedNodeLabel = i18nString(UIStrings.layoutRoot); |
| break; |
| } |
| |
| case Trace.Types.Events.Name.CONSOLE_TIME: { |
| contentHelper.appendTextRow(i18nString(UIStrings.message), event.name); |
| break; |
| } |
| |
| case Trace.Types.Events.Name.WEB_SOCKET_CREATE: |
| case Trace.Types.Events.Name.WEB_SOCKET_SEND_HANDSHAKE_REQUEST: |
| case Trace.Types.Events.Name.WEB_SOCKET_RECEIVE_HANDSHAKE_REQUEST: |
| case Trace.Types.Events.Name.WEB_SOCKET_SEND: |
| case Trace.Types.Events.Name.WEB_SOCKET_RECEIVE: |
| case Trace.Types.Events.Name.WEB_SOCKET_DESTROY: { |
| if (Trace.Types.Events.isWebSocketTraceEvent(event)) { |
| const rows = TimelineComponents.DetailsView.buildRowsForWebSocketEvent(event, parsedTrace); |
| for (const {key, value} of rows) { |
| contentHelper.appendTextRow(key, value); |
| } |
| } |
| break; |
| } |
| |
| case Trace.Types.Events.Name.EMBEDDER_CALLBACK: { |
| contentHelper.appendTextRow(i18nString(UIStrings.callbackFunction), unsafeEventData['callbackName']); |
| break; |
| } |
| |
| case Trace.Types.Events.Name.ANIMATION: { |
| if (!Trace.Types.Events.isSyntheticAnimation(event)) { |
| break; |
| } |
| const {displayName, nodeName} = event.args.data.beginEvent.args.data; |
| displayName && contentHelper.appendTextRow(i18nString(UIStrings.animating), displayName); |
| // If relatedNodes is empty (maybe saved trace), then print the text description of the DOM node. |
| if (!relatedNodesMap?.size && nodeName) { |
| contentHelper.appendTextRow(i18nString(UIStrings.relatedNode), nodeName); |
| } |
| |
| const CLSInsight = Trace.Insights.Models.CLSCulprits; |
| const failures = CLSInsight.getNonCompositedFailure(event); |
| if (!failures.length) { |
| break; |
| } |
| |
| const failureReasons = new Set(failures.map(f => f.failureReasons).flat().filter(Boolean)); |
| const unsupportedProperties = |
| new Set(failures.map(f => f.unsupportedProperties).flat().filter(Boolean)) as Set<string>; |
| |
| // The failureReasons can be empty when Blink added a new failure reason that is |
| // not supported by DevTools yet |
| if (failureReasons.size === 0) { |
| contentHelper.appendElementRow( |
| i18nString(UIStrings.compositingFailed), i18nString(UIStrings.compositingFailedUnknownReason), true); |
| } else { |
| for (const reason of failureReasons) { |
| let str; |
| switch (reason) { |
| case CLSInsight.AnimationFailureReasons.ACCELERATED_ANIMATIONS_DISABLED: |
| str = i18nString(UIStrings.compositingFailedAcceleratedAnimationsDisabled); |
| break; |
| case CLSInsight.AnimationFailureReasons.EFFECT_SUPPRESSED_BY_DEVTOOLS: |
| str = i18nString(UIStrings.compositingFailedEffectSuppressedByDevtools); |
| break; |
| case CLSInsight.AnimationFailureReasons.INVALID_ANIMATION_OR_EFFECT: |
| str = i18nString(UIStrings.compositingFailedInvalidAnimationOrEffect); |
| break; |
| case CLSInsight.AnimationFailureReasons.EFFECT_HAS_UNSUPPORTED_TIMING_PARAMS: |
| str = i18nString(UIStrings.compositingFailedEffectHasUnsupportedTimingParams); |
| break; |
| case CLSInsight.AnimationFailureReasons.EFFECT_HAS_NON_REPLACE_COMPOSITE_MODE: |
| str = i18nString(UIStrings.compositingFailedEffectHasNonReplaceCompositeMode); |
| break; |
| case CLSInsight.AnimationFailureReasons.TARGET_HAS_INVALID_COMPOSITING_STATE: |
| str = i18nString(UIStrings.compositingFailedTargetHasInvalidCompositingState); |
| break; |
| case CLSInsight.AnimationFailureReasons.TARGET_HAS_INCOMPATIBLE_ANIMATIONS: |
| str = i18nString(UIStrings.compositingFailedTargetHasIncompatibleAnimations); |
| break; |
| case CLSInsight.AnimationFailureReasons.TARGET_HAS_CSS_OFFSET: |
| str = i18nString(UIStrings.compositingFailedTargetHasCSSOffset); |
| break; |
| case CLSInsight.AnimationFailureReasons.ANIMATION_AFFECTS_NON_CSS_PROPERTIES: |
| str = i18nString(UIStrings.compositingFailedAnimationAffectsNonCSSProperties); |
| break; |
| case CLSInsight.AnimationFailureReasons.TRANSFORM_RELATED_PROPERTY_CANNOT_BE_ACCELERATED_ON_TARGET: |
| str = i18nString(UIStrings.compositingFailedTransformRelatedPropertyCannotBeAcceleratedOnTarget); |
| break; |
| case CLSInsight.AnimationFailureReasons.TRANSFROM_BOX_SIZE_DEPENDENT: |
| str = i18nString(UIStrings.compositingFailedTransformDependsBoxSize); |
| break; |
| case CLSInsight.AnimationFailureReasons.FILTER_RELATED_PROPERTY_MAY_MOVE_PIXELS: |
| str = i18nString(UIStrings.compositingFailedFilterRelatedPropertyMayMovePixels); |
| break; |
| case CLSInsight.AnimationFailureReasons.UNSUPPORTED_CSS_PROPERTY: |
| str = i18nString(UIStrings.compositingFailedUnsupportedCSSProperty, { |
| propertyCount: unsupportedProperties.size, |
| properties: new Intl.ListFormat(undefined, {style: 'short', type: 'conjunction'}) |
| .format(unsupportedProperties), |
| }); |
| break; |
| case CLSInsight.AnimationFailureReasons.MIXED_KEYFRAME_VALUE_TYPES: |
| str = i18nString(UIStrings.compositingFailedMixedKeyframeValueTypes); |
| break; |
| case CLSInsight.AnimationFailureReasons.TIMELINE_SOURCE_HAS_INVALID_COMPOSITING_STATE: |
| str = i18nString(UIStrings.compositingFailedTimelineSourceHasInvalidCompositingState); |
| break; |
| case CLSInsight.AnimationFailureReasons.ANIMATION_HAS_NO_VISIBLE_CHANGE: |
| str = i18nString(UIStrings.compositingFailedAnimationHasNoVisibleChange); |
| break; |
| case CLSInsight.AnimationFailureReasons.AFFECTS_IMPORTANT_PROPERTY: |
| str = i18nString(UIStrings.compositingFailedAffectsImportantProperty); |
| break; |
| case CLSInsight.AnimationFailureReasons.SVG_TARGET_HAS_INDEPENDENT_TRANSFORM_PROPERTY: |
| str = i18nString(UIStrings.compositingFailedSVGTargetHasIndependentTransformProperty); |
| break; |
| default: |
| // We should never actually end up here, as adding a new AnimationFailureReason |
| // should also require adding a UIString that describes it |
| str = i18nString(UIStrings.compositingFailedUnknownReason); |
| break; |
| } |
| str && contentHelper.appendElementRow(i18nString(UIStrings.compositingFailed), str, true); |
| } |
| } |
| |
| break; |
| } |
| |
| case Trace.Types.Events.Name.PARSE_HTML: { |
| const beginData = unsafeEventArgs['beginData']; |
| const startLine = beginData['startLine'] - 1; |
| const endLine = unsafeEventArgs['endData'] ? unsafeEventArgs['endData']['endLine'] - 1 : undefined; |
| url = beginData['url']; |
| if (url) { |
| contentHelper.appendLocationRange(i18nString(UIStrings.range), url, startLine, endLine); |
| } |
| break; |
| } |
| |
| // @ts-expect-error Fall-through intended. |
| case Trace.Types.Events.Name.FIRE_IDLE_CALLBACK: { |
| contentHelper.appendTextRow( |
| i18nString(UIStrings.allottedTime), |
| i18n.TimeUtilities.millisToString(unsafeEventData['allottedMilliseconds'])); |
| contentHelper.appendTextRow(i18nString(UIStrings.invokedByTimeout), unsafeEventData['timedOut']); |
| } |
| |
| case Trace.Types.Events.Name.REQUEST_IDLE_CALLBACK: |
| case Trace.Types.Events.Name.CANCEL_IDLE_CALLBACK: { |
| contentHelper.appendTextRow(i18nString(UIStrings.callbackId), unsafeEventData['id']); |
| |
| if (Trace.Types.Events.isRequestIdleCallback(event)) { |
| contentHelper.appendTextRow( |
| i18nString(UIStrings.requestIdleCallbackTimeout), |
| i18n.TimeUtilities.preciseMillisToString(event.args.data.timeout)); |
| } |
| break; |
| } |
| |
| case Trace.Types.Events.Name.EVENT_DISPATCH: { |
| contentHelper.appendTextRow(i18nString(UIStrings.type), unsafeEventData['type']); |
| break; |
| } |
| |
| case Trace.Types.Events.Name.MARK_LCP_CANDIDATE_FOR_SOFT_NAVIGATION: |
| // @ts-expect-error Fall-through intended. |
| case Trace.Types.Events.Name.MARK_LCP_CANDIDATE: { |
| contentHelper.appendTextRow(i18nString(UIStrings.type), String(unsafeEventData['type'])); |
| contentHelper.appendTextRow(i18nString(UIStrings.size), String(unsafeEventData['size'])); |
| } |
| |
| case Trace.Types.Events.Name.MARK_FIRST_PAINT: |
| case Trace.Types.Events.Name.MARK_FCP: |
| case Trace.Types.Events.Name.MARK_LOAD: |
| case Trace.Types.Events.Name.MARK_DOM_CONTENT: { |
| const adjustedEventTimeStamp = timeStampForEventAdjustedForClosestNavigationIfPossible( |
| event, |
| parsedTrace, |
| ); |
| |
| contentHelper.appendTextRow( |
| i18nString(UIStrings.timestamp), i18n.TimeUtilities.preciseMillisToString(adjustedEventTimeStamp, 1)); |
| |
| if (Trace.Types.Events.isMarkerEvent(event)) { |
| contentHelper.appendElementRow( |
| i18nString(UIStrings.details), TimelineUIUtils.buildDetailsNodeForMarkerEvents(event)); |
| } |
| |
| break; |
| } |
| |
| case Trace.Types.Events.Name.EVENT_TIMING: { |
| const detailsNode = await TimelineUIUtils.buildDetailsNodeForTraceEvent( |
| event, targetForEvent(parsedTrace, event), linkifier, isFreshOrEnhanced, parsedTrace); |
| if (detailsNode) { |
| contentHelper.appendElementRow(i18nString(UIStrings.details), detailsNode); |
| } |
| if (Trace.Types.Events.isSyntheticInteraction(event)) { |
| const inputDelay = i18n.TimeUtilities.formatMicroSecondsAsMillisFixed(event.inputDelay); |
| const mainThreadTime = i18n.TimeUtilities.formatMicroSecondsAsMillisFixed(event.mainThreadHandling); |
| const presentationDelay = i18n.TimeUtilities.formatMicroSecondsAsMillisFixed(event.presentationDelay); |
| contentHelper.appendTextRow(i18nString(UIStrings.interactionID), event.interactionId); |
| contentHelper.appendTextRow(i18nString(UIStrings.inputDelay), inputDelay); |
| contentHelper.appendTextRow(i18nString(UIStrings.processingDuration), mainThreadTime); |
| contentHelper.appendTextRow(i18nString(UIStrings.presentationDelay), presentationDelay); |
| } |
| break; |
| } |
| |
| default: { |
| const detailsNode = await TimelineUIUtils.buildDetailsNodeForTraceEvent( |
| event, targetForEvent(parsedTrace, event), linkifier, isFreshOrEnhanced, parsedTrace); |
| if (detailsNode) { |
| contentHelper.appendElementRow(i18nString(UIStrings.details), detailsNode); |
| } |
| break; |
| } |
| } |
| const relatedNodes = relatedNodesMap?.values() || []; |
| for (const relatedNode of relatedNodes) { |
| if (relatedNode) { |
| const nodeSpan = PanelsCommon.DOMLinkifier.Linkifier.instance().linkify(relatedNode); |
| contentHelper.appendElementRow(relatedNodeLabel || i18nString(UIStrings.relatedNode), nodeSpan); |
| } |
| } |
| |
| // @ts-expect-error TODO(crbug.com/1011811): Remove symbol usage. |
| if (event[previewElementSymbol]) { |
| contentHelper.addSection(i18nString(UIStrings.preview)); |
| // @ts-expect-error TODO(crbug.com/1011811): Remove symbol usage. |
| contentHelper.appendElementRow('', event[previewElementSymbol]); |
| } |
| |
| if (!entityAppended) { |
| const originWithEntity = this.getOriginWithEntity(entityMapper, parsedTrace, event); |
| if (originWithEntity) { |
| contentHelper.appendElementRow(i18nString(UIStrings.origin), originWithEntity); |
| } |
| } |
| |
| const hasStackTrace = Boolean(Trace.Helpers.Trace.getStackTraceTopCallFrameInEventPayload(event)); |
| if (Trace.Types.Events.isUserTiming(event) || Trace.Types.Extensions.isSyntheticExtensionEntry(event) || |
| Trace.Types.Events.isProfileCall(event) || initiator || initiatorFor || hasStackTrace || |
| parsedTrace?.data.Invalidations.invalidationsForEvent.get(event)) { |
| await TimelineUIUtils.generateCauses(event, contentHelper, parsedTrace); |
| } |
| |
| if (Root.Runtime.experiments.isEnabled(Root.ExperimentNames.ExperimentName.TIMELINE_DEBUG_MODE)) { |
| TimelineUIUtils.renderEventJson(event, contentHelper); |
| } |
| |
| const stats: TimeRangeCategoryStats = {}; |
| const showPieChart = canShowPieChart && TimelineUIUtils.aggregatedStatsForTraceEvent(stats, parsedTrace, event); |
| if (showPieChart) { |
| contentHelper.addSection(i18nString(UIStrings.aggregatedTime)); |
| const pieChart = TimelineUIUtils.generatePieChart(stats, TimelineUIUtils.eventStyle(event).category, selfTime); |
| contentHelper.appendElementRow('', pieChart); |
| } |
| |
| return contentHelper.fragment; |
| } |
| |
| static statsForTimeRange( |
| events: Trace.Types.Events.Event[], startTime: Trace.Types.Timing.Milli, |
| endTime: Trace.Types.Timing.Milli): TimeRangeCategoryStats { |
| if (!events.length) { |
| return {idle: endTime - startTime}; |
| } |
| |
| buildRangeStatsCacheIfNeeded(events); |
| const aggregatedStats = subtractStats(aggregatedStatsAtTime(endTime), aggregatedStatsAtTime(startTime)); |
| const aggregatedTotal = Object.values(aggregatedStats).reduce((a, b) => a + b, 0); |
| aggregatedStats['idle'] = Math.max(0, endTime - startTime - aggregatedTotal); |
| return aggregatedStats; |
| |
| function aggregatedStatsAtTime(time: number): TimeRangeCategoryStats { |
| const stats: TimeRangeCategoryStats = {}; |
| // @ts-expect-error TODO(crbug.com/1011811): Remove symbol usage. |
| const cache = events[categoryBreakdownCacheSymbol]; |
| for (const category in cache) { |
| const categoryCache = cache[category]; |
| const index = |
| Platform.ArrayUtilities.upperBound(categoryCache.time, time, Platform.ArrayUtilities.DEFAULT_COMPARATOR); |
| let value; |
| if (index === 0) { |
| value = 0; |
| } else if (index === categoryCache.time.length) { |
| value = categoryCache.value[categoryCache.value.length - 1]; |
| } else { |
| const t0 = categoryCache.time[index - 1]; |
| const t1 = categoryCache.time[index]; |
| const v0 = categoryCache.value[index - 1]; |
| const v1 = categoryCache.value[index]; |
| value = v0 + (v1 - v0) * (time - t0) / (t1 - t0); |
| } |
| stats[category] = value; |
| } |
| return stats; |
| } |
| |
| function subtractStats(a: TimeRangeCategoryStats, b: TimeRangeCategoryStats): TimeRangeCategoryStats { |
| const result = Object.assign({}, a); |
| for (const key in b) { |
| result[key] -= b[key]; |
| } |
| return result; |
| } |
| |
| function buildRangeStatsCacheIfNeeded(events: Trace.Types.Events.Event[]): void { |
| // @ts-expect-error TODO(crbug.com/1011811): Remove symbol usage. |
| if (events[categoryBreakdownCacheSymbol]) { |
| return; |
| } |
| |
| // aggregatedStats is a map by categories. For each category there's an array |
| // containing sorted time points which records accumulated value of the category. |
| const aggregatedStats: Record<string, { |
| time: number[], |
| value: number[], |
| }> = {}; |
| const categoryStack: string[] = []; |
| let lastTime = 0; |
| Trace.Helpers.Trace.forEachEvent(events, { |
| onStartEvent, |
| onEndEvent, |
| }); |
| |
| function updateCategory(category: string, time: number): void { |
| let statsArrays: { |
| time: number[], |
| value: number[], |
| } = aggregatedStats[category]; |
| if (!statsArrays) { |
| statsArrays = {time: [], value: []}; |
| aggregatedStats[category] = statsArrays; |
| } |
| if (statsArrays.time.length && statsArrays.time[statsArrays.time.length - 1] === time || lastTime > time) { |
| return; |
| } |
| const lastValue = statsArrays.value.length > 0 ? statsArrays.value[statsArrays.value.length - 1] : 0; |
| statsArrays.value.push(lastValue + time - lastTime); |
| statsArrays.time.push(time); |
| } |
| |
| function categoryChange(from: string|null, to: string|null, time: number): void { |
| if (from) { |
| updateCategory(from, time); |
| } |
| lastTime = time; |
| if (to) { |
| updateCategory(to, time); |
| } |
| } |
| |
| function onStartEvent(e: Trace.Types.Events.Event): void { |
| const {startTime} = Trace.Helpers.Timing.eventTimingsMilliSeconds(e); |
| const category = Trace.Styles.getEventStyle(e.name as Trace.Types.Events.Name)?.category.name || |
| Trace.Styles.getCategoryStyles().other.name; |
| const parentCategory = categoryStack.length ? categoryStack[categoryStack.length - 1] : null; |
| if (category !== parentCategory) { |
| categoryChange(parentCategory || null, category, startTime); |
| } |
| categoryStack.push(category); |
| } |
| |
| function onEndEvent(e: Trace.Types.Events.Event): void { |
| const {endTime} = Trace.Helpers.Timing.eventTimingsMilliSeconds(e); |
| const category = categoryStack.pop(); |
| const parentCategory = categoryStack.length ? categoryStack[categoryStack.length - 1] : null; |
| if (category !== parentCategory) { |
| categoryChange(category || null, parentCategory || null, endTime || 0); |
| } |
| } |
| |
| const obj = (events as Object); |
| // @ts-expect-error TODO(crbug.com/1011811): Remove symbol usage. |
| obj[categoryBreakdownCacheSymbol] = aggregatedStats; |
| } |
| } |
| |
| private static renderEventJson(event: Trace.Types.Events.Event, contentHelper: TimelineDetailsContentHelper): void { |
| contentHelper.addSection(i18nString(UIStrings.traceEvent)); |
| |
| contentHelper.appendElementRow('eventKey', new Trace.EventsSerializer.EventsSerializer().keyForEvent(event) ?? '?'); |
| |
| const eventWithArgsFirst = { |
| ...{args: event.args}, |
| ...event, |
| }; |
| const highlightContainer = TimelineUIUtils.renderObjectJson(eventWithArgsFirst); |
| contentHelper.appendElementRow('', highlightContainer); |
| } |
| |
| private static renderObjectJson(obj: Object|Trace.Types.Extensions.JsonValue): HTMLDivElement { |
| const indentLength = Common.Settings.Settings.instance().moduleSetting('text-editor-indent').get().length; |
| // Elide if the data is huge. Then remove the initial new-line for a denser UI |
| const eventStr = JSON.stringify(obj, null, indentLength).slice(0, 10_000).replace(/{\n /, '{ '); |
| |
| // Use CodeHighlighter for syntax highlighting. |
| const highlightContainer = document.createElement('div'); |
| const shadowRoot = UI.UIUtils.createShadowRootWithCoreStyles(highlightContainer, {cssFile: codeHighlighterStyles}); |
| const elem = shadowRoot.createChild('div'); |
| elem.classList.add('monospace', 'source-code'); |
| elem.textContent = eventStr; |
| // Highlighting is done async (shrug), but we'll return the container immediately. |
| void CodeHighlighter.CodeHighlighter.highlightNode(elem, 'text/javascript').then(() => { |
| /** |
| * Linkify any URLs within the text nodes. |
| * Use a TreeWalker to find all our text nodes |
| **/ |
| function* iterateTreeWalker(walker: TreeWalker): IterableIterator<Node> { |
| while (walker.nextNode()) { |
| yield walker.currentNode; |
| } |
| } |
| const walker = document.createTreeWalker(elem, NodeFilter.SHOW_TEXT); |
| // Gather all the nodes first, then we'll potentially replace them. |
| for (const node of Array.from(iterateTreeWalker(walker))) { |
| const frag = TimelineUIUtils.parseStringForLinks(node.textContent || ''); |
| node.parentNode?.replaceChild(frag, node); |
| } |
| }); |
| |
| return highlightContainer; |
| } |
| |
| static stackTraceFromCallFrames(callFrames: Protocol.Runtime.CallFrame[]|Trace.Types.Events.CallFrame[]): |
| Protocol.Runtime.StackTrace { |
| return {callFrames} as Protocol.Runtime.StackTrace; |
| } |
| |
| /** This renders a stack trace... and other cool stuff. */ |
| static async generateCauses( |
| event: Trace.Types.Events.Event, contentHelper: TimelineDetailsContentHelper, |
| parsedTrace: Trace.TraceModel.ParsedTrace): Promise<void> { |
| const {startTime} = Trace.Helpers.Timing.eventTimingsMilliSeconds(event); |
| let initiatorStackLabel = i18nString(UIStrings.initiatorStackTrace); |
| await contentHelper.appendFunctionStackTraceSection(event, parsedTrace); |
| |
| switch (event.name) { |
| case Trace.Types.Events.Name.TIMER_FIRE: |
| initiatorStackLabel = i18nString(UIStrings.timerInstalled); |
| break; |
| case Trace.Types.Events.Name.FIRE_ANIMATION_FRAME: |
| initiatorStackLabel = i18nString(UIStrings.animationFrameRequested); |
| break; |
| case Trace.Types.Events.Name.FIRE_IDLE_CALLBACK: |
| initiatorStackLabel = i18nString(UIStrings.idleCallbackRequested); |
| break; |
| case Trace.Types.Events.Name.RECALC_STYLE: |
| initiatorStackLabel = i18nString(UIStrings.firstInvalidated); |
| break; |
| case Trace.Types.Events.Name.LAYOUT: |
| initiatorStackLabel = i18nString(UIStrings.firstLayoutInvalidation); |
| break; |
| } |
| |
| const initiator = parsedTrace.data.Initiators.eventToInitiator.get(event); |
| const initiatorFor = parsedTrace.data.Initiators.initiatorToEvents.get(event); |
| const invalidations = parsedTrace.data.Invalidations.invalidationsForEvent.get(event); |
| |
| if (initiator) { |
| // If we have an initiator for the event, we can show its stack trace, a link to reveal the initiator, |
| // and the time since the initiator (Pending For). |
| const stackTrace = Trace.Helpers.Trace.getZeroIndexedStackTraceInEventPayload(initiator); |
| if (stackTrace) { |
| const traceElement = |
| await contentHelper.createChildStackTraceElement(TimelineUIUtils.stackTraceFromCallFrames(stackTrace)); |
| contentHelper.appendSectionWithBodyIfExists(initiatorStackLabel, {body: traceElement}); |
| } |
| |
| const link = this.createEntryLink(initiator); |
| contentHelper.appendElementRow(i18nString(UIStrings.initiatedBy), link); |
| |
| const {startTime: initiatorStartTime} = Trace.Helpers.Timing.eventTimingsMilliSeconds(initiator); |
| const delay = startTime - initiatorStartTime; |
| contentHelper.appendTextRow(i18nString(UIStrings.pendingFor), i18n.TimeUtilities.preciseMillisToString(delay, 1)); |
| } |
| |
| if (initiatorFor) { |
| // If the event is an initiator for some entries, add links to reveal them. |
| const links = document.createElement('div'); |
| initiatorFor.map((initiator, i) => { |
| links.appendChild(this.createEntryLink(initiator)); |
| // Add space between each link if it's not last |
| if (i < initiatorFor.length - 1) { |
| links.append(' '); |
| } |
| }); |
| contentHelper.appendElementRow(UIStrings.initiatorFor, links); |
| } |
| |
| if (invalidations?.length) { |
| const totalInvalidations = parsedTrace.data.Invalidations.invalidationCountForEvent.get(event) ?? |
| 0; // Won't be 0, but saves us dealing with undefined. |
| contentHelper.addSection(i18nString(UIStrings.invalidations, {PH1: totalInvalidations})); |
| await TimelineUIUtils.generateInvalidationsList(invalidations, contentHelper); |
| } |
| } |
| |
| private static createEntryLink(entry: Trace.Types.Events.Event): HTMLElement { |
| const link = document.createElement('span'); |
| |
| const traceBoundsState = TraceBounds.TraceBounds.BoundsManager.instance().state(); |
| |
| if (!traceBoundsState) { |
| console.error('Tried to link to an entry without any traceBoundsState. This should never happen.'); |
| return link; |
| } |
| |
| // Check is the entry is outside of the current breadcrumb. If it is, don't create a link to navigate to it because there is no way to navigate outside breadcrumb without removing it. Instead, just display the name and "outside breadcrumb" text |
| // Consider entry outside breadcrumb only if it is fully outside. If a part of it is visible, we can still select it. |
| const isEntryOutsideBreadcrumb = traceBoundsState.micro.minimapTraceBounds.min > entry.ts + (entry.dur || 0) || |
| traceBoundsState.micro.minimapTraceBounds.max < entry.ts; |
| |
| // Check if it is in the hidden array |
| const isEntryHidden = ModificationsManager.activeManager()?.getEntriesFilter().entryIsInvisible(entry); |
| |
| if (!isEntryOutsideBreadcrumb) { |
| link.classList.add('timeline-link'); |
| UI.ARIAUtils.markAsLink(link); |
| link.tabIndex = 0; |
| link.addEventListener('click', () => { |
| TimelinePanel.instance().select(selectionFromEvent(entry)); |
| }); |
| |
| link.addEventListener('keydown', event => { |
| if (event.key === Platform.KeyboardUtilities.ENTER_KEY) { |
| TimelinePanel.instance().select(selectionFromEvent(entry)); |
| event.consume(true); |
| } |
| }); |
| } |
| |
| if (isEntryHidden) { |
| link.textContent = this.eventTitle(entry) + ' ' + i18nString(UIStrings.entryIsHidden); |
| } else if (isEntryOutsideBreadcrumb) { |
| link.textContent = this.eventTitle(entry) + ' ' + i18nString(UIStrings.outsideBreadcrumbRange); |
| } else { |
| link.textContent = this.eventTitle(entry); |
| } |
| |
| return link; |
| } |
| |
| private static async generateInvalidationsList( |
| invalidations: Trace.Types.Events.InvalidationTrackingEvent[], |
| contentHelper: TimelineDetailsContentHelper): Promise<void> { |
| const {groupedByReason, backendNodeIds} = TimelineComponents.DetailsView.generateInvalidationsList(invalidations); |
| |
| let relatedNodesMap: Map<number, SDK.DOMModel.DOMNode|null>|null = null; |
| const target = SDK.TargetManager.TargetManager.instance().primaryPageTarget(); |
| const domModel = target?.model(SDK.DOMModel.DOMModel); |
| if (domModel) { |
| relatedNodesMap = await domModel.pushNodesByBackendIdsToFrontend(backendNodeIds); |
| } |
| |
| Object.keys(groupedByReason).forEach(reason => { |
| TimelineUIUtils.generateInvalidationsForReason(reason, groupedByReason[reason], relatedNodesMap, contentHelper); |
| }); |
| } |
| |
| private static generateInvalidationsForReason( |
| reason: string, invalidations: Trace.Types.Events.InvalidationTrackingEvent[], |
| relatedNodesMap: Map<number, SDK.DOMModel.DOMNode|null>|null, contentHelper: TimelineDetailsContentHelper): void { |
| function createLinkForInvalidationNode(invalidation: Trace.Types.Events.InvalidationTrackingEvent): |
| HTMLSpanElement { |
| const node = (invalidation.args.data.nodeId && relatedNodesMap) ? |
| relatedNodesMap.get(invalidation.args.data.nodeId) : |
| null; |
| if (node) { |
| const nodeSpan = document.createElement('span'); |
| nodeSpan.appendChild(PanelsCommon.DOMLinkifier.Linkifier.instance().linkify(node)); |
| return nodeSpan; |
| } |
| if (invalidation.args.data.nodeName) { |
| const nodeSpan = document.createElement('span'); |
| nodeSpan.textContent = invalidation.args.data.nodeName; |
| return nodeSpan; |
| } |
| const nodeSpan = document.createElement('span'); |
| UI.UIUtils.createTextChild(nodeSpan, i18nString(UIStrings.UnknownNode)); |
| return nodeSpan; |
| } |
| |
| const generatedItems = new Set<string>(); |
| |
| for (const invalidation of invalidations) { |
| const stackTrace = Trace.Helpers.Trace.getZeroIndexedStackTraceInEventPayload(invalidation); |
| let scriptLink: HTMLElement|null = null; |
| const callFrame = stackTrace?.at(0); |
| if (callFrame) { |
| scriptLink = contentHelper.linkifier()?.maybeLinkifyScriptLocation( |
| SDK.TargetManager.TargetManager.instance().rootTarget(), |
| callFrame.scriptId as Protocol.Runtime.ScriptId, |
| callFrame.url as Platform.DevToolsPath.UrlString, |
| callFrame.lineNumber, |
| ) || |
| null; |
| } |
| |
| const niceNodeLink = createLinkForInvalidationNode(invalidation); |
| |
| const text = scriptLink ? |
| uiI18n.getFormatLocalizedString( |
| str_, UIStrings.invalidationWithCallFrame, {PH1: niceNodeLink, PH2: scriptLink}) as HTMLElement : |
| niceNodeLink; |
| |
| // Sometimes we can get different Invalidation events which cause |
| // the same text for the same element for the same reason to be |
| // generated. Rather than show the user duplicates, if we have |
| // generated text that looks identical to this before, we will |
| // bail. |
| const generatedText: string = (typeof text === 'string' ? text : text.innerText); |
| if (generatedItems.has(generatedText)) { |
| continue; |
| } |
| |
| generatedItems.add(generatedText); |
| contentHelper.appendElementRow(reason, text); |
| } |
| } |
| |
| /** Populates the passed object then returns true/false if it makes sense to show the pie chart */ |
| private static aggregatedStatsForTraceEvent( |
| total: TimeRangeCategoryStats, parsedTrace: Trace.TraceModel.ParsedTrace, |
| event: Trace.Types.Events.Event): boolean { |
| const node = parsedTrace.data.Renderer.entryToNode.get(event); |
| if (!node) { |
| return false; |
| } |
| |
| // If the event has no children, we cannot calculate a pie chart. |
| if (node.children.length === 0) { |
| return false; |
| } |
| |
| const childNodesToVisit: Trace.Helpers.TreeHelpers.TraceEntryNode[] = [...node.children]; |
| |
| while (childNodesToVisit.length) { |
| // Traversal order doesn't matter, pop() is more efficient than shift(). |
| const childNode = childNodesToVisit.pop(); |
| if (!childNode) { |
| continue; |
| } |
| const childSelfTime = childNode.selfTime ?? 0; |
| if (childSelfTime > 0) { |
| const categoryName = TimelineUIUtils.eventStyle(childNode.entry).category.name; |
| total[categoryName] = (total[categoryName] || 0) + childSelfTime; |
| } |
| |
| childNodesToVisit.push(...childNode.children); |
| } |
| |
| if (Trace.Types.Events.isPhaseAsync(event.ph)) { |
| let aggregatedTotal = 0; |
| for (const categoryName in total) { |
| aggregatedTotal += total[categoryName]; |
| } |
| |
| const {startTime, endTime} = Trace.Helpers.Timing.eventTimingsMicroSeconds(event); |
| const deltaInMicro = (endTime - startTime) as Trace.Types.Timing.Micro; |
| total['idle'] = Math.max(0, deltaInMicro - aggregatedTotal); |
| return false; |
| } |
| |
| for (const categoryName in total) { |
| const value = total[categoryName] as Trace.Types.Timing.Micro; |
| // Up until now we've kept the math all in micro integers. Finally switch these sums to milli. |
| total[categoryName] = Trace.Helpers.Timing.microToMilli(value); |
| } |
| |
| return true; |
| } |
| |
| static async buildPicturePreviewContent( |
| parsedTrace: Trace.TraceModel.ParsedTrace, event: Trace.Types.Events.Paint, |
| target: SDK.Target.Target): Promise<Element|null> { |
| const snapshotEvent = parsedTrace.data.LayerTree.paintsToSnapshots.get(event); |
| if (!snapshotEvent) { |
| return null; |
| } |
| |
| const paintProfilerModel = target.model(SDK.PaintProfiler.PaintProfilerModel); |
| if (!paintProfilerModel) { |
| return null; |
| } |
| const snapshot = await paintProfilerModel.loadSnapshot(snapshotEvent.args.snapshot.skp64); |
| if (!snapshot) { |
| return null; |
| } |
| |
| const snapshotWithRect = { |
| snapshot, |
| rect: snapshotEvent.args.snapshot.params?.layer_rect, |
| }; |
| |
| if (!snapshotWithRect) { |
| return null; |
| } |
| const imageURLPromise = snapshotWithRect.snapshot.replay(); |
| snapshotWithRect.snapshot.release(); |
| const imageURL = await imageURLPromise as Platform.DevToolsPath.UrlString; |
| if (!imageURL) { |
| return null; |
| } |
| const stylesContainer = document.createElement('div'); |
| const shadowRoot = stylesContainer.attachShadow({mode: 'open'}); |
| shadowRoot.createChild('style').textContent = imagePreviewStyles; |
| const container = shadowRoot.createChild('div'); |
| container.classList.add('image-preview-container', 'vbox', 'link'); |
| const img = container.createChild('img'); |
| img.src = imageURL; |
| img.alt = LegacyComponents.ImagePreview.ImagePreview.defaultAltTextForImageURL(imageURL); |
| const paintProfilerButton = container.createChild('a'); |
| paintProfilerButton.textContent = i18nString(UIStrings.paintProfiler); |
| UI.ARIAUtils.markAsLink(container); |
| container.tabIndex = 0; |
| container.addEventListener('click', () => TimelinePanel.instance().select(selectionFromEvent(event)), false); |
| container.addEventListener('keydown', keyEvent => { |
| if (keyEvent.key === Platform.KeyboardUtilities.ENTER_KEY) { |
| TimelinePanel.instance().select(selectionFromEvent(event)); |
| keyEvent.consume(true); |
| } |
| }); |
| return stylesContainer; |
| } |
| |
| static createEventDivider(event: Trace.Types.Events.Event, zeroTime: number): HTMLDivElement { |
| const eventDivider = document.createElement('div'); |
| eventDivider.classList.add('resources-event-divider'); |
| const {startTime: eventStartTime} = Trace.Helpers.Timing.eventTimingsMilliSeconds(event); |
| |
| const startTime = i18n.TimeUtilities.millisToString(eventStartTime - zeroTime); |
| UI.Tooltip.Tooltip.install( |
| eventDivider, i18nString(UIStrings.sAtS, {PH1: TimelineUIUtils.eventTitle(event), PH2: startTime})); |
| const style = TimelineUIUtils.markerStyleForEvent(event); |
| if (style.tall) { |
| eventDivider.style.backgroundColor = style.color; |
| } |
| return eventDivider; |
| } |
| |
| static visibleEventsFilter(): Trace.Extras.TraceFilter.TraceFilter { |
| return new Trace.Extras.TraceFilter.VisibleEventsFilter(Trace.Styles.visibleTypes()); |
| } |
| |
| // Included only for layout tests. |
| // TODO(crbug.com/1386091): Fix/port layout tests and remove. |
| static categories(): Trace.Styles.CategoryPalette { |
| return Trace.Styles.getCategoryStyles(); |
| } |
| |
| static generatePieChart( |
| aggregatedStats: TimeRangeCategoryStats, selfCategory?: Trace.Styles.TimelineCategory, |
| selfTime?: Trace.Types.Timing.Micro): Element { |
| let total = 0; |
| for (const categoryName in aggregatedStats) { |
| total += aggregatedStats[categoryName]; |
| } |
| |
| const element = document.createElement('div'); |
| element.classList.add('timeline-details-view-pie-chart-wrapper'); |
| element.classList.add('hbox'); |
| |
| const pieChart = new PerfUI.PieChart.PieChart(); |
| const slices: Array<{ |
| value: number, |
| color: string, |
| title: string, |
| }> = []; |
| |
| function appendLegendRow(title: string, value: number, color: string): void { |
| if (!value) { |
| return; |
| } |
| slices.push({value, color, title}); |
| } |
| |
| // In case of self time, first add self, then children of the same category. |
| if (selfCategory) { |
| const selfTimeMilli = Trace.Helpers.Timing.microToMilli(selfTime || 0 as Trace.Types.Timing.Micro); |
| if (selfTime) { |
| appendLegendRow( |
| i18nString(UIStrings.sSelf, {PH1: selfCategory.title}), |
| selfTimeMilli, |
| selfCategory.getCSSValue(), |
| ); |
| } |
| // Children of the same category. |
| const categoryTime = aggregatedStats[selfCategory.name]; |
| const value = categoryTime - (selfTimeMilli || 0); |
| if (value > 0) { |
| appendLegendRow( |
| i18nString(UIStrings.sChildren, {PH1: selfCategory.title}), |
| value, |
| selfCategory.getCSSValue(), |
| ); |
| } |
| } |
| |
| // Add other categories. |
| for (const categoryName in Trace.Styles.getCategoryStyles()) { |
| const category = Trace.Styles.getCategoryStyles()[categoryName as keyof Trace.Styles.CategoryPalette]; |
| if (categoryName === selfCategory?.name) { |
| // Do not add an entry for this event's self category because 2 |
| // entries for it where added just before this for loop (for |
| // self and children times). |
| continue; |
| } |
| appendLegendRow(category.title, aggregatedStats[category.name], category.getCSSValue()); |
| } |
| |
| pieChart.data = { |
| chartName: i18nString(UIStrings.timeSpentInRendering), |
| size: 110, |
| formatter: (value: number) => i18n.TimeUtilities.preciseMillisToString(value), |
| showLegend: true, |
| total, |
| slices, |
| }; |
| const pieChartContainer = element.createChild('div', 'vbox'); |
| pieChartContainer.appendChild(pieChart); |
| |
| return element; |
| } |
| |
| static generateDetailsContentForFrame( |
| frame: Trace.Types.Events.LegacyTimelineFrame, filmStrip: Trace.Extras.FilmStrip.Data|null, |
| filmStripFrame: Trace.Extras.FilmStrip.Frame|null): DocumentFragment { |
| const contentHelper = new TimelineDetailsContentHelper(null, null); |
| contentHelper.addSection(i18nString(UIStrings.frame)); |
| |
| const duration = TimelineUIUtils.frameDuration(frame); |
| contentHelper.appendElementRow(i18nString(UIStrings.duration), duration); |
| if (filmStrip && filmStripFrame) { |
| const filmStripPreview = document.createElement('div'); |
| filmStripPreview.classList.add('timeline-filmstrip-preview'); |
| // TODO(paulirish): Adopt Util.ImageCache |
| const uri = Trace.Handlers.ModelHandlers.Screenshots.screenshotImageDataUri(filmStripFrame.screenshotEvent); |
| void UI.UIUtils.loadImage(uri).then(image => image && filmStripPreview.appendChild(image)); |
| contentHelper.appendElementRow('', filmStripPreview); |
| filmStripPreview.addEventListener('click', frameClicked.bind(null, filmStrip, filmStripFrame), false); |
| } |
| |
| function frameClicked(filmStrip: Trace.Extras.FilmStrip.Data, filmStripFrame: Trace.Extras.FilmStrip.Frame): void { |
| PerfUI.FilmStripView.Dialog.fromFilmStrip(filmStrip, filmStripFrame.index); |
| } |
| |
| return contentHelper.fragment; |
| } |
| |
| static frameDuration(frame: Trace.Types.Events.LegacyTimelineFrame): Element { |
| const offsetMilli = Trace.Helpers.Timing.microToMilli(frame.startTimeOffset); |
| const durationMilli = Trace.Helpers.Timing.microToMilli(Trace.Types.Timing.Micro(frame.endTime - frame.startTime)); |
| |
| const durationText = i18nString(UIStrings.sAtSParentheses, { |
| PH1: i18n.TimeUtilities.millisToString(durationMilli, true), |
| PH2: i18n.TimeUtilities.millisToString(offsetMilli, true), |
| }); |
| return uiI18n.getFormatLocalizedString(str_, UIStrings.emptyPlaceholder, {PH1: durationText}); |
| } |
| |
| static quadWidth(quad: number[]): number { |
| return Math.round(Math.sqrt(Math.pow(quad[0] - quad[2], 2) + Math.pow(quad[1] - quad[3], 2))); |
| } |
| |
| static quadHeight(quad: number[]): number { |
| return Math.round(Math.sqrt(Math.pow(quad[0] - quad[6], 2) + Math.pow(quad[1] - quad[7], 2))); |
| } |
| |
| static eventDispatchDesciptors(): EventDispatchTypeDescriptor[] { |
| if (eventDispatchDesciptors) { |
| return eventDispatchDesciptors; |
| } |
| const lightOrange = 'hsl(40,100%,80%)'; |
| const orange = 'hsl(40,100%,50%)'; |
| const green = 'hsl(90,100%,40%)'; |
| const purple = 'hsl(256,100%,75%)'; |
| eventDispatchDesciptors = [ |
| new EventDispatchTypeDescriptor( |
| 1, lightOrange, ['mousemove', 'mouseenter', 'mouseleave', 'mouseout', 'mouseover']), |
| new EventDispatchTypeDescriptor( |
| 1, lightOrange, ['pointerover', 'pointerout', 'pointerenter', 'pointerleave', 'pointermove']), |
| new EventDispatchTypeDescriptor(2, green, ['wheel']), |
| new EventDispatchTypeDescriptor(3, orange, ['click', 'mousedown', 'mouseup']), |
| new EventDispatchTypeDescriptor(3, orange, ['touchstart', 'touchend', 'touchmove', 'touchcancel']), |
| new EventDispatchTypeDescriptor( |
| 3, orange, ['pointerdown', 'pointerup', 'pointercancel', 'gotpointercapture', 'lostpointercapture']), |
| new EventDispatchTypeDescriptor(3, purple, ['keydown', 'keyup', 'keypress']), |
| ]; |
| return eventDispatchDesciptors; |
| } |
| |
| static markerStyleForEvent(event: Trace.Types.Events.Event): TimelineMarkerStyle { |
| // Note: keep the colors matching that of `markerDetailsForEvent`. |
| |
| const tallMarkerDashStyle = [6, 4]; |
| const title = TimelineUIUtils.eventTitle(event); |
| |
| if (event.name !== Trace.Types.Events.Name.NAVIGATION_START && |
| (Trace.Helpers.Trace.eventHasCategory(event, Trace.Types.Events.Categories.Console) || |
| Trace.Helpers.Trace.eventHasCategory(event, Trace.Types.Events.Categories.UserTiming))) { |
| return { |
| title, |
| dashStyle: tallMarkerDashStyle, |
| lineWidth: 0.5, |
| color: Trace.Helpers.Trace.eventHasCategory(event, Trace.Types.Events.Categories.Console) ? 'purple' : 'orange', |
| tall: false, |
| lowPriority: false, |
| }; |
| } |
| let tall = false; |
| let color = 'grey'; |
| switch (event.name) { |
| case Trace.Types.Events.Name.NAVIGATION_START: |
| color = 'var(--color-text-primary)'; |
| tall = true; |
| break; |
| case Trace.Types.Events.Name.SOFT_NAVIGATION_START: |
| color = 'var(--sys-color-blue)'; |
| tall = true; |
| break; |
| case Trace.Types.Events.Name.FRAME_STARTED_LOADING: |
| color = 'green'; |
| tall = true; |
| break; |
| case Trace.Types.Events.Name.MARK_DOM_CONTENT: |
| color = 'var(--color-text-disabled)'; |
| tall = true; |
| break; |
| case Trace.Types.Events.Name.MARK_LOAD: |
| color = 'var(--color-text-disabled)'; |
| tall = true; |
| break; |
| case Trace.Types.Events.Name.MARK_FIRST_PAINT: |
| color = '#228847'; |
| tall = true; |
| break; |
| case Trace.Types.Events.Name.MARK_FCP: |
| color = 'var(--sys-color-green-bright)'; |
| tall = true; |
| break; |
| case Trace.Types.Events.Name.MARK_LCP_CANDIDATE_FOR_SOFT_NAVIGATION: |
| case Trace.Types.Events.Name.MARK_LCP_CANDIDATE: |
| color = 'var(--sys-color-green)'; |
| tall = true; |
| break; |
| case Trace.Types.Events.Name.TIME_STAMP: |
| color = 'orange'; |
| break; |
| } |
| return { |
| title, |
| dashStyle: tallMarkerDashStyle, |
| lineWidth: 0.5, |
| color, |
| tall, |
| lowPriority: false, |
| }; |
| } |
| |
| static colorForId(id: string): string { |
| if (!colorGenerator) { |
| colorGenerator = new Common.Color.Generator( |
| { |
| min: 30, |
| max: 330, |
| }, |
| { |
| min: 50, |
| max: 80, |
| count: 3, |
| }, |
| 85); |
| colorGenerator.setColorForID('', '#f2ecdc'); |
| } |
| return colorGenerator.colorForID(id); |
| } |
| |
| static displayNameForFrame(frame: Trace.Types.Events.TraceFrame, trimAt = 80): string { |
| const url = frame.url as Platform.DevToolsPath.UrlString; |
| return Common.ParsedURL.schemeIs(url, 'about:') ? `"${Platform.StringUtilities.trimMiddle(frame.name, trimAt)}"` : |
| frame.url.slice(0, trimAt); |
| } |
| |
| static getOriginWithEntity( |
| entityMapper: Trace.EntityMapper.EntityMapper|null, parsedTrace: Trace.TraceModel.ParsedTrace, |
| event: Trace.Types.Events.Event): string|null { |
| const resolvedURL = SourceMapsResolver.SourceMapsResolver.resolvedURLForEntry(parsedTrace, event); |
| if (!resolvedURL) { |
| return null; |
| } |
| const parsedUrl = URL.parse(resolvedURL); |
| if (!parsedUrl) { |
| return null; |
| } |
| |
| const entity = entityMapper?.entityForEvent(event) ?? null; |
| if (!entity) { |
| return null; |
| } |
| |
| const originWithEntity = Utils.Helpers.formatOriginWithEntity(parsedUrl, entity, true); |
| return originWithEntity; |
| } |
| } |
| |
| export const aggregatedStatsKey = Symbol('aggregatedStats'); |
| |
| export const previewElementSymbol = Symbol('previewElement'); |
| |
| export class EventDispatchTypeDescriptor { |
| priority: number; |
| color: string; |
| eventTypes: string[]; |
| |
| constructor(priority: number, color: string, eventTypes: string[]) { |
| this.priority = priority; |
| this.color = color; |
| this.eventTypes = eventTypes; |
| } |
| } |
| |
| export class TimelineDetailsContentHelper { |
| fragment: DocumentFragment; |
| #linkifier: LegacyComponents.Linkifier.Linkifier|null; |
| private target: SDK.Target.Target|null; |
| element: HTMLDivElement; |
| private tableElement: HTMLElement; |
| |
| constructor(target: SDK.Target.Target|null, linkifier: LegacyComponents.Linkifier.Linkifier|null) { |
| this.fragment = document.createDocumentFragment(); |
| |
| this.#linkifier = linkifier; |
| this.target = target; |
| |
| this.element = document.createElement('div'); |
| this.element.classList.add('timeline-details-view-block'); |
| this.tableElement = this.element.createChild('div', 'vbox timeline-details-chip-body'); |
| this.fragment.appendChild(this.element); |
| } |
| |
| addSection(title: string, swatchColor?: string, event?: Trace.Types.Events.Event): void { |
| if (!this.tableElement.hasChildNodes()) { |
| this.element.removeChildren(); |
| } else { |
| this.element = document.createElement('div'); |
| this.element.classList.add('timeline-details-view-block'); |
| this.fragment.appendChild(this.element); |
| } |
| |
| if (title) { |
| const titleElement = this.element.createChild('div', 'timeline-details-chip-title'); |
| if (swatchColor) { |
| titleElement.createChild('div').style.backgroundColor = swatchColor; |
| } |
| |
| const textChild = titleElement.createChild('span'); |
| textChild.textContent = title; |
| |
| if (event) { |
| textChild.classList.add('timeline-details-chip-title-reveal-entry'); |
| textChild.addEventListener('click', function() { |
| TimelinePanel.instance().zoomEvent(event); |
| }); |
| } |
| } |
| |
| this.tableElement = this.element.createChild('div', 'vbox timeline-details-chip-body'); |
| this.fragment.appendChild(this.element); |
| } |
| |
| /** |
| * Creates a new section, but only if the provided `body` element is present, |
| * otherwise it does nothing. |
| */ |
| appendSectionWithBodyIfExists(title: string, options: { |
| body: HTMLElement|null, |
| swatchColor?: string, |
| event?: Trace.Types.Events.Event, |
| }): void { |
| if (!options.body) { |
| return; |
| } |
| this.addSection(title, options.swatchColor, options.event); |
| this.tableElement.appendChild(options.body); |
| } |
| |
| /** |
| * Generates a stack trace for the given event. If there is no stack data, |
| * nothing is appended; you can safely call this without fearing that it will |
| * create an empty section. |
| */ |
| async appendFunctionStackTraceSection( |
| event: Trace.Types.Events.Event, |
| parsedTrace: Trace.TraceModel.ParsedTrace, |
| ): Promise<void> { |
| const stackTraceForEvent = Trace.Extras.StackTraceForEvent.get(event, parsedTrace.data); |
| if (!stackTraceForEvent) { |
| return; |
| } |
| const traceElement = await this.createChildStackTraceElement(stackTraceForEvent); |
| this.appendSectionWithBodyIfExists(i18nString(UIStrings.functionStack), {body: traceElement}); |
| } |
| |
| linkifier(): LegacyComponents.Linkifier.Linkifier|null { |
| return this.#linkifier; |
| } |
| |
| appendTextRow(title: string, value: string|number|boolean): void { |
| const rowElement = this.tableElement.createChild('div', 'timeline-details-view-row'); |
| rowElement.createChild('div', 'timeline-details-view-row-title').textContent = title; |
| rowElement.createChild('div', 'timeline-details-view-row-value').textContent = value.toString(); |
| } |
| |
| appendElementRow(title: string, content: string|Node, isWarning?: boolean, isStacked?: boolean): void { |
| const rowElement = this.tableElement.createChild('div', 'timeline-details-view-row'); |
| rowElement.setAttribute('data-row-title', title); |
| if (isWarning) { |
| rowElement.classList.add('timeline-details-warning'); |
| } |
| if (isStacked) { |
| rowElement.classList.add('timeline-details-stack-values'); |
| } |
| const titleElement = rowElement.createChild('div', 'timeline-details-view-row-title'); |
| titleElement.textContent = title; |
| const valueElement = rowElement.createChild('div', 'timeline-details-view-row-value'); |
| if (content instanceof Node) { |
| valueElement.appendChild(content); |
| } else { |
| UI.UIUtils.createTextChild(valueElement, content || ''); |
| } |
| } |
| |
| appendLocationRow( |
| title: string, url: string, startLine: number, startColumn?: number, text?: string, omitOrigin?: boolean): void { |
| if (!this.#linkifier) { |
| return; |
| } |
| |
| const options = { |
| tabStop: true, |
| columnNumber: startColumn, |
| showColumnNumber: true, |
| inlineFrameIndex: 0, |
| text, |
| omitOrigin, |
| }; |
| const link = this.#linkifier.maybeLinkifyScriptLocation( |
| this.target, null, url as Platform.DevToolsPath.UrlString, startLine, options); |
| if (!link) { |
| return; |
| } |
| this.appendElementRow(title, link); |
| } |
| |
| appendLocationRange(title: string, url: Platform.DevToolsPath.UrlString, startLine: number, endLine?: number): void { |
| if (!this.#linkifier || !this.target) { |
| return; |
| } |
| const locationContent = document.createElement('span'); |
| const link = this.#linkifier.maybeLinkifyScriptLocation( |
| this.target, null, url, startLine, {tabStop: true, inlineFrameIndex: 0}); |
| if (!link) { |
| return; |
| } |
| locationContent.appendChild(link); |
| UI.UIUtils.createTextChild( |
| locationContent, Platform.StringUtilities.sprintf(' [%s…%s]', startLine + 1, (endLine || 0) + 1 || '')); |
| this.appendElementRow(title, locationContent); |
| } |
| |
| /** |
| * Creates a stack trace element for the given trace, but checks if it |
| * contains any entries, and discards it if it's empty. |
| */ |
| async createChildStackTraceElement(runtimeStackTrace: Protocol.Runtime.StackTrace): Promise<HTMLElement|null> { |
| // Fallback to the main page/root target. Maybe the main page has a source map we need. |
| // Worst case the stack is identity mapped. |
| const targetManager = SDK.TargetManager.TargetManager.instance(); |
| const target = this.target ?? targetManager.primaryPageTarget() ?? targetManager.rootTarget(); |
| if (!target) { |
| return null; |
| } |
| |
| const stackTrace = |
| await Bindings.DebuggerWorkspaceBinding.DebuggerWorkspaceBinding.instance().createStackTraceFromProtocolRuntime( |
| runtimeStackTrace, target); |
| const callFrameContents = new LegacyComponents.JSPresentationUtils.StackTracePreviewContent(); |
| callFrameContents.options = {tabStops: true, showColumnNumber: true}; |
| callFrameContents.stackTrace = stackTrace; |
| |
| await callFrameContents.updateComplete; |
| if (!callFrameContents.hasContent()) { |
| return null; |
| } |
| |
| const stackTraceElement = document.createElement('div'); |
| stackTraceElement.classList.add('timeline-details-view-row', 'timeline-details-stack-values'); |
| callFrameContents.markAsRoot(); |
| callFrameContents.show(stackTraceElement); |
| return stackTraceElement; |
| } |
| } |
| |
| export const categoryBreakdownCacheSymbol = Symbol('categoryBreakdownCache'); |
| export interface TimelineMarkerStyle { |
| title: string; |
| color: string; |
| lineWidth: number; |
| dashStyle: number[]; |
| tall: boolean; |
| lowPriority: boolean; |
| } |
| |
| /** |
| * Given a particular event, this method can adjust its timestamp by |
| * substracting the timestamp of the previous navigation. This helps in cases |
| * where the user has navigated multiple times in the trace, so that we can show |
| * the LCP (for example) relative to the last navigation. |
| **/ |
| export function timeStampForEventAdjustedForClosestNavigationIfPossible( |
| event: Trace.Types.Events.Event, parsedTrace: Trace.TraceModel.ParsedTrace|null): Trace.Types.Timing.Milli { |
| if (!parsedTrace) { |
| const {startTime} = Trace.Helpers.Timing.eventTimingsMilliSeconds(event); |
| return startTime; |
| } |
| |
| const time = Trace.Helpers.Timing.timeStampForEventAdjustedByClosestNavigation( |
| event, |
| parsedTrace.data.Meta.traceBounds, |
| parsedTrace.data.Meta.navigationsByNavigationId, |
| parsedTrace.data.Meta.softNavigationsById, |
| parsedTrace.data.Meta.navigationsByFrameId, |
| ); |
| return Trace.Helpers.Timing.microToMilli(time); |
| } |
| |
| /** |
| * 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 time. This method does not filter out events: for example, it treats |
| * every LCP Candidate event as a potential marker event. |
| **/ |
| export function isMarkerEvent(parsedTrace: Trace.TraceModel.ParsedTrace, event: Trace.Types.Events.Event): boolean { |
| const {Name} = Trace.Types.Events; |
| |
| if (event.name === Name.TIME_STAMP || event.name === Name.NAVIGATION_START || |
| event.name === Name.SOFT_NAVIGATION_START) { |
| return true; |
| } |
| |
| if (Trace.Types.Events.isFirstContentfulPaint(event) || Trace.Types.Events.isFirstPaint(event)) { |
| return event.args.frame === parsedTrace.data.Meta.mainFrameId; |
| } |
| |
| if (Trace.Types.Events.isMarkDOMContent(event) || Trace.Types.Events.isMarkLoad(event) || |
| Trace.Types.Events.isAnyLargestContentfulPaintCandidate(event)) { |
| // isOutermostMainFrame was added in 2022, so we fallback to isMainFrame |
| // for older traces. |
| if (!event.args.data) { |
| return false; |
| } |
| const {isOutermostMainFrame, isMainFrame} = event.args.data; |
| if (typeof isOutermostMainFrame !== 'undefined') { |
| // If isOutermostMainFrame is defined we want to use that and not |
| // fallback to isMainFrame, even if isOutermostMainFrame is false. Hence |
| // this check. |
| return isOutermostMainFrame; |
| } |
| return Boolean(isMainFrame); |
| } |
| |
| return false; |
| } |
| |
| function getEventSelfTime( |
| event: Trace.Types.Events.Event, parsedTrace: Trace.TraceModel.ParsedTrace): Trace.Types.Timing.Micro { |
| const mapToUse = Trace.Types.Extensions.isSyntheticExtensionEntry(event) ? |
| parsedTrace.data.ExtensionTraceData.entryToNode : |
| parsedTrace.data.Renderer.entryToNode; |
| const selfTime = mapToUse.get(event)?.selfTime; |
| return selfTime ? selfTime : Trace.Types.Timing.Micro(0); |
| } |