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