UNPKG

chrome-devtools-frontend

Version:
308 lines (274 loc) 13.3 kB
// Copyright 2016 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import * as i18n from '../../core/i18n/i18n.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 EmulationModel from '../../models/emulation/emulation.js'; import * as Extensions from '../../models/extensions/extensions.js'; import * as LiveMetrics from '../../models/live-metrics/live-metrics.js'; import * as Trace from '../../models/trace/trace.js'; const UIStrings = { /** *@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', } as const; const str_ = i18n.i18n.registerUIStrings('panels/timeline/TimelineController.ts', UIStrings); const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); export class TimelineController implements Trace.TracingManager.TracingManagerClient { readonly primaryPageTarget: SDK.Target.Target; readonly rootTarget: SDK.Target.Target; private tracingManager: Trace.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; /** * 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(Trace.TracingManager.TracingManager); this.client = client; } async dispose(): Promise<void> { if (this.tracingManager) { await this.tracingManager.reset(); } } async startRecording(options: RecordingOptions): Promise<Protocol.ProtocolResponseWithError> { function disabledByDefault(category: string): string { return 'disabled-by-default-' + category; } // 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'), // 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')); } 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(); } return response; } async #onFrameNavigated(event: {data: SDK.ResourceTreeModel.ResourceTreeFrame}): Promise<void> { if (!event.data.isPrimaryFrame()) { return; } this.#navigationUrls.push(event.data.url); } async stopRecording(): Promise<void> { if (this.tracingManager) { this.tracingManager.stop(); } SDK.TargetManager.TargetManager.instance().removeModelListener( SDK.ResourceTreeModel.ResourceTreeModel, SDK.ResourceTreeModel.Events.FrameNavigated, this.#onFrameNavigated, 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 createMetadata(): Promise<Trace.Types.File.MetaData> { const deviceModeModel = EmulationModel.DeviceModeModel.DeviceModeModel.tryInstance(); let emulatedDeviceTitle; if (deviceModeModel?.type() === EmulationModel.DeviceModeModel.Type.Device) { emulatedDeviceTitle = deviceModeModel.device()?.title ?? undefined; } else if (deviceModeModel?.type() === EmulationModel.DeviceModeModel.Type.Responsive) { emulatedDeviceTitle = 'Responsive'; } return await Trace.Extras.Metadata.forNewRecording( false, this.#recordingStartTime ?? undefined, emulatedDeviceTitle, this.#fieldData ?? undefined); } 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(); Extensions.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> { Extensions.ExtensionServer.ExtensionServer.instance().profilingStopped(); this.client.processingStarted(); const metadata = await this.createMetadata(); 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(usage: number): 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; }