blob: c56643172c3ba8b580c52e298f1d065dd5b926dc [file] [log] [blame]
// Copyright 2019 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:convert';
import 'dart:io' show File, Platform, Process, ProcessResult, WebSocket;
import 'package:logging/logging.dart';
import 'package:meta/meta.dart';
import 'package:path/path.dart' as path;
import 'dump.dart';
import 'sl4f_client.dart';
import 'storage.dart';
import 'trace_processing/metrics_results.dart';
import 'trace_processing/metrics_spec.dart';
import 'trace_processing/trace_importing.dart';
String _traceExtension({bool binary, bool compress}) {
String extension = 'json';
if (binary) {
extension = 'fxt';
}
if (compress) {
extension += '.gz';
}
return extension;
}
File _replaceExtension(File file, String newExtension) {
String basePath = file.path;
final firstExtension = path.extension(basePath);
basePath = path.withoutExtension(basePath);
if (firstExtension == '.gz') {
basePath = path.withoutExtension(basePath);
}
return File('$basePath.$newExtension');
}
String _traceNameToTargetPath(String traceName, String extension) {
return '/tmp/$traceName-trace.$extension';
}
final _log = Logger('Performance');
class Performance {
// Environment variable names used by the catapult converter to tag the test results.
static const String _builderNameVarName = 'BUILDER_NAME';
static const String _buildBucketIdVarName = 'BUILDBUCKET_ID';
static const String _buildCreateTimeVarName = 'BUILD_CREATE_TIME';
static const String _inputCommitHostVarName = 'INPUT_COMMIT_HOST';
static const String _inputCommitProjectVarName = 'INPUT_COMMIT_PROJECT';
static const String _inputCommitRefVarName = 'INPUT_COMMIT_REF';
final Sl4f _sl4f;
final Dump _dump;
/// Constructs a [Performance] object.
Performance(this._sl4f, [Dump dump]) : _dump = dump ?? Dump();
/// Closes the underlying HTTP client.
///
/// This need not be called if the Sl4f client is closed instead.
void close() {
_sl4f.close();
}
Future<TraceSession> initializeTracing(
{List<String> categories, int bufferSize}) async {
_log.info('Performance: Initializing trace session');
final params = {};
if (categories != null) {
params['categories'] = categories;
}
if (bufferSize != null) {
params['buffer_size'] = bufferSize;
}
await _sl4f.request('tracing_facade.Initialize', params);
return TraceSession(_sl4f, _dump);
}
/// Terminate any existing trace session without collecting trace data.
Future<void> terminateExistingTraceSession() async {
_log.info('Performance: Terminating any existing trace session');
await _sl4f
.request('tracing_facade.Terminate', {'results_destination': 'Ignore'});
}
/// Starts tracing for the given [duration].
///
/// If [binary] is true, then the trace will be captured in Fuchsia Trace
/// Format (by default, it is in Chrome JSON Format). If [compress] is true,
/// the trace will be gzip-compressed. The trace output will be saved to a
/// path implied by [traceName], [binary], and [compress], and can be
/// retrieved later via [downloadTraceFile].
Future<bool> trace(
{@required Duration duration,
@required String traceName,
String categories,
int bufferSize,
bool binary = false,
bool compress = false}) async {
// Invoke `/bin/trace record --duration=$duration --categories=$categories
// --output-file=$outputFile --buffer-size=$bufferSize` on the target
// device via ssh.
final durationSeconds = duration.inSeconds;
String command = 'trace record --duration=$durationSeconds';
if (categories != null) {
command += ' --categories=$categories';
}
if (bufferSize != null) {
command += ' --buffer-size=$bufferSize';
}
if (binary) {
command += ' --binary';
}
if (compress) {
command += ' --compress';
}
final String extension =
_traceExtension(binary: binary, compress: compress);
final outputFile = _traceNameToTargetPath(traceName, extension);
if (outputFile != null) {
command += ' --output-file=$outputFile';
}
final result = await _sl4f.ssh.run(command);
return result.exitCode == 0;
}
/// Copies the trace file specified by [traceName] off of the target device,
/// and then saves it to the dump directory.
///
/// A [trace] call with the same [traceName], [binary], and [compress] must
/// have successfully completed before calling [downloadTraceFile]. The trace
/// file will be removed from the target device once it is downloaded.
///
/// Returns the download trace [File].
Future<File> downloadTraceFile(String traceName,
{bool binary = false, bool compress = false}) async {
_log.info('Performance: Downloading trace $traceName');
final String extension =
_traceExtension(binary: binary, compress: compress);
final tracePath = _traceNameToTargetPath(traceName, extension);
var response = await _sl4f
.request('traceutil_facade.GetTraceFile', {'path': tracePath});
List<int> contents = base64.decode(response['data']);
while (response.containsKey('next_offset')) {
response = await _sl4f.request('traceutil_facade.GetTraceFile',
{'path': tracePath, 'offset': response['next_offset']});
contents += base64.decode(response['data']);
}
await Storage(_sl4f).deleteFile(tracePath);
return _dump.writeAsBytes('$traceName-trace', extension, contents);
}
/// Starts a Chrome trace from the given [webSocketUrl] with the default
/// categories.
///
/// [webSocketUrl] can be obtained from
/// [Webdriver.webSocketDebuggerUrlsForHost]. Returns a WebSocket object that
/// is to be passed to [stopChromeTrace] to stop and download the trace data.
///
/// TODO(35714): Allow tracing users to specify categories to trace.
Future<WebSocket> startChromeTrace(String webSocketUrl) async {
final webSocket = await WebSocket.connect(webSocketUrl);
_log.info('Starting chrome trace');
webSocket.add(json.encode({
'jsonrpc': '2.0',
'method': 'Tracing.start',
'params': {},
'id': 1,
}));
return webSocket;
}
/// Stops a Chrome trace that was started by [startChromeTrace] and writes it
/// to a file.
///
/// Returns the file containing the trace data. Calling [stopChromeTrace] on
/// the same [webSocket] twice will throw an error.
Future<File> stopChromeTrace(WebSocket webSocket,
{@required String traceName}) async {
_log.info('Stopping and saving chrome trace');
webSocket.add(json.encode({
'jsonrpc': '2.0',
'method': 'Tracing.end',
'params': {},
'id': 2,
}));
final traceEvents = [];
await for (final content in webSocket) {
final obj = json.decode(content);
if (obj['method'] == 'Tracing.tracingComplete') {
break;
} else if (obj['method'] == 'Tracing.dataCollected') {
traceEvents.addAll(obj['params']['value']);
}
}
await webSocket.close();
_log.info('Writing chrome trace to file');
return _dump.writeAsBytes('$traceName-chrome-trace', 'json',
utf8.encode(json.encode(traceEvents)));
}
/// Combine [fuchsiaTrace] and [chromeTrace] into a merged JSON-format trace.
///
/// [fuchsiaTrace] must be a trace file in JSON format (not FXT).
Future<File> mergeTraces(
{@required File fuchsiaTrace,
@required File chromeTrace,
@required String traceName}) async {
final fuchsiaTraceData = json.decode(await fuchsiaTrace.readAsString());
final chromeTraceData = json.decode(await chromeTrace.readAsString());
final mergedTraceData = fuchsiaTraceData;
mergedTraceData['traceEvents'].addAll(chromeTraceData);
return _dump.writeAsBytes('$traceName-merged-trace', 'json',
utf8.encode(json.encode(mergedTraceData)));
}
/// A helper function that runs a process with the given args.
/// Required by the test to capture the parameters passed to [Process.run].
///
/// Returns [true] if the process ran successufly, [false] otherwise.
Future<bool> runProcess(String executablePath, List<String> args) async {
_log.info('Performance: Running $executablePath ${args.join(" ")}');
final ProcessResult results = await Process.run(executablePath, args);
_log..info(results.stdout)..info(results.stderr);
return results.exitCode == 0;
}
/// Convert the specified [traceFile] from fxt or fxt.gz to json or json.gz.
///
/// In typical uses, [traceFile] should be the return value of a call to
/// [downloadTraceFile].
///
/// By default, this function guesses whether the input is compressed by
/// examining [traceFile]'s extension. This can be overridden by passing a
/// value for [compressedInput]. If [compressedOutput] is set to true, then
/// this will produce a json.gz file instead of a json file.
///
/// Returns the [File] generated by trace2json.
Future<File> convertTraceFileToJson(String trace2jsonPath, File traceFile,
{bool compressedInput, bool compressedOutput = false}) async {
_log.info('Performance: Converting ${traceFile.absolute.path} to json');
final String outputExtension =
_traceExtension(binary: false, compress: compressedOutput);
final File outputFile = _replaceExtension(traceFile, outputExtension);
final args = [
'--input-file=${traceFile.path}',
'--output-file=${outputFile.path}',
];
if (compressedInput ?? path.extension(traceFile.path) == '.gz') {
args.add('--compressed-input');
}
if (compressedOutput) {
args.add('--compressed-output');
}
final trace2json = Platform.script.resolve(trace2jsonPath).toFilePath();
if (!await runProcess(trace2json, args)) {
return null;
}
return outputFile;
}
/// Runs the provided [MetricsSpecSet] on the given [trace].
/// It sets the ouptut file location to be the same as the source.
/// It will also run the catapult converter if the [converterPath] was provided.
///
/// The [converterPath] must be relative to the script path.
///
/// [registry] defines the set of known metrics processors, which can be
/// specified to allow processing of custom metrics.
///
/// TODO(PT-216): Avoid explicitly passing the [converterPath].
///
/// Returns the benchmark result [File] generated by the processor.
Future<File> processTrace(MetricsSpecSet metricsSpecSet, File trace,
{String converterPath,
Map<String, MetricsProcessor> registry = defaultMetricsRegistry}) async {
_log.info('Processing trace: ${trace.path}');
final outputFileName =
'${trace.parent.absolute.path}/${metricsSpecSet.testName}-benchmark.fuchsiaperf.json';
final model = await createModelFromFile(trace);
final List<Map<String, dynamic>> results = [];
for (final metricsSpec in metricsSpecSet.metricsSpecs) {
_log.info('Applying metricsSpec ${metricsSpec.name} to ${trace.path}');
final testCaseResultss =
processMetrics(model, metricsSpec, registry: registry);
for (final testCaseResults in testCaseResultss) {
results.add({
'label': testCaseResults.label,
'test_suite': metricsSpecSet.testName,
'unit': unitToCatapultConverterString(testCaseResults.unit),
'values': testCaseResults.values,
'split_first': testCaseResults.splitFirst,
});
}
}
File(outputFileName)
..createSync()
..writeAsStringSync(json.encode(results));
File processedResultFile = File(outputFileName);
_log.info('Processing trace completed.');
if (converterPath != null) {
await convertResults(
converterPath, processedResultFile, Platform.environment);
}
return processedResultFile;
}
/// A helper function that converts the results to the catapult format.
///
/// Returns the converted benchmark result [File].
///
/// TODO(fxb/23091): Remove the uploadToCatapultDashboard argument once all
/// the performance tests are moved over to using SL4F and this argument is
/// unused.
Future<File> convertResults(
String converterPath, File result, Map<String, String> environment,
{bool uploadToCatapultDashboard = true}) async {
_log.info('Converting the results into the catapult format');
var bot = '', logurl = '', master = '', timestamp = 0;
if (!environment.containsKey(_buildBucketIdVarName)) {
_log.info(
'convertResults: No $_buildBucketIdVarName, treating as a local run.');
bot = 'local-bot';
master = 'local-master';
logurl = 'http://ci.example.com/build/300';
timestamp = new DateTime.now().millisecondsSinceEpoch;
} else {
// Verify that all required environment variables are available.
final builderName = environment[_builderNameVarName];
final buildbucketId = environment[_buildBucketIdVarName];
final buildCreateTime = environment[_buildCreateTimeVarName];
final inputCommitRef = environment[_inputCommitRefVarName];
final inputCommitHost = environment[_inputCommitHostVarName];
final inputCommitProject = environment[_inputCommitProjectVarName];
if (buildbucketId == null ||
builderName == null ||
buildCreateTime == null ||
inputCommitRef == null ||
inputCommitHost == null ||
inputCommitProject == null) {
_log.warning('Some required environment variables are not available. '
'Current available variables are: ${environment.keys}');
return null;
}
logurl = 'https://ci.chromium.org/b/$buildbucketId';
bot = builderName;
timestamp = int.parse(buildCreateTime);
master =
'${inputCommitHost.replaceFirst('.googlesource.com', '')}.$inputCommitProject';
const releasesRefPrefix = 'refs/heads/releases/';
if (inputCommitRef.startsWith(releasesRefPrefix)) {
master += '.${inputCommitRef.substring(releasesRefPrefix.length)}';
} else {
assert(inputCommitRef == 'refs/heads/master');
}
}
final resultsPath = result.absolute.path;
assert(resultsPath.endsWith('.fuchsiaperf.json'));
// The infra recipe looks for the filename extension '.catapult_json',
// so uploading to the Catapult performance dashboard is disabled if we
// use a different extension.
final catapultExtension = uploadToCatapultDashboard
? '.catapult_json'
: '.catapult_json_disabled';
final outputFileName = resultsPath.replaceFirst(
RegExp(r'\.fuchsiaperf\.json$'), catapultExtension);
final List<String> args = [
'--input',
result.absolute.path,
'--output',
outputFileName,
'--execution-timestamp-ms',
timestamp.toString(),
'--masters',
master,
'--log-url',
logurl,
'--bots',
bot
];
final converter = Platform.script.resolve(converterPath).toFilePath();
if (!await runProcess(converter, args)) {
_log.warning('Running the results converter failed.');
return null;
}
_log.info('Conversion to catapult results format completed.');
return Future.value(File(outputFileName));
}
}
class TraceSession {
final Sl4f _sl4f;
final Dump _dump;
bool _closed;
TraceSession(this._sl4f, this._dump) : _closed = false;
/// Start tracing.
Future<void> start() async {
if (_closed) {
throw StateError('Cannot start: Session already terminated');
}
_log.info('Tracing: starting trace');
await _sl4f.request('tracing_facade.Start');
}
/// Stop tracing.
Future<void> stop() async {
if (_closed) {
throw StateError('Cannot stop: Session already terminated');
}
_log.info('Tracing: stopping trace');
await _sl4f.request('tracing_facade.Stop');
}
/// Terminate the trace session and download the trace data, returning a
/// [File] object with the Fuchsia trace format data.
///
/// After a call to [terminateAndDownload], further calls on the
/// [TraceSession] object will throw a [StateError].
Future<File> terminateAndDownload(String traceName) async {
if (_closed) {
throw StateError('Cannot terminate: Session already terminated');
}
_log.info('Tracing: terminating trace');
final response = await _sl4f.request('tracing_facade.Terminate');
_closed = true;
final traceData = base64.decode(response['data']);
return _dump.writeAsBytes('$traceName-trace', 'fxt', traceData);
}
}