UNPKG

chrome-devtools-frontend

Version:
486 lines (423 loc) 19.5 kB
// Copyright 2016 The Chromium Authors // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import type * as Common from '../../core/common/common.js'; import * as i18n from '../../core/i18n/i18n.js'; import type * 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 CrUXManager from '../../models/crux-manager/crux-manager.js'; import * as LiveMetrics from '../../models/live-metrics/live-metrics.js'; import * as Trace from '../../models/trace/trace.js'; import * as PanelCommon from '../../panels/common/common.js'; import * as Tracing from '../../services/tracing/tracing.js'; import * as RecordingMetadata from './RecordingMetadata.js'; const UIStrings = { /** * @description Text in Timeline Panel of the Performance panel */ initializingTracing: 'Initializing tracing…', /** * @description Text in Timeline Controller of the Performance panel indicating that the Performance Panel cannot * record a performance trace because the type of target (where possible types are page, service worker and shared * worker) doesn't support it. */ tracingNotSupported: 'Performance trace recording not supported for this type of target', /** * @description Text in a status dialog shown during a performance trace of a web page. It indicates to the user what the tracing is currently waiting on. */ waitingForLoadEvent: 'Waiting for load event…', /** * @description Text in a status dialog shown during a performance trace of a web page. It indicates to the user what the tracing is currently waiting on. */ waitingForLoadEventPlus5Seconds: 'Waiting for load event (+5s)…', } as const; const str_ = i18n.i18n.registerUIStrings('panels/timeline/TimelineController.ts', UIStrings); const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); type StatusUpdate = string|null; type Listener = (status: StatusUpdate) => void; /** * Accepts promises with a text label, and reports to a listener as promises resolve. * Only returns the label of the first incomplete promise. When no more promises * remain, the updated status is null. */ class StatusChecker { #checkers: Array<{title: string, complete: boolean}> = []; #listener: Listener|null = null; #currentStatus: StatusUpdate = null; add(title: string, promise: Promise<unknown>): void { const item = {title, complete: false}; this.#checkers.push(item); void promise.finally(() => { item.complete = true; this.#evaluate(); }); } setListener(listener: Listener): void { this.#listener = null; this.#evaluate(); this.#listener = listener; listener(this.#currentStatus); } removeListener(): void { this.#listener = null; } #evaluate(): void { let nextStatus: StatusUpdate = null; // Only report the status of the first incomplete checker. for (const checker of this.#checkers) { if (!checker.complete) { nextStatus = checker.title; break; } } if (nextStatus !== this.#currentStatus) { this.#currentStatus = nextStatus; if (this.#listener) { this.#listener(nextStatus); } } } } export class TimelineController implements Tracing.TracingManager.TracingManagerClient { readonly primaryPageTarget: SDK.Target.Target; readonly rootTarget: SDK.Target.Target; private tracingManager: Tracing.TracingManager.TracingManager|null; #collectedEvents: Trace.Types.Events.Event[] = []; #navigationUrls: string[] = []; #fieldData: CrUXManager.PageResult[]|null = null; #recordingStartTime: number|null = null; private readonly client: Client; private tracingCompletePromise: PromiseWithResolvers<void>|null = null; // These properties are only used for "Reload and record". #statusChecker: StatusChecker|null = null; #loadEventFiredCb: (() => void)|null = null; /** * We always need to profile against the DevTools root target, which is * the target that DevTools is attached to. * * In most cases, this will be the tab that DevTools is inspecting. * Now pre-rendering is active, tabs can have multiple pages - only one * of which the user is being shown. This is the "primary page" and hence * why in code we have "primaryPageTarget". When there's a prerendered * page in a background, tab target would have multiple subtargets, one * of them being primaryPageTarget. * * The problems with using primary page target for tracing are: * 1. Performance trace doesn't include information from the other pages on * the tab which is probably not what the user wants as it does not * reflect reality. * 2. Capturing trace never finishes after prerendering activation as * we've started on one target and ending on another one, and * tracingComplete event never gets processed. * * However, when we want to look at the URL of the current page, we need * to use the primaryPageTarget to ensure we get the URL of the tab and * the tab's page that is being shown to the user. This is because the tab * target (which is what rootTarget is) only exposes the Target and Tracing * domains. We need the Page target to navigate as it implements the Page * domain. That is why here we have to store both. **/ constructor(rootTarget: SDK.Target.Target, primaryPageTarget: SDK.Target.Target, client: Client) { this.primaryPageTarget = primaryPageTarget; this.rootTarget = rootTarget; // Ensure the tracing manager is the one for the Root Target, NOT the // primaryPageTarget, as that is the one we have to invoke tracing against. this.tracingManager = rootTarget.model(Tracing.TracingManager.TracingManager); this.client = client; } async dispose(): Promise<void> { if (this.tracingManager) { await this.tracingManager.reset(); } } async #navigateToAboutBlank(): Promise<void> { const aboutBlankNavigationComplete = new Promise<void>(async (resolve, reject) => { const target = this.primaryPageTarget; const resourceModel = target.model(SDK.ResourceTreeModel.ResourceTreeModel); if (!resourceModel) { reject('Could not load resourceModel'); return; } /** * To clear out the page and any state from prior test runs, we * navigate to about:blank before initiating the trace recording. * Once we have navigated to about:blank, we start recording and * then navigate to the original page URL, to ensure we profile the * page load. **/ function waitForAboutBlank(event: Common.EventTarget.EventTargetEvent<SDK.ResourceTreeModel.ResourceTreeFrame>): void { if (event.data.url === 'about:blank') { resolve(); } else { reject(`Unexpected navigation to ${event.data.url}`); } resourceModel?.removeEventListener(SDK.ResourceTreeModel.Events.FrameNavigated, waitForAboutBlank); } resourceModel.addEventListener(SDK.ResourceTreeModel.Events.FrameNavigated, waitForAboutBlank); await resourceModel.navigate('about:blank' as Platform.DevToolsPath.UrlString); }); await aboutBlankNavigationComplete; } async #navigateWithSDK(url: Platform.DevToolsPath.UrlString): Promise<void> { const resourceModel = this.primaryPageTarget.model(SDK.ResourceTreeModel.ResourceTreeModel); if (!resourceModel) { throw new Error('expected to find ResourceTreeModel'); } const loadPromiseWithResolvers = Promise.withResolvers<void>(); this.#loadEventFiredCb = loadPromiseWithResolvers.resolve; SDK.TargetManager.TargetManager.instance().addModelListener( SDK.ResourceTreeModel.ResourceTreeModel, SDK.ResourceTreeModel.Events.Load, this.#onLoadEventFired, this); // We don't need to await this because we are purposefully showing UI // progress as the page loads & tracing is underway. void resourceModel.navigate(url); await loadPromiseWithResolvers.promise; } async startRecording(options: RecordingOptions): Promise<void> { function disabledByDefault(category: string): string { return 'disabled-by-default-' + category; } this.client.recordingStatus(i18nString(UIStrings.initializingTracing)); // If we are doing "Reload & record", we first navigate the page to // about:blank. This is to ensure any data on the timeline from any // previous performance recording is lost, avoiding the problem where a // timeline will show data & screenshots from a previous page load that // was not relevant. if (options.navigateToUrl) { await this.#navigateToAboutBlank(); } // The following categories are also used in other tools, but this panel // offers the possibility of turning them off (see below). // 'disabled-by-default-devtools.screenshot' // └ default: on, option: captureFilmStrip // 'disabled-by-default-devtools.timeline.invalidationTracking' // └ default: off, experiment: timelineInvalidationTracking // 'disabled-by-default-v8.cpu_profiler' // └ default: on, option: enableJSSampling const categoriesArray = [ Root.Runtime.experiments.isEnabled('timeline-show-all-events') ? '*' : '-*', Trace.Types.Events.Categories.Console, Trace.Types.Events.Categories.Loading, Trace.Types.Events.Categories.UserTiming, 'devtools.timeline', disabledByDefault('devtools.target-rundown'), disabledByDefault('devtools.timeline.frame'), disabledByDefault('devtools.timeline.stack'), disabledByDefault('devtools.timeline'), disabledByDefault('devtools.v8-source-rundown-sources'), disabledByDefault('devtools.v8-source-rundown'), disabledByDefault('layout_shift.debug'), // Looking for disabled-by-default-v8.compile? We disabled it: crbug.com/414330508. disabledByDefault('v8.inspector'), disabledByDefault('v8.cpu_profiler.hires'), disabledByDefault('lighthouse'), 'v8.execute', 'v8', 'cppgc', 'navigation,rail', ]; if (Root.Runtime.experiments.isEnabled('timeline-v8-runtime-call-stats') && options.enableJSSampling) { categoriesArray.push(disabledByDefault('v8.runtime_stats_sampling')); } if (options.enableJSSampling) { categoriesArray.push(disabledByDefault('v8.cpu_profiler')); } if (Root.Runtime.experiments.isEnabled('timeline-invalidation-tracking')) { categoriesArray.push(disabledByDefault('devtools.timeline.invalidationTracking')); } if (options.capturePictures) { categoriesArray.push( disabledByDefault('devtools.timeline.layers'), disabledByDefault('devtools.timeline.picture'), disabledByDefault('blink.graphics_context_annotations')); } if (options.captureFilmStrip) { categoriesArray.push(disabledByDefault('devtools.screenshot')); } if (options.captureSelectorStats) { categoriesArray.push(disabledByDefault('blink.debug')); // enable invalidation nodes categoriesArray.push(disabledByDefault('devtools.timeline.invalidationTracking')); } await LiveMetrics.LiveMetrics.instance().disable(); SDK.TargetManager.TargetManager.instance().addModelListener( SDK.ResourceTreeModel.ResourceTreeModel, SDK.ResourceTreeModel.Events.FrameNavigated, this.#onFrameNavigated, this); this.#navigationUrls = []; this.#fieldData = null; this.#recordingStartTime = Date.now(); const response = await this.startRecordingWithCategories(categoriesArray.join(',')); if (response.getError()) { await SDK.TargetManager.TargetManager.instance().resumeAllTargets(); throw new Error(response.getError()); } if (!options.navigateToUrl) { return; } // If the user hit "Reload & record", by this point we have: // 1. Navigated to about:blank // 2. Initiated tracing. // We therefore now should navigate back to the original URL that the user wants to profile. // Setup a status checker so we can wait long enough for the page to settle, // and to let users know what is going on. this.#statusChecker?.removeListener(); this.#statusChecker = new StatusChecker(); const loadEvent = this.#navigateWithSDK(options.navigateToUrl); this.#statusChecker.add(i18nString(UIStrings.waitingForLoadEvent), loadEvent); this.#statusChecker.add( i18nString(UIStrings.waitingForLoadEventPlus5Seconds), loadEvent.then(() => new Promise(resolve => setTimeout(resolve, 5000)))); this.#statusChecker.setListener(status => { if (status === null) { void this.stopRecording(); } else { this.client.recordingStatus(status); } }); } async #onFrameNavigated(event: {data: SDK.ResourceTreeModel.ResourceTreeFrame}): Promise<void> { if (!event.data.isPrimaryFrame()) { return; } this.#navigationUrls.push(event.data.url); } async #onLoadEventFired( event: Common.EventTarget .EventTargetEvent<{resourceTreeModel: SDK.ResourceTreeModel.ResourceTreeModel, loadTime: number}>): Promise<void> { if (!event.data.resourceTreeModel.mainFrame?.isPrimaryFrame()) { return; } this.#loadEventFiredCb?.(); } async stopRecording(): Promise<void> { this.#statusChecker?.removeListener(); this.#statusChecker = null; this.#loadEventFiredCb = null; if (this.tracingManager) { this.tracingManager.stop(); } SDK.TargetManager.TargetManager.instance().removeModelListener( SDK.ResourceTreeModel.ResourceTreeModel, SDK.ResourceTreeModel.Events.FrameNavigated, this.#onFrameNavigated, this); SDK.TargetManager.TargetManager.instance().removeModelListener( SDK.ResourceTreeModel.ResourceTreeModel, SDK.ResourceTreeModel.Events.Load, this.#onLoadEventFired, this); // When throttling is applied to the main renderer, it can slow down the // collection of trace events once tracing has completed. Therefore we // temporarily disable throttling whilst the final trace event collection // takes place. Once it is done, we re-enable it (this is the existing // behaviour within DevTools; the throttling settling is sticky + global). const throttlingManager = SDK.CPUThrottlingManager.CPUThrottlingManager.instance(); const optionDuringRecording = throttlingManager.cpuThrottlingOption(); throttlingManager.setCPUThrottlingOption(SDK.CPUThrottlingManager.NoThrottlingOption); this.client.loadingStarted(); // Give `TimelinePanel.#executeNewTrace` a chance to retain source maps from SDK.SourceMap.SourceMapManager. SDK.SourceMap.SourceMap.retainRawSourceMaps = true; const [fieldData] = await Promise .all([ this.fetchFieldData(), // TODO(crbug.com/366072294): Report the progress of this resumption, as it can be lengthy on heavy pages. SDK.TargetManager.TargetManager.instance().resumeAllTargets(), this.waitForTracingToStop(), ]) .catch(e => { // Normally set false in allSourcesFinished, but just in case something fails, catch it here. SDK.SourceMap.SourceMap.retainRawSourceMaps = false; throw e; }); this.#fieldData = fieldData; // Now we re-enable throttling again to maintain the setting being persistent. throttlingManager.setCPUThrottlingOption(optionDuringRecording); await this.allSourcesFinished(); await LiveMetrics.LiveMetrics.instance().enable(); } private async fetchFieldData(): Promise<CrUXManager.PageResult[]|null> { const cruxManager = CrUXManager.CrUXManager.instance(); if (!cruxManager.isEnabled() || !navigator.onLine) { return null; } const urls = [...new Set(this.#navigationUrls)]; return await Promise.all(urls.map(url => cruxManager.getFieldDataForPage(url))); } private async waitForTracingToStop(): Promise<void> { if (this.tracingManager) { await this.tracingCompletePromise?.promise; } } private async startRecordingWithCategories(categories: string): Promise<Protocol.ProtocolResponseWithError> { if (!this.tracingManager) { throw new Error(i18nString(UIStrings.tracingNotSupported)); } // There might be a significant delay in the beginning of timeline recording // caused by starting CPU profiler, that needs to traverse JS heap to collect // all the functions data. await SDK.TargetManager.TargetManager.instance().suspendAllTargets('performance-timeline'); this.tracingCompletePromise = Promise.withResolvers(); const response = await this.tracingManager.start(this, categories); await this.warmupJsProfiler(); PanelCommon.ExtensionServer.ExtensionServer.instance().profilingStarted(); return response; } // CPUProfiler::StartProfiling has a non-trivial cost and we'd prefer it not happen within an // interaction as that complicates debugging interaction latency. // To trigger the StartProfiling interrupt and get the warmup cost out of the way, we send a // very soft invocation to V8.https://crbug.com/1358602 async warmupJsProfiler(): Promise<void> { // primaryPageTarget has RuntimeModel whereas rootTarget (Tab) does not. const runtimeModel = this.primaryPageTarget.model(SDK.RuntimeModel.RuntimeModel); if (!runtimeModel) { return; } await runtimeModel.agent.invoke_evaluate({ expression: '(async function(){ await 1; })()', throwOnSideEffect: true, }); } traceEventsCollected(events: Trace.Types.Events.Event[]): void { this.#collectedEvents.push(...events); } tracingComplete(): void { if (!this.tracingCompletePromise) { return; } this.tracingCompletePromise.resolve(undefined); this.tracingCompletePromise = null; } private async allSourcesFinished(): Promise<void> { PanelCommon.ExtensionServer.ExtensionServer.instance().profilingStopped(); this.client.processingStarted(); const metadata = await RecordingMetadata.forTrace({ recordingStartTime: this.#recordingStartTime ?? undefined, cruxFieldData: this.#fieldData ?? undefined, }); await this.client.loadingComplete(this.#collectedEvents, /* exclusiveFilter= */ null, metadata); this.client.loadingCompleteForTest(); SDK.SourceMap.SourceMap.retainRawSourceMaps = false; } tracingBufferUsage(usage: number): void { this.client.recordingProgress(usage); } eventsRetrievalProgress(progress: number): void { this.client.loadingProgress(progress); } } export interface Client { recordingProgress(bufferUsage: number): void; recordingStatus(status: string): void; loadingStarted(): void; processingStarted(): void; loadingProgress(progress?: number): void; loadingComplete( collectedEvents: Trace.Types.Events.Event[], exclusiveFilter: Trace.Extras.TraceFilter.TraceFilter|null, metadata: Trace.Types.File.MetaData|null): Promise<void>; loadingCompleteForTest(): void; } export interface RecordingOptions { enableJSSampling?: boolean; capturePictures?: boolean; captureFilmStrip?: boolean; captureSelectorStats?: boolean; navigateToUrl?: Platform.DevToolsPath.UrlString; }