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 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 convertDictKeys(obj: any): any { 117 if (Array.isArray(obj)) { 118 return obj.map((v) => this.convertDictKeys(v)); 119 } 120 if (typeof obj === 'object' && obj !== null) { 121 const converted: any = {}; 122 for (const key of Object.keys(obj)) { 123 converted[this.toCamelCase(key, '_')] = this.convertDictKeys(obj[key]); 124 } 125 return converted; 126 } 127 return obj; 128 } 129 130 convertToDevToolsConfig(config: any): Protocol.Tracing.TraceConfig { 131 // DevTools uses a different naming style for config properties: Dictionary 132 // keys are named "camelCase" style, rather than "underscore_case" style as 133 // in the TraceConfig. 134 config = this.convertDictKeys(config); 135 // recordMode is specified as an enum with camelCase values. 136 if (config.recordMode) { 137 config.recordMode = this.toCamelCase(config.recordMode as string, '-'); 138 } 139 return config as Protocol.Tracing.TraceConfig; 140 } 141 142 // TODO(nicomazz): write unit test for this 143 extractChromeConfig(perfettoConfig: TraceConfig): 144 Protocol.Tracing.TraceConfig { 145 for (const ds of perfettoConfig.dataSources) { 146 if (ds.config && ds.config.name === 'org.chromium.trace_event' && 147 ds.config.chromeConfig && ds.config.chromeConfig.traceConfig) { 148 const chromeConfigJsonString = ds.config.chromeConfig.traceConfig; 149 const config = JSON.parse(chromeConfigJsonString); 150 return this.convertToDevToolsConfig(config); 151 } 152 } 153 return {}; 154 } 155 156 freeBuffers() { 157 this.devtoolsSocket.detach(); 158 this.sendMessage({type: 'FreeBuffersResponse'}); 159 } 160 161 async readBuffers(offset = 0) { 162 if (!this.devtoolsSocket.isAttached() || this.streamHandle === undefined) { 163 this.sendErrorMessage('No tracing session to read from'); 164 return; 165 } 166 167 const res = await this.api.IO.read( 168 {handle: this.streamHandle, offset, size: CHUNK_SIZE}); 169 if (res === undefined) return; 170 171 const chunk = res.base64Encoded ? atob(res.data) : res.data; 172 // The 'as {} as UInt8Array' is done because we can't send ArrayBuffers 173 // trough a chrome.runtime.Port. The conversion from string to ArrayBuffer 174 // takes place on the other side of the port. 175 const response: ReadBuffersResponse = { 176 type: 'ReadBuffersResponse', 177 slices: [{data: chunk as {} as Uint8Array, lastSliceForPacket: res.eof}], 178 }; 179 this.sendMessage(response); 180 if (res.eof) return; 181 this.readBuffers(offset + res.data.length); 182 } 183 184 async disableTracing() { 185 await this.endTracing(this.tracingSessionId); 186 this.sendMessage({type: 'DisableTracingResponse'}); 187 } 188 189 async endTracing(tracingSessionId: number) { 190 if (tracingSessionId !== this.tracingSessionId) { 191 return; 192 } 193 if (this.tracingSessionOngoing) { 194 await this.api.Tracing.end(); 195 } 196 this.tracingSessionOngoing = false; 197 } 198 199 getTraceStats() { 200 let percentFull = 0; // If the statistics are not available yet, it is 0. 201 if (this.lastBufferUsageEvent && this.lastBufferUsageEvent.percentFull) { 202 percentFull = this.lastBufferUsageEvent.percentFull; 203 } 204 const stats: perfetto.protos.ITraceStats = { 205 bufferStats: 206 [{bufferSize: 1000, bytesWritten: Math.round(percentFull * 1000)}], 207 }; 208 const response: GetTraceStatsResponse = { 209 type: 'GetTraceStatsResponse', 210 traceStats: stats, 211 }; 212 this.sendMessage(response); 213 } 214 215 getCategories() { 216 const fetchCategories = async () => { 217 const categories = (await this.api.Tracing.getCategories()).categories; 218 this.uiPort.postMessage({type: 'GetCategoriesResponse', categories}); 219 }; 220 // If a target is already attached, we simply fetch the categories. 221 if (this.devtoolsSocket.isAttached()) { 222 fetchCategories(); 223 return; 224 } 225 // Otherwise, we attach temporarily. 226 this.devtoolsSocket.attachToBrowser(async (error?: string) => { 227 if (error) { 228 this.sendErrorMessage( 229 `Could not attach to DevTools browser target ` + 230 `(req. Chrome >= M81): ${error}`); 231 return; 232 } 233 fetchCategories(); 234 this.devtoolsSocket.detach(); 235 }); 236 } 237 238 resetState() { 239 this.devtoolsSocket.detach(); 240 this.streamHandle = undefined; 241 } 242 243 onTracingComplete(params: Protocol.Tracing.TracingCompleteEvent) { 244 this.streamHandle = params.stream; 245 this.sendMessage({type: 'EnableTracingResponse'}); 246 } 247 248 onBufferUsage(params: Protocol.Tracing.BufferUsageEvent) { 249 this.lastBufferUsageEvent = params; 250 } 251 252 handleStartTracing(traceConfigProto: Uint8Array) { 253 this.devtoolsSocket.attachToBrowser(async (error?: string) => { 254 if (error) { 255 this.sendErrorMessage( 256 `Could not attach to DevTools browser target ` + 257 `(req. Chrome >= M81): ${error}`); 258 return; 259 } 260 261 const requestParams: Protocol.Tracing.StartRequest = { 262 streamFormat: 'proto', 263 transferMode: 'ReturnAsStream', 264 streamCompression: 'gzip', 265 bufferUsageReportingInterval: 200, 266 }; 267 268 const traceConfig = TraceConfig.decode(traceConfigProto); 269 if (browserSupportsPerfettoConfig()) { 270 const configEncoded = base64Encode(traceConfigProto); 271 await this.api.Tracing.start( 272 {perfettoConfig: configEncoded, ...requestParams}); 273 this.tracingSessionOngoing = true; 274 const tracingSessionId = ++this.tracingSessionId; 275 setTimeout( 276 () => this.endTracing(tracingSessionId), traceConfig.durationMs); 277 } else { 278 console.log( 279 'Used Chrome version is too old to support ' + 280 'perfettoConfig parameter. Using chrome config only instead.'); 281 282 if (hasSystemDataSourceConfig(traceConfig)) { 283 this.sendErrorMessage( 284 'System tracing is not supported by this Chrome version. Choose' + 285 ' the \'Chrome\' target instead to record a Chrome-only trace.'); 286 return; 287 } 288 289 const chromeConfig = this.extractChromeConfig(traceConfig); 290 await this.api.Tracing.start( 291 {traceConfig: chromeConfig, ...requestParams}); 292 } 293 }); 294 } 295} 296