// Copyright (C) 2019 The Android Open Source Project // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. import {Protocol} from 'devtools-protocol'; import {ProtocolProxyApi} from 'devtools-protocol/types/protocol-proxy-api'; import {Client} from 'noice-json-rpc'; import {base64Encode} from '../base/string_utils'; import { ConsumerPortResponse, GetTraceStatsResponse, ReadBuffersResponse, } from './consumer_port_types'; import {RpcConsumerPort} from './record_controller_interfaces'; import { browserSupportsPerfettoConfig, extractTraceConfig, hasSystemDataSourceConfig, } from './trace_config_utils'; import protos from '../protos'; import {DevToolsSocket} from './devtools_socket'; import {exists} from '../base/utils'; const CHUNK_SIZE: number = 1024 * 1024 * 16; // 16Mb export class ChromeTracingController extends RpcConsumerPort { private streamHandle: string | undefined = undefined; private uiPort: chrome.runtime.Port; private api: ProtocolProxyApi.ProtocolApi; private devtoolsSocket: DevToolsSocket; private lastBufferUsageEvent: Protocol.Tracing.BufferUsageEvent | undefined; private tracingSessionOngoing = false; private tracingSessionId = 0; constructor(port: chrome.runtime.Port) { super({ onConsumerPortResponse: (message: ConsumerPortResponse) => this.uiPort.postMessage(message), onError: (error: string) => this.uiPort.postMessage({type: 'ChromeExtensionError', error}), onStatus: (status) => this.uiPort.postMessage({type: 'ChromeExtensionStatus', status}), }); this.uiPort = port; this.devtoolsSocket = new DevToolsSocket(); this.devtoolsSocket.on('close', () => this.resetState()); const rpcClient = new Client(this.devtoolsSocket); this.api = rpcClient.api(); this.api.Tracing.on('tracingComplete', this.onTracingComplete.bind(this)); this.api.Tracing.on('bufferUsage', this.onBufferUsage.bind(this)); this.uiPort.onDisconnect.addListener(() => { this.devtoolsSocket.detach(); }); } handleCommand(methodName: string, requestData: Uint8Array) { switch (methodName) { case 'EnableTracing': this.enableTracing(requestData); break; case 'FreeBuffers': this.freeBuffers(); break; case 'ReadBuffers': this.readBuffers(); break; case 'DisableTracing': this.disableTracing(); break; case 'GetTraceStats': this.getTraceStats(); break; case 'GetCategories': this.getCategories(); break; default: this.sendErrorMessage('Action not recognized'); console.log('Received not recognized message: ', methodName); break; } } enableTracing(enableTracingRequest: Uint8Array) { this.resetState(); const traceConfigProto = extractTraceConfig(enableTracingRequest); if (!traceConfigProto) { this.sendErrorMessage('Invalid trace config'); return; } this.handleStartTracing(traceConfigProto); } toCamelCase(key: string, separator: string): string { return key .split(separator) .map((part, index) => { return index === 0 ? part : part[0].toUpperCase() + part.slice(1); }) .join(''); } // eslint-disable-next-line @typescript-eslint/no-explicit-any convertDictKeys(obj: any): any { if (Array.isArray(obj)) { return obj.map((v) => this.convertDictKeys(v)); } if (typeof obj === 'object' && obj !== null) { // eslint-disable-next-line @typescript-eslint/no-explicit-any const converted: any = {}; for (const key of Object.keys(obj)) { converted[this.toCamelCase(key, '_')] = this.convertDictKeys(obj[key]); } return converted; } return obj; } convertToDevToolsConfig(config: unknown): Protocol.Tracing.TraceConfig { // DevTools uses a different naming style for config properties: Dictionary // keys are named "camelCase" style, rather than "underscore_case" style as // in the TraceConfig. const convertedConfig = this.convertDictKeys(config); // recordMode is specified as an enum with camelCase values. if (convertedConfig.recordMode as string) { convertedConfig.recordMode = this.toCamelCase( convertedConfig.recordMode as string, '-', ); } return convertedConfig as Protocol.Tracing.TraceConfig; } // TODO(nicomazz): write unit test for this extractChromeConfig( perfettoConfig: protos.TraceConfig, ): Protocol.Tracing.TraceConfig { for (const ds of perfettoConfig.dataSources) { if ( ds.config && ds.config.name === 'org.chromium.trace_event' && exists(ds.config.chromeConfig) && exists(ds.config.chromeConfig.traceConfig) ) { const chromeConfigJsonString = ds.config.chromeConfig.traceConfig; const config = JSON.parse(chromeConfigJsonString); return this.convertToDevToolsConfig(config); } } return {}; } freeBuffers() { this.devtoolsSocket.detach(); this.sendMessage({type: 'FreeBuffersResponse'}); } async readBuffers(offset = 0) { if (!this.devtoolsSocket.isAttached() || this.streamHandle === undefined) { this.sendErrorMessage('No tracing session to read from'); return; } const res = await this.api.IO.read({ handle: this.streamHandle, offset, size: CHUNK_SIZE, }); if (res === undefined) return; const chunk = res.base64Encoded ? atob(res.data) : res.data; // The 'as {} as UInt8Array' is done because we can't send ArrayBuffers // trough a chrome.runtime.Port. The conversion from string to ArrayBuffer // takes place on the other side of the port. const response: ReadBuffersResponse = { type: 'ReadBuffersResponse', slices: [{data: chunk as {} as Uint8Array, lastSliceForPacket: res.eof}], }; this.sendMessage(response); if (res.eof) return; this.readBuffers(offset + chunk.length); } async disableTracing() { await this.endTracing(this.tracingSessionId); this.sendMessage({type: 'DisableTracingResponse'}); } async endTracing(tracingSessionId: number) { if (tracingSessionId !== this.tracingSessionId) { return; } if (this.tracingSessionOngoing) { await this.api.Tracing.end(); } this.tracingSessionOngoing = false; } getTraceStats() { // If the statistics are not available yet, it is 0. const percentFull = this.lastBufferUsageEvent?.percentFull ?? 0; const stats: protos.ITraceStats = { bufferStats: [ {bufferSize: 1000, bytesWritten: Math.round(percentFull * 1000)}, ], }; const response: GetTraceStatsResponse = { type: 'GetTraceStatsResponse', traceStats: stats, }; this.sendMessage(response); } getCategories() { const fetchCategories = async () => { const categories = (await this.api.Tracing.getCategories()).categories; this.uiPort.postMessage({type: 'GetCategoriesResponse', categories}); }; // If a target is already attached, we simply fetch the categories. if (this.devtoolsSocket.isAttached()) { fetchCategories(); return; } // Otherwise, we attach temporarily. this.devtoolsSocket.attachToBrowser(async (error?: string) => { if (error) { this.sendErrorMessage( `Could not attach to DevTools browser target ` + `(req. Chrome >= M81): ${error}`, ); return; } fetchCategories(); this.devtoolsSocket.detach(); }); } resetState() { this.devtoolsSocket.detach(); this.streamHandle = undefined; } onTracingComplete(params: Protocol.Tracing.TracingCompleteEvent) { this.streamHandle = params.stream; this.sendMessage({type: 'EnableTracingResponse'}); } onBufferUsage(params: Protocol.Tracing.BufferUsageEvent) { this.lastBufferUsageEvent = params; } handleStartTracing(traceConfigProto: Uint8Array) { this.devtoolsSocket.attachToBrowser(async (error?: string) => { if (error) { this.sendErrorMessage( `Could not attach to DevTools browser target ` + `(req. Chrome >= M81): ${error}`, ); return; } const requestParams: Protocol.Tracing.StartRequest = { streamFormat: 'proto', transferMode: 'ReturnAsStream', streamCompression: 'gzip', bufferUsageReportingInterval: 200, }; const traceConfig = protos.TraceConfig.decode(traceConfigProto); if (browserSupportsPerfettoConfig()) { const configEncoded = base64Encode(traceConfigProto); await this.api.Tracing.start({ perfettoConfig: configEncoded, ...requestParams, }); this.tracingSessionOngoing = true; const tracingSessionId = ++this.tracingSessionId; setTimeout( () => this.endTracing(tracingSessionId), traceConfig.durationMs, ); } else { console.log( 'Used Chrome version is too old to support ' + 'perfettoConfig parameter. Using chrome config only instead.', ); if (hasSystemDataSourceConfig(traceConfig)) { this.sendErrorMessage( 'System tracing is not supported by this Chrome version. Choose' + " the 'Chrome' target instead to record a Chrome-only trace.", ); return; } const chromeConfig = this.extractChromeConfig(traceConfig); await this.api.Tracing.start({ traceConfig: chromeConfig, ...requestParams, }); } }); } }