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 * as rpc from 'noice-json-rpc'; 18 19import {base64Encode} from '../base/string_utils'; 20import { 21 browserSupportsPerfettoConfig, 22 extractTraceConfig, 23 hasSystemDataSourceConfig 24} from '../base/trace_config_utils'; 25import {TraceConfig} from '../common/protos'; 26import { 27 ConsumerPortResponse, 28 GetTraceStatsResponse, 29 ReadBuffersResponse 30} from '../controller/consumer_port_types'; 31import {RpcConsumerPort} from '../controller/record_controller_interfaces'; 32import {perfetto} from '../gen/protos'; 33 34import {DevToolsSocket} from './devtools_socket'; 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 rpc.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.split(separator) 110 .map((part, index) => { 111 return (index === 0) ? part : part[0].toUpperCase() + part.slice(1); 112 }) 113 .join(''); 114 } 115 116 // tslint:disable-next-line: no-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 // tslint:disable-next-line: no-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 // tslint:disable-next-line: no-any 133 convertToDevToolsConfig(config: any): 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 config = this.convertDictKeys(config); 138 // recordMode is specified as an enum with camelCase values. 139 if (config.recordMode) { 140 config.recordMode = this.toCamelCase(config.recordMode as string, '-'); 141 } 142 return config as Protocol.Tracing.TraceConfig; 143 } 144 145 // TODO(nicomazz): write unit test for this 146 extractChromeConfig(perfettoConfig: TraceConfig): 147 Protocol.Tracing.TraceConfig { 148 for (const ds of perfettoConfig.dataSources) { 149 if (ds.config && ds.config.name === 'org.chromium.trace_event' && 150 ds.config.chromeConfig && ds.config.chromeConfig.traceConfig) { 151 const chromeConfigJsonString = ds.config.chromeConfig.traceConfig; 152 const config = JSON.parse(chromeConfigJsonString); 153 return this.convertToDevToolsConfig(config); 154 } 155 } 156 return {}; 157 } 158 159 freeBuffers() { 160 this.devtoolsSocket.detach(); 161 this.sendMessage({type: 'FreeBuffersResponse'}); 162 } 163 164 async readBuffers(offset = 0) { 165 if (!this.devtoolsSocket.isAttached() || this.streamHandle === undefined) { 166 this.sendErrorMessage('No tracing session to read from'); 167 return; 168 } 169 170 const res = await this.api.IO.read( 171 {handle: this.streamHandle, offset, size: CHUNK_SIZE}); 172 if (res === undefined) return; 173 174 const chunk = res.base64Encoded ? atob(res.data) : res.data; 175 // The 'as {} as UInt8Array' is done because we can't send ArrayBuffers 176 // trough a chrome.runtime.Port. The conversion from string to ArrayBuffer 177 // takes place on the other side of the port. 178 const response: ReadBuffersResponse = { 179 type: 'ReadBuffersResponse', 180 slices: [{data: chunk as {} as Uint8Array, lastSliceForPacket: res.eof}] 181 }; 182 this.sendMessage(response); 183 if (res.eof) return; 184 this.readBuffers(offset + res.data.length); 185 } 186 187 async disableTracing() { 188 await this.endTracing(this.tracingSessionId); 189 this.sendMessage({type: 'DisableTracingResponse'}); 190 } 191 192 async endTracing(tracingSessionId: number) { 193 if (tracingSessionId !== this.tracingSessionId) { 194 return; 195 } 196 if (this.tracingSessionOngoing) { 197 await this.api.Tracing.end(); 198 } 199 this.tracingSessionOngoing = false; 200 } 201 202 getTraceStats() { 203 let percentFull = 0; // If the statistics are not available yet, it is 0. 204 if (this.lastBufferUsageEvent && this.lastBufferUsageEvent.percentFull) { 205 percentFull = this.lastBufferUsageEvent.percentFull; 206 } 207 const stats: perfetto.protos.ITraceStats = { 208 bufferStats: 209 [{bufferSize: 1000, bytesWritten: Math.round(percentFull * 1000)}] 210 }; 211 const response: GetTraceStatsResponse = { 212 type: 'GetTraceStatsResponse', 213 traceStats: stats 214 }; 215 this.sendMessage(response); 216 } 217 218 getCategories() { 219 const fetchCategories = async () => { 220 const categories = (await this.api.Tracing.getCategories()).categories; 221 this.uiPort.postMessage({type: 'GetCategoriesResponse', categories}); 222 }; 223 // If a target is already attached, we simply fetch the categories. 224 if (this.devtoolsSocket.isAttached()) { 225 fetchCategories(); 226 return; 227 } 228 // Otherwise, we attach temporarily. 229 this.devtoolsSocket.attachToBrowser(async (error?: string) => { 230 if (error) { 231 this.sendErrorMessage( 232 `Could not attach to DevTools browser target ` + 233 `(req. Chrome >= M81): ${error}`); 234 return; 235 } 236 fetchCategories(); 237 this.devtoolsSocket.detach(); 238 }); 239 } 240 241 resetState() { 242 this.devtoolsSocket.detach(); 243 this.streamHandle = undefined; 244 } 245 246 onTracingComplete(params: Protocol.Tracing.TracingCompleteEvent) { 247 this.streamHandle = params.stream; 248 this.sendMessage({type: 'EnableTracingResponse'}); 249 } 250 251 onBufferUsage(params: Protocol.Tracing.BufferUsageEvent) { 252 this.lastBufferUsageEvent = params; 253 } 254 255 handleStartTracing(traceConfigProto: Uint8Array) { 256 this.devtoolsSocket.attachToBrowser(async (error?: string) => { 257 if (error) { 258 this.sendErrorMessage( 259 `Could not attach to DevTools browser target ` + 260 `(req. Chrome >= M81): ${error}`); 261 return; 262 } 263 264 const requestParams: Protocol.Tracing.StartRequest = { 265 streamFormat: 'proto', 266 transferMode: 'ReturnAsStream', 267 streamCompression: 'gzip', 268 bufferUsageReportingInterval: 200 269 }; 270 271 const traceConfig = TraceConfig.decode(traceConfigProto); 272 if (browserSupportsPerfettoConfig()) { 273 const configEncoded = base64Encode(traceConfigProto); 274 await this.api.Tracing.start( 275 {perfettoConfig: configEncoded, ...requestParams}); 276 this.tracingSessionOngoing = true; 277 const tracingSessionId = ++this.tracingSessionId; 278 setTimeout( 279 () => this.endTracing(tracingSessionId), traceConfig.durationMs); 280 } else { 281 console.log( 282 'Used Chrome version is too old to support ' + 283 'perfettoConfig parameter. Using chrome config only instead.'); 284 285 if (hasSystemDataSourceConfig(traceConfig)) { 286 this.sendErrorMessage( 287 'System tracing is not supported by this Chrome version. Choose' + 288 ' the \'Chrome\' target instead to record a Chrome-only trace.'); 289 return; 290 } 291 292 const chromeConfig = this.extractChromeConfig(traceConfig); 293 await this.api.Tracing.start( 294 {traceConfig: chromeConfig, ...requestParams}); 295 } 296 }); 297 } 298} 299