1// Copyright (C) 2019 The Android Open Source Project 2// 3// Licensed under the Apache License, Version 2.0 (the "License"); 4// you may not use this file except in compliance with the License. 5// You may obtain a copy of the License at 6// 7// http://www.apache.org/licenses/LICENSE-2.0 8// 9// Unless required by applicable law or agreed to in writing, software 10// distributed under the License is distributed on an "AS IS" BASIS, 11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12// See the License for the specific language governing permissions and 13// limitations under the License. 14 15import {Protocol} from 'devtools-protocol'; 16import {ProtocolProxyApi} from 'devtools-protocol/types/protocol-proxy-api'; 17import {Client} from 'noice-json-rpc'; 18 19import {base64Encode} from '../base/string_utils'; 20import { 21 ConsumerPortResponse, 22 GetTraceStatsResponse, 23 ReadBuffersResponse, 24} from '../plugins/dev.perfetto.RecordTrace/consumer_port_types'; 25import {RpcConsumerPort} from '../plugins/dev.perfetto.RecordTrace/record_controller_interfaces'; 26import { 27 browserSupportsPerfettoConfig, 28 extractTraceConfig, 29 hasSystemDataSourceConfig, 30} from '../plugins/dev.perfetto.RecordTrace/trace_config_utils'; 31import protos from '../protos'; 32 33import {DevToolsSocket} from './devtools_socket'; 34import {exists} from '../base/utils'; 35 36const CHUNK_SIZE: number = 1024 * 1024 * 16; // 16Mb 37 38export class ChromeTracingController extends RpcConsumerPort { 39 private streamHandle: string | undefined = undefined; 40 private uiPort: chrome.runtime.Port; 41 private api: ProtocolProxyApi.ProtocolApi; 42 private devtoolsSocket: DevToolsSocket; 43 private lastBufferUsageEvent: Protocol.Tracing.BufferUsageEvent | undefined; 44 private tracingSessionOngoing = false; 45 private tracingSessionId = 0; 46 47 constructor(port: chrome.runtime.Port) { 48 super({ 49 onConsumerPortResponse: (message: ConsumerPortResponse) => 50 this.uiPort.postMessage(message), 51 52 onError: (error: string) => 53 this.uiPort.postMessage({type: 'ChromeExtensionError', error}), 54 55 onStatus: (status) => 56 this.uiPort.postMessage({type: 'ChromeExtensionStatus', status}), 57 }); 58 this.uiPort = port; 59 this.devtoolsSocket = new DevToolsSocket(); 60 this.devtoolsSocket.on('close', () => this.resetState()); 61 const rpcClient = new Client(this.devtoolsSocket); 62 this.api = rpcClient.api(); 63 this.api.Tracing.on('tracingComplete', this.onTracingComplete.bind(this)); 64 this.api.Tracing.on('bufferUsage', this.onBufferUsage.bind(this)); 65 this.uiPort.onDisconnect.addListener(() => { 66 this.devtoolsSocket.detach(); 67 }); 68 } 69 70 handleCommand(methodName: string, requestData: Uint8Array) { 71 switch (methodName) { 72 case 'EnableTracing': 73 this.enableTracing(requestData); 74 break; 75 case 'FreeBuffers': 76 this.freeBuffers(); 77 break; 78 case 'ReadBuffers': 79 this.readBuffers(); 80 break; 81 case 'DisableTracing': 82 this.disableTracing(); 83 break; 84 case 'GetTraceStats': 85 this.getTraceStats(); 86 break; 87 case 'GetCategories': 88 this.getCategories(); 89 break; 90 default: 91 this.sendErrorMessage('Action not recognized'); 92 console.log('Received not recognized message: ', methodName); 93 break; 94 } 95 } 96 97 enableTracing(enableTracingRequest: Uint8Array) { 98 this.resetState(); 99 const traceConfigProto = extractTraceConfig(enableTracingRequest); 100 if (!traceConfigProto) { 101 this.sendErrorMessage('Invalid trace config'); 102 return; 103 } 104 105 this.handleStartTracing(traceConfigProto); 106 } 107 108 toCamelCase(key: string, separator: string): string { 109 return key 110 .split(separator) 111 .map((part, index) => { 112 return index === 0 ? part : part[0].toUpperCase() + part.slice(1); 113 }) 114 .join(''); 115 } 116 117 // eslint-disable-next-line @typescript-eslint/no-explicit-any 118 convertDictKeys(obj: any): any { 119 if (Array.isArray(obj)) { 120 return obj.map((v) => this.convertDictKeys(v)); 121 } 122 if (typeof obj === 'object' && obj !== null) { 123 // eslint-disable-next-line @typescript-eslint/no-explicit-any 124 const converted: any = {}; 125 for (const key of Object.keys(obj)) { 126 converted[this.toCamelCase(key, '_')] = this.convertDictKeys(obj[key]); 127 } 128 return converted; 129 } 130 return obj; 131 } 132 133 convertToDevToolsConfig(config: unknown): Protocol.Tracing.TraceConfig { 134 // DevTools uses a different naming style for config properties: Dictionary 135 // keys are named "camelCase" style, rather than "underscore_case" style as 136 // in the TraceConfig. 137 const convertedConfig = this.convertDictKeys(config); 138 // recordMode is specified as an enum with camelCase values. 139 if (convertedConfig.recordMode as string) { 140 convertedConfig.recordMode = this.toCamelCase( 141 convertedConfig.recordMode as string, 142 '-', 143 ); 144 } 145 return convertedConfig as Protocol.Tracing.TraceConfig; 146 } 147 148 // TODO(nicomazz): write unit test for this 149 extractChromeConfig( 150 perfettoConfig: protos.TraceConfig, 151 ): Protocol.Tracing.TraceConfig { 152 for (const ds of perfettoConfig.dataSources) { 153 if ( 154 ds.config && 155 ds.config.name === 'org.chromium.trace_event' && 156 exists(ds.config.chromeConfig) && 157 exists(ds.config.chromeConfig.traceConfig) 158 ) { 159 const chromeConfigJsonString = ds.config.chromeConfig.traceConfig; 160 const config = JSON.parse(chromeConfigJsonString); 161 return this.convertToDevToolsConfig(config); 162 } 163 } 164 return {}; 165 } 166 167 freeBuffers() { 168 this.devtoolsSocket.detach(); 169 this.sendMessage({type: 'FreeBuffersResponse'}); 170 } 171 172 async readBuffers(offset = 0) { 173 if (!this.devtoolsSocket.isAttached() || this.streamHandle === undefined) { 174 this.sendErrorMessage('No tracing session to read from'); 175 return; 176 } 177 178 const res = await this.api.IO.read({ 179 handle: this.streamHandle, 180 offset, 181 size: CHUNK_SIZE, 182 }); 183 if (res === undefined) return; 184 185 const chunk = res.base64Encoded ? atob(res.data) : res.data; 186 // The 'as {} as UInt8Array' is done because we can't send ArrayBuffers 187 // trough a chrome.runtime.Port. The conversion from string to ArrayBuffer 188 // takes place on the other side of the port. 189 const response: ReadBuffersResponse = { 190 type: 'ReadBuffersResponse', 191 slices: [{data: chunk as {} as Uint8Array, lastSliceForPacket: res.eof}], 192 }; 193 this.sendMessage(response); 194 if (res.eof) return; 195 this.readBuffers(offset + chunk.length); 196 } 197 198 async disableTracing() { 199 await this.endTracing(this.tracingSessionId); 200 this.sendMessage({type: 'DisableTracingResponse'}); 201 } 202 203 async endTracing(tracingSessionId: number) { 204 if (tracingSessionId !== this.tracingSessionId) { 205 return; 206 } 207 if (this.tracingSessionOngoing) { 208 await this.api.Tracing.end(); 209 } 210 this.tracingSessionOngoing = false; 211 } 212 213 getTraceStats() { 214 // If the statistics are not available yet, it is 0. 215 const percentFull = this.lastBufferUsageEvent?.percentFull ?? 0; 216 const stats: protos.ITraceStats = { 217 bufferStats: [ 218 {bufferSize: 1000, bytesWritten: Math.round(percentFull * 1000)}, 219 ], 220 }; 221 const response: GetTraceStatsResponse = { 222 type: 'GetTraceStatsResponse', 223 traceStats: stats, 224 }; 225 this.sendMessage(response); 226 } 227 228 getCategories() { 229 const fetchCategories = async () => { 230 const categories = (await this.api.Tracing.getCategories()).categories; 231 this.uiPort.postMessage({type: 'GetCategoriesResponse', categories}); 232 }; 233 // If a target is already attached, we simply fetch the categories. 234 if (this.devtoolsSocket.isAttached()) { 235 fetchCategories(); 236 return; 237 } 238 // Otherwise, we attach temporarily. 239 this.devtoolsSocket.attachToBrowser(async (error?: string) => { 240 if (error) { 241 this.sendErrorMessage( 242 `Could not attach to DevTools browser target ` + 243 `(req. Chrome >= M81): ${error}`, 244 ); 245 return; 246 } 247 fetchCategories(); 248 this.devtoolsSocket.detach(); 249 }); 250 } 251 252 resetState() { 253 this.devtoolsSocket.detach(); 254 this.streamHandle = undefined; 255 } 256 257 onTracingComplete(params: Protocol.Tracing.TracingCompleteEvent) { 258 this.streamHandle = params.stream; 259 this.sendMessage({type: 'EnableTracingResponse'}); 260 } 261 262 onBufferUsage(params: Protocol.Tracing.BufferUsageEvent) { 263 this.lastBufferUsageEvent = params; 264 } 265 266 handleStartTracing(traceConfigProto: Uint8Array) { 267 this.devtoolsSocket.attachToBrowser(async (error?: string) => { 268 if (error) { 269 this.sendErrorMessage( 270 `Could not attach to DevTools browser target ` + 271 `(req. Chrome >= M81): ${error}`, 272 ); 273 return; 274 } 275 276 const requestParams: Protocol.Tracing.StartRequest = { 277 streamFormat: 'proto', 278 transferMode: 'ReturnAsStream', 279 streamCompression: 'gzip', 280 bufferUsageReportingInterval: 200, 281 }; 282 283 const traceConfig = protos.TraceConfig.decode(traceConfigProto); 284 if (browserSupportsPerfettoConfig()) { 285 const configEncoded = base64Encode(traceConfigProto); 286 await this.api.Tracing.start({ 287 perfettoConfig: configEncoded, 288 ...requestParams, 289 }); 290 this.tracingSessionOngoing = true; 291 const tracingSessionId = ++this.tracingSessionId; 292 setTimeout( 293 () => this.endTracing(tracingSessionId), 294 traceConfig.durationMs, 295 ); 296 } else { 297 console.log( 298 'Used Chrome version is too old to support ' + 299 'perfettoConfig parameter. Using chrome config only instead.', 300 ); 301 302 if (hasSystemDataSourceConfig(traceConfig)) { 303 this.sendErrorMessage( 304 'System tracing is not supported by this Chrome version. Choose' + 305 " the 'Chrome' target instead to record a Chrome-only trace.", 306 ); 307 return; 308 } 309 310 const chromeConfig = this.extractChromeConfig(traceConfig); 311 await this.api.Tracing.start({ 312 traceConfig: chromeConfig, 313 ...requestParams, 314 }); 315 } 316 }); 317 } 318} 319