• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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