xref: /aosp_15_r20/external/perfetto/ui/src/chrome_extension/chrome_tracing_controller.ts (revision 6dbdd20afdafa5e3ca9b8809fa73465d530080dc)
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