blob: 916fca9a6fa42da6ad279162e6a9899627f07b27 [file] [log] [blame]
// Copyright (C) 2019 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import {Protocol} from 'devtools-protocol';
import {ProtocolProxyApi} from 'devtools-protocol/types/protocol-proxy-api';
import {Client} from 'noice-json-rpc';
import {base64Encode} from '../base/string_utils';
import {
ConsumerPortResponse,
GetTraceStatsResponse,
ReadBuffersResponse,
} from '../controller/consumer_port_types';
import {RpcConsumerPort} from '../controller/record_controller_interfaces';
import {
browserSupportsPerfettoConfig,
extractTraceConfig,
hasSystemDataSourceConfig,
} from '../core/trace_config_utils';
import {ITraceStats, TraceConfig} from '../protos';
import {DevToolsSocket} from './devtools_socket';
import {exists} from '../base/utils';
const CHUNK_SIZE: number = 1024 * 1024 * 16; // 16Mb
export class ChromeTracingController extends RpcConsumerPort {
private streamHandle: string | undefined = undefined;
private uiPort: chrome.runtime.Port;
private api: ProtocolProxyApi.ProtocolApi;
private devtoolsSocket: DevToolsSocket;
private lastBufferUsageEvent: Protocol.Tracing.BufferUsageEvent | undefined;
private tracingSessionOngoing = false;
private tracingSessionId = 0;
constructor(port: chrome.runtime.Port) {
super({
onConsumerPortResponse: (message: ConsumerPortResponse) =>
this.uiPort.postMessage(message),
onError: (error: string) =>
this.uiPort.postMessage({type: 'ChromeExtensionError', error}),
onStatus: (status) =>
this.uiPort.postMessage({type: 'ChromeExtensionStatus', status}),
});
this.uiPort = port;
this.devtoolsSocket = new DevToolsSocket();
this.devtoolsSocket.on('close', () => this.resetState());
const rpcClient = new Client(this.devtoolsSocket);
this.api = rpcClient.api();
this.api.Tracing.on('tracingComplete', this.onTracingComplete.bind(this));
this.api.Tracing.on('bufferUsage', this.onBufferUsage.bind(this));
this.uiPort.onDisconnect.addListener(() => {
this.devtoolsSocket.detach();
});
}
handleCommand(methodName: string, requestData: Uint8Array) {
switch (methodName) {
case 'EnableTracing':
this.enableTracing(requestData);
break;
case 'FreeBuffers':
this.freeBuffers();
break;
case 'ReadBuffers':
this.readBuffers();
break;
case 'DisableTracing':
this.disableTracing();
break;
case 'GetTraceStats':
this.getTraceStats();
break;
case 'GetCategories':
this.getCategories();
break;
default:
this.sendErrorMessage('Action not recognized');
console.log('Received not recognized message: ', methodName);
break;
}
}
enableTracing(enableTracingRequest: Uint8Array) {
this.resetState();
const traceConfigProto = extractTraceConfig(enableTracingRequest);
if (!traceConfigProto) {
this.sendErrorMessage('Invalid trace config');
return;
}
this.handleStartTracing(traceConfigProto);
}
toCamelCase(key: string, separator: string): string {
return key
.split(separator)
.map((part, index) => {
return index === 0 ? part : part[0].toUpperCase() + part.slice(1);
})
.join('');
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
convertDictKeys(obj: any): any {
if (Array.isArray(obj)) {
return obj.map((v) => this.convertDictKeys(v));
}
if (typeof obj === 'object' && obj !== null) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const converted: any = {};
for (const key of Object.keys(obj)) {
converted[this.toCamelCase(key, '_')] = this.convertDictKeys(obj[key]);
}
return converted;
}
return obj;
}
convertToDevToolsConfig(config: unknown): Protocol.Tracing.TraceConfig {
// DevTools uses a different naming style for config properties: Dictionary
// keys are named "camelCase" style, rather than "underscore_case" style as
// in the TraceConfig.
const convertedConfig = this.convertDictKeys(config);
// recordMode is specified as an enum with camelCase values.
if (convertedConfig.recordMode as string) {
convertedConfig.recordMode = this.toCamelCase(
convertedConfig.recordMode as string,
'-',
);
}
return convertedConfig as Protocol.Tracing.TraceConfig;
}
// TODO(nicomazz): write unit test for this
extractChromeConfig(
perfettoConfig: TraceConfig,
): Protocol.Tracing.TraceConfig {
for (const ds of perfettoConfig.dataSources) {
if (
ds.config &&
ds.config.name === 'org.chromium.trace_event' &&
exists(ds.config.chromeConfig) &&
exists(ds.config.chromeConfig.traceConfig)
) {
const chromeConfigJsonString = ds.config.chromeConfig.traceConfig;
const config = JSON.parse(chromeConfigJsonString);
return this.convertToDevToolsConfig(config);
}
}
return {};
}
freeBuffers() {
this.devtoolsSocket.detach();
this.sendMessage({type: 'FreeBuffersResponse'});
}
async readBuffers(offset = 0) {
if (!this.devtoolsSocket.isAttached() || this.streamHandle === undefined) {
this.sendErrorMessage('No tracing session to read from');
return;
}
const res = await this.api.IO.read({
handle: this.streamHandle,
offset,
size: CHUNK_SIZE,
});
if (res === undefined) return;
const chunk = res.base64Encoded ? atob(res.data) : res.data;
// The 'as {} as UInt8Array' is done because we can't send ArrayBuffers
// trough a chrome.runtime.Port. The conversion from string to ArrayBuffer
// takes place on the other side of the port.
const response: ReadBuffersResponse = {
type: 'ReadBuffersResponse',
slices: [{data: chunk as {} as Uint8Array, lastSliceForPacket: res.eof}],
};
this.sendMessage(response);
if (res.eof) return;
this.readBuffers(offset + chunk.length);
}
async disableTracing() {
await this.endTracing(this.tracingSessionId);
this.sendMessage({type: 'DisableTracingResponse'});
}
async endTracing(tracingSessionId: number) {
if (tracingSessionId !== this.tracingSessionId) {
return;
}
if (this.tracingSessionOngoing) {
await this.api.Tracing.end();
}
this.tracingSessionOngoing = false;
}
getTraceStats() {
// If the statistics are not available yet, it is 0.
const percentFull = this.lastBufferUsageEvent?.percentFull ?? 0;
const stats: ITraceStats = {
bufferStats: [
{bufferSize: 1000, bytesWritten: Math.round(percentFull * 1000)},
],
};
const response: GetTraceStatsResponse = {
type: 'GetTraceStatsResponse',
traceStats: stats,
};
this.sendMessage(response);
}
getCategories() {
const fetchCategories = async () => {
const categories = (await this.api.Tracing.getCategories()).categories;
this.uiPort.postMessage({type: 'GetCategoriesResponse', categories});
};
// If a target is already attached, we simply fetch the categories.
if (this.devtoolsSocket.isAttached()) {
fetchCategories();
return;
}
// Otherwise, we attach temporarily.
this.devtoolsSocket.attachToBrowser(async (error?: string) => {
if (error) {
this.sendErrorMessage(
`Could not attach to DevTools browser target ` +
`(req. Chrome >= M81): ${error}`,
);
return;
}
fetchCategories();
this.devtoolsSocket.detach();
});
}
resetState() {
this.devtoolsSocket.detach();
this.streamHandle = undefined;
}
onTracingComplete(params: Protocol.Tracing.TracingCompleteEvent) {
this.streamHandle = params.stream;
this.sendMessage({type: 'EnableTracingResponse'});
}
onBufferUsage(params: Protocol.Tracing.BufferUsageEvent) {
this.lastBufferUsageEvent = params;
}
handleStartTracing(traceConfigProto: Uint8Array) {
this.devtoolsSocket.attachToBrowser(async (error?: string) => {
if (error) {
this.sendErrorMessage(
`Could not attach to DevTools browser target ` +
`(req. Chrome >= M81): ${error}`,
);
return;
}
const requestParams: Protocol.Tracing.StartRequest = {
streamFormat: 'proto',
transferMode: 'ReturnAsStream',
streamCompression: 'gzip',
bufferUsageReportingInterval: 200,
};
const traceConfig = TraceConfig.decode(traceConfigProto);
if (browserSupportsPerfettoConfig()) {
const configEncoded = base64Encode(traceConfigProto);
await this.api.Tracing.start({
perfettoConfig: configEncoded,
...requestParams,
});
this.tracingSessionOngoing = true;
const tracingSessionId = ++this.tracingSessionId;
setTimeout(
() => this.endTracing(tracingSessionId),
traceConfig.durationMs,
);
} else {
console.log(
'Used Chrome version is too old to support ' +
'perfettoConfig parameter. Using chrome config only instead.',
);
if (hasSystemDataSourceConfig(traceConfig)) {
this.sendErrorMessage(
'System tracing is not supported by this Chrome version. Choose' +
" the 'Chrome' target instead to record a Chrome-only trace.",
);
return;
}
const chromeConfig = this.extractChromeConfig(traceConfig);
await this.api.Tracing.start({
traceConfig: chromeConfig,
...requestParams,
});
}
});
}
}