[tools/perf] Copy over catapult/experimental perf tools
Graduating these out of experimental folder in catapult.
Bug: 922030
Change-Id: I3ac3679f865aa4f409c96608348cf65d6941f82e
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/1494880
Reviewed-by: Sergiy Belozorov <sergiyb@chromium.org>
Reviewed-by: Caleb Rouleau <crouleau@chromium.org>
Commit-Queue: Caleb Rouleau <crouleau@chromium.org>
Auto-Submit: Juan Antonio Navarro PĂ©rez <perezju@chromium.org>
Cr-Commit-Position: refs/heads/master@{#638364}
diff --git a/chrome/test/BUILD.gn b/chrome/test/BUILD.gn
index aef854ae..a5590450 100644
--- a/chrome/test/BUILD.gn
+++ b/chrome/test/BUILD.gn
@@ -2370,9 +2370,6 @@
# For tests in tools/perf/process_perf_results_unittest.py
"//build/android/pylib/",
"//tools/swarming_client/",
-
- # For tests in tools/perf/cli_tools/update_wpr
- "//third_party/catapult/experimental/soundwave/services/",
]
if (enable_mus) {
diff --git a/tools/perf/cli_tools/pinpoint_cli/__init__.py b/tools/perf/cli_tools/pinpoint_cli/__init__.py
new file mode 100644
index 0000000..1adf20d2
--- /dev/null
+++ b/tools/perf/cli_tools/pinpoint_cli/__init__.py
@@ -0,0 +1,3 @@
+# Copyright 2018 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
diff --git a/tools/perf/cli_tools/pinpoint_cli/commands.py b/tools/perf/cli_tools/pinpoint_cli/commands.py
new file mode 100644
index 0000000..c2ce907
--- /dev/null
+++ b/tools/perf/cli_tools/pinpoint_cli/commands.py
@@ -0,0 +1,62 @@
+# Copyright 2018 The Chromium 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 csv
+import json
+import ntpath
+import posixpath
+import sys
+
+from cli_tools.pinpoint_cli import histograms_df
+from cli_tools.pinpoint_cli import job_results
+from core.services import isolate_service
+from core.services import pinpoint_service
+
+
+def StartJobFromConfig(config_path):
+ """Start a pinpoint job based on a config file."""
+ src = sys.stdin if config_path == '-' else open(config_path)
+ with src as f:
+ config = json.load(f)
+
+ if not isinstance(config, dict):
+ raise ValueError('Invalid job config')
+
+ response = pinpoint_service.NewJob(**config)
+ print 'Started:', response['jobUrl']
+
+
+def CheckJobStatus(job_ids):
+ for job_id in job_ids:
+ job = pinpoint_service.Job(job_id)
+ print '%s: %s' % (job_id, job['status'].lower())
+
+
+def DownloadJobResultsAsCsv(job_ids, only_differences, output_file):
+ """Download the perf results of a job as a csv file."""
+ with open(output_file, 'wb') as f:
+ writer = csv.writer(f)
+ writer.writerow(('job_id', 'change', 'isolate') + histograms_df.COLUMNS)
+ num_rows = 0
+ for job_id in job_ids:
+ job = pinpoint_service.Job(job_id, with_state=True)
+ os_path = _OsPathFromJob(job)
+ results_file = os_path.join(
+ job['arguments']['benchmark'], 'perf_results.json')
+ print 'Fetching results for %s job %s:' % (job['status'].lower(), job_id)
+ for change_id, isolate_hash in job_results.IterTestOutputIsolates(
+ job, only_differences):
+ print '- isolate: %s ...' % isolate_hash
+ histograms = isolate_service.RetrieveFile(isolate_hash, results_file)
+ for row in histograms_df.IterRows(json.loads(histograms)):
+ writer.writerow((job_id, change_id, isolate_hash) + row)
+ num_rows += 1
+ print 'Wrote data from %d histograms in %s.' % (num_rows, output_file)
+
+
+def _OsPathFromJob(job):
+ if job['arguments']['configuration'].lower().startswith('win'):
+ return ntpath
+ else:
+ return posixpath
diff --git a/tools/perf/cli_tools/pinpoint_cli/histograms_df.py b/tools/perf/cli_tools/pinpoint_cli/histograms_df.py
new file mode 100644
index 0000000..3887515
--- /dev/null
+++ b/tools/perf/cli_tools/pinpoint_cli/histograms_df.py
@@ -0,0 +1,56 @@
+# Copyright 2018 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+from core.external_modules import pandas
+
+from tracing.value import histogram_set
+from tracing.value.diagnostics import generic_set
+
+
+_PROPERTIES = (
+ ('name', 'name'),
+ ('unit', 'unit'),
+ ('mean', 'average'),
+ ('stdev', 'standard_deviation'),
+ ('count', 'num_values')
+)
+_DIAGNOSTICS = (
+ ('run_label', 'labels'),
+ ('benchmark', 'benchmarks'),
+ ('story', 'stories'),
+ ('benchmark_start', 'benchmarkStart'),
+ ('device_id', 'deviceIds'),
+ ('trace_url', 'traceUrls')
+)
+COLUMNS = tuple(key for key, _ in _PROPERTIES) + tuple(
+ key for key, _ in _DIAGNOSTICS)
+
+
+def _DiagnosticValueToStr(value):
+ if value is None:
+ return ''
+ elif isinstance(value, generic_set.GenericSet):
+ return ','.join(str(v) for v in value)
+ else:
+ return str(value)
+
+
+def IterRows(histogram_dicts):
+ """Iterate over histogram dicts yielding rows for a DataFrame or csv."""
+ histograms = histogram_set.HistogramSet()
+ histograms.ImportDicts(histogram_dicts)
+ for hist in histograms:
+ row = [getattr(hist, name) for _, name in _PROPERTIES]
+ row.extend(
+ _DiagnosticValueToStr(hist.diagnostics.get(name))
+ for _, name in _DIAGNOSTICS)
+ yield tuple(row)
+
+
+def DataFrame(histogram_dicts):
+ """Turn a list of histogram dicts into a pandas DataFrame."""
+ df = pandas.DataFrame.from_records(
+ IterRows(histogram_dicts), columns=COLUMNS)
+ df['benchmark_start'] = pandas.to_datetime(df['benchmark_start'])
+ return df
diff --git a/tools/perf/cli_tools/pinpoint_cli/histograms_df_test.py b/tools/perf/cli_tools/pinpoint_cli/histograms_df_test.py
new file mode 100644
index 0000000..e3d955f
--- /dev/null
+++ b/tools/perf/cli_tools/pinpoint_cli/histograms_df_test.py
@@ -0,0 +1,90 @@
+# Copyright 2018 The Chromium 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 unittest
+
+from cli_tools.pinpoint_cli import histograms_df
+from core.external_modules import pandas
+
+from tracing.value import histogram
+from tracing.value import histogram_set
+from tracing.value.diagnostics import date_range
+from tracing.value.diagnostics import generic_set
+
+
+def TestHistogram(name, units, values, **kwargs):
+ def DiagnosticValue(value):
+ if isinstance(value, (int, long)):
+ return date_range.DateRange(value)
+ elif isinstance(value, list):
+ return generic_set.GenericSet(value)
+ else:
+ raise NotImplementedError(type(value))
+
+ hist = histogram.Histogram(name, units)
+ hist.diagnostics.update(
+ (key, DiagnosticValue(value)) for key, value in kwargs.iteritems())
+ for value in values:
+ hist.AddSample(value)
+ return hist
+
+
+@unittest.skipIf(pandas is None, 'pandas not available')
+class TestHistogramsDf(unittest.TestCase):
+ def testIterRows(self):
+ run1 = {'benchmarkStart': 1234567890000, 'labels': ['run1'],
+ 'benchmarks': ['system_health'], 'deviceIds': ['device1']}
+ # Second run on same device ten minutes later.
+ run2 = {'benchmarkStart': 1234567890000 + 600000, 'labels': ['run2'],
+ 'benchmarks': ['system_health'], 'deviceIds': ['device1']}
+ hists = histogram_set.HistogramSet([
+ TestHistogram('startup', 'ms', [8, 10, 12], stories=['story1'],
+ traceUrls=['http://url/to/trace1'], **run1),
+ TestHistogram('memory', 'sizeInBytes', [256], stories=['story2'],
+ traceUrls=['http://url/to/trace2'], **run1),
+ TestHistogram('memory', 'sizeInBytes', [512], stories=['story2'],
+ traceUrls=['http://url/to/trace3'], **run2),
+ ])
+
+ expected = [
+ ('startup', 'ms', 10.0, 2.0, 3, 'run1', 'system_health',
+ 'story1', '2009-02-13 23:31:30', 'device1', 'http://url/to/trace1'),
+ ('memory', 'sizeInBytes', 256.0, 0.0, 1, 'run1', 'system_health',
+ 'story2', '2009-02-13 23:31:30', 'device1', 'http://url/to/trace2'),
+ ('memory', 'sizeInBytes', 512.0, 0.0, 1, 'run2', 'system_health',
+ 'story2', '2009-02-13 23:41:30', 'device1', 'http://url/to/trace3'),
+ ]
+ self.assertItemsEqual(histograms_df.IterRows(hists.AsDicts()), expected)
+
+ def testDataFrame(self):
+ run1 = {'benchmarkStart': 1234567890000, 'labels': ['run1'],
+ 'benchmarks': ['system_health'], 'deviceIds': ['device1']}
+ # Second run on same device ten minutes later.
+ run2 = {'benchmarkStart': 1234567890000 + 600000, 'labels': ['run2'],
+ 'benchmarks': ['system_health'], 'deviceIds': ['device1']}
+ hists = histogram_set.HistogramSet([
+ TestHistogram('startup', 'ms', [8, 10, 12], stories=['story1'],
+ traceUrls=['http://url/to/trace1'], **run1),
+ TestHistogram('memory', 'sizeInBytes', [256], stories=['story2'],
+ traceUrls=['http://url/to/trace2'], **run1),
+ TestHistogram('memory', 'sizeInBytes', [384], stories=['story2'],
+ traceUrls=['http://url/to/trace3'], **run2),
+ ])
+ df = histograms_df.DataFrame(hists.AsDicts())
+
+ # Poke at the data frame and check a few known facts about our fake data:
+ # It has 3 histograms.
+ self.assertEqual(len(df), 3)
+ # The benchmark has two stories.
+ self.assertItemsEqual(df['story'].unique(), ['story1', 'story2'])
+ # We recorded three traces.
+ self.assertEqual(len(df['trace_url'].unique()), 3)
+ # All benchmarks ran on the same device.
+ self.assertEqual(len(df['device_id'].unique()), 1)
+ # There is a memory regression between runs 1 and 2.
+ memory = df.set_index(['name', 'run_label']).loc['memory']['mean']
+ self.assertEqual(memory['run2'] - memory['run1'], 128.0)
+ # Ten minutes passed between the two benchmark runs.
+ self.assertEqual(df['benchmark_start'].max() - df['benchmark_start'].min(),
+ pandas.Timedelta('10 minutes'))
diff --git a/tools/perf/cli_tools/pinpoint_cli/job_results.py b/tools/perf/cli_tools/pinpoint_cli/job_results.py
new file mode 100644
index 0000000..88e3abd1
--- /dev/null
+++ b/tools/perf/cli_tools/pinpoint_cli/job_results.py
@@ -0,0 +1,44 @@
+# Copyright 2018 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+
+def ChangeToStr(change):
+ """Turn a pinpoint change dict into a string id."""
+ change_id = ','.join(
+ '{repository}@{git_hash}'.format(**commit)
+ for commit in change['commits'])
+ if 'patch' in change:
+ change_id += '+' + change['patch']['url']
+ return change_id
+
+
+def IterTestOutputIsolates(job, only_differences=False):
+ """Iterate over test execution results for all changes tested in the job.
+
+ Args:
+ job: A pinpoint job dict with state.
+
+ Yields:
+ (change_id, isolate_hash) pairs for each completed test execution found in
+ the job.
+ """
+ quests = job['quests']
+ for change_state in job['state']:
+ if only_differences and not any(
+ v == 'different' for v in change_state['comparisons'].itervalues()):
+ continue
+ change_id = ChangeToStr(change_state['change'])
+ for attempt in change_state['attempts']:
+ executions = dict(zip(quests, attempt['executions']))
+ if 'Test' not in executions:
+ continue
+ test_run = executions['Test']
+ if not test_run['completed']:
+ continue
+ try:
+ isolate_hash = next(
+ d['value'] for d in test_run['details'] if d['key'] == 'isolate')
+ except StopIteration:
+ continue
+ yield change_id, isolate_hash
diff --git a/tools/perf/cli_tools/pinpoint_cli/job_results_test.py b/tools/perf/cli_tools/pinpoint_cli/job_results_test.py
new file mode 100644
index 0000000..241c09f
--- /dev/null
+++ b/tools/perf/cli_tools/pinpoint_cli/job_results_test.py
@@ -0,0 +1,99 @@
+# Copyright 2018 The Chromium 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 unittest
+
+from cli_tools.pinpoint_cli import job_results
+
+
+def Change(*args, **kwargs):
+ commits = []
+ for arg in args:
+ repository, git_hash = arg.split('@')
+ commits.append({'repository': repository, 'git_hash': git_hash})
+ change = {'commits': commits}
+ patch = kwargs.pop('patch', None)
+ if patch is not None:
+ change['patch'] = {'url': patch}
+ return change
+
+
+def Execution(**kwargs):
+ execution = {'completed': kwargs.pop('completed', True)}
+ execution['details'] = [
+ {'key': k, 'value': v} for k, v in kwargs.iteritems()]
+ return execution
+
+
+class TestJobResults(unittest.TestCase):
+ def testChangeToStr(self):
+ self.assertEqual(
+ job_results.ChangeToStr(Change('src@1234')),
+ 'src@1234')
+ self.assertEqual(
+ job_results.ChangeToStr(
+ Change('src@1234', 'v8@4567', patch='crrev.com/c/123')),
+ 'src@1234,v8@4567+crrev.com/c/123')
+
+ def testIterTestOutputIsolates(self):
+ job = {
+ 'quests': ['Build', 'Test', 'Get results'],
+ 'state': [
+ {
+ 'change': Change('src@1234'),
+ 'attempts': [
+ {
+ 'executions': [
+ Execution(), # Build
+ Execution(isolate='results1'), # Test
+ Execution() # Get results
+ ]
+ },
+ {
+ 'executions': [
+ Execution(), # Build
+ Execution(), # Test (completed but failed)
+ ]
+ },
+ {
+ 'executions': [
+ Execution(), # Build
+ Execution(isolate='results3'), # Test
+ Execution(completed=False) # Get results
+ ]
+ }
+ ]
+ },
+ {
+ 'change': Change('src@1234', patch='crrev.com/c/123'),
+ 'attempts': [
+ {
+ 'executions': [
+ Execution(), # Build
+ Execution(isolate='results4'), # Test
+ Execution() # Get results
+ ]
+ },
+ {
+ 'executions': [
+ Execution(), # Build
+ Execution(completed=False) # Test
+ ]
+ },
+ {
+ 'executions': [
+ Execution(completed=False) # Build
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ self.assertSequenceEqual(
+ list(job_results.IterTestOutputIsolates(job)),
+ [
+ ('src@1234', 'results1'),
+ ('src@1234', 'results3'),
+ ('src@1234+crrev.com/c/123', 'results4')
+ ])
diff --git a/tools/perf/cli_tools/soundwave/__init__.py b/tools/perf/cli_tools/soundwave/__init__.py
new file mode 100644
index 0000000..1adf20d2
--- /dev/null
+++ b/tools/perf/cli_tools/soundwave/__init__.py
@@ -0,0 +1,3 @@
+# Copyright 2018 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
diff --git a/tools/perf/cli_tools/soundwave/commands.py b/tools/perf/cli_tools/soundwave/commands.py
new file mode 100644
index 0000000..61e9cc7
--- /dev/null
+++ b/tools/perf/cli_tools/soundwave/commands.py
@@ -0,0 +1,157 @@
+# Copyright 2018 The Chromium 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 json
+import logging
+import sqlite3
+
+from core import cli_utils
+from core.external_modules import pandas
+from core.services import dashboard_service
+from cli_tools.soundwave import pandas_sqlite
+from cli_tools.soundwave import studies
+from cli_tools.soundwave import tables
+from cli_tools.soundwave import worker_pool
+
+
+def _FetchBugsWorker(args):
+ con = sqlite3.connect(args.database_file, timeout=10)
+
+ def Process(bug_id):
+ bugs = tables.bugs.DataFrameFromJson([dashboard_service.Bugs(bug_id)])
+ pandas_sqlite.InsertOrReplaceRecords(con, 'bugs', bugs)
+
+ worker_pool.Process = Process
+
+
+def FetchAlertsData(args):
+ params = {
+ 'test_suite': args.benchmark,
+ 'min_timestamp': cli_utils.DaysAgoToTimestamp(args.days)
+ }
+ if args.sheriff != 'all':
+ params['sheriff'] = args.sheriff
+
+ with tables.DbSession(args.database_file) as con:
+ # Get alerts.
+ num_alerts = 0
+ bug_ids = set()
+ # TODO: This loop may be slow when fetching thousands of alerts, needs a
+ # better progress indicator.
+ for data in dashboard_service.IterAlerts(**params):
+ alerts = tables.alerts.DataFrameFromJson(data)
+ pandas_sqlite.InsertOrReplaceRecords(con, 'alerts', alerts)
+ num_alerts += len(alerts)
+ bug_ids.update(alerts['bug_id'].unique())
+ print '%d alerts found!' % num_alerts
+
+ # Get set of bugs associated with those alerts.
+ bug_ids.discard(0) # A bug_id of 0 means untriaged.
+ print '%d bugs found!' % len(bug_ids)
+
+ # Filter out bugs already in cache.
+ if args.use_cache:
+ known_bugs = set(
+ b for b in bug_ids if tables.bugs.Get(con, b) is not None)
+ if known_bugs:
+ print '(skipping %d bugs already in the database)' % len(known_bugs)
+ bug_ids.difference_update(known_bugs)
+
+ # Use worker pool to fetch bug data.
+ total_seconds = worker_pool.Run(
+ 'Fetching data of %d bugs: ' % len(bug_ids),
+ _FetchBugsWorker, args, bug_ids)
+ print '[%.1f bugs per second]' % (len(bug_ids) / total_seconds)
+
+
+def _IterStaleTestPaths(con, test_paths):
+ """Iterate over test_paths yielding only those with stale or absent data.
+
+ A test_path is considered to be stale if the most recent data point we have
+ for it in the db is more than a day older.
+ """
+ a_day_ago = pandas.Timestamp.utcnow() - pandas.Timedelta(days=1)
+ a_day_ago = a_day_ago.tz_convert(tz=None)
+
+ for test_path in test_paths:
+ latest = tables.timeseries.GetMostRecentPoint(con, test_path)
+ if latest is None or latest['timestamp'] < a_day_ago:
+ yield test_path
+
+
+def _FetchTimeseriesWorker(args):
+ con = sqlite3.connect(args.database_file, timeout=10)
+ min_timestamp = cli_utils.DaysAgoToTimestamp(args.days)
+
+ def Process(test_path):
+ try:
+ if isinstance(test_path, tables.timeseries.Key):
+ params = test_path.AsApiParams()
+ params['min_timestamp'] = min_timestamp
+ data = dashboard_service.Timeseries2(**params)
+ else:
+ data = dashboard_service.Timeseries(test_path, days=args.days)
+ except KeyError:
+ logging.info('Timeseries not found: %s', test_path)
+ return
+
+ timeseries = tables.timeseries.DataFrameFromJson(test_path, data)
+ pandas_sqlite.InsertOrReplaceRecords(con, 'timeseries', timeseries)
+
+ worker_pool.Process = Process
+
+
+def _ReadTimeseriesFromFile(filename):
+ with open(filename, 'r') as f:
+ data = json.load(f)
+ return [tables.timeseries.Key.FromDict(ts) for ts in data]
+
+
+def FetchTimeseriesData(args):
+ def _MatchesAllFilters(test_path):
+ return all(f in test_path for f in args.filters)
+
+ with tables.DbSession(args.database_file) as con:
+ # Get test_paths.
+ if args.benchmark is not None:
+ test_paths = dashboard_service.ListTestPaths(
+ args.benchmark, sheriff=args.sheriff)
+ elif args.input_file is not None:
+ test_paths = _ReadTimeseriesFromFile(args.input_file)
+ elif args.study is not None:
+ test_paths = list(args.study.IterTestPaths())
+ else:
+ raise ValueError('No source for test paths specified')
+
+ # Apply --filter's to test_paths.
+ if args.filters:
+ test_paths = filter(_MatchesAllFilters, test_paths)
+ num_found = len(test_paths)
+ print '%d test paths found!' % num_found
+
+ # Filter out test_paths already in cache.
+ if args.use_cache:
+ test_paths = list(_IterStaleTestPaths(con, test_paths))
+ num_skipped = num_found - len(test_paths)
+ if num_skipped:
+ print '(skipping %d test paths already in the database)' % num_skipped
+
+ # Use worker pool to fetch test path data.
+ total_seconds = worker_pool.Run(
+ 'Fetching data of %d timeseries: ' % len(test_paths),
+ _FetchTimeseriesWorker, args, test_paths)
+ print '[%.1f test paths per second]' % (len(test_paths) / total_seconds)
+
+ if args.output_csv is not None:
+ print
+ print 'Post-processing data for study ...'
+ dfs = []
+ with tables.DbSession(args.database_file) as con:
+ for test_path in test_paths:
+ df = tables.timeseries.GetTimeSeries(con, test_path)
+ dfs.append(df)
+ df = studies.PostProcess(pandas.concat(dfs, ignore_index=True))
+ with cli_utils.OpenWrite(args.output_csv) as f:
+ df.to_csv(f, index=False)
+ print 'Wrote timeseries data to:', args.output_csv
diff --git a/tools/perf/cli_tools/soundwave/pandas_sqlite.py b/tools/perf/cli_tools/soundwave/pandas_sqlite.py
new file mode 100644
index 0000000..59b9a154
--- /dev/null
+++ b/tools/perf/cli_tools/soundwave/pandas_sqlite.py
@@ -0,0 +1,84 @@
+# Copyright 2018 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+"""
+Helper methods for dealing with a SQLite database with pandas.
+"""
+
+from core.external_modules import pandas
+
+
+def DataFrame(column_types, index=None, rows=None):
+ """Create a DataFrame with given column types as index.
+
+ Unlike usual pandas DataFrame constructors, this allows to have explicitly
+ typed column values, even when no rows of data are provided. And, when such
+ data is available, values are explicitly casted, instead of letting pandas
+ guess a type.
+
+ Args:
+ column_types: A sequence of (name, dtype) pairs to define the columns.
+ index: An optional column name or sequence of column names to use as index
+ of the frame.
+ rows: An optional sequence of rows of data.
+ """
+ if rows:
+ cols = zip(*rows)
+ assert len(cols) == len(column_types)
+ cols = (list(vs) for vs in cols)
+ else:
+ cols = (None for _ in column_types)
+ df = pandas.DataFrame()
+ for (column, dtype), values in zip(column_types, cols):
+ df[column] = pandas.Series(values, dtype=dtype)
+ if index is not None:
+ index = [index] if isinstance(index, basestring) else list(index)
+ df.set_index(index, inplace=True)
+ return df
+
+
+def CreateTableIfNotExists(con, name, frame):
+ """Create a new empty table, if it doesn't already exist.
+
+ Args:
+ con: A sqlite connection object.
+ name: Name of SQL table to create.
+ frame: A DataFrame used to infer the schema of the table; the index of the
+ DataFrame is set as PRIMARY KEY of the table.
+ """
+ keys = [k for k in frame.index.names if k is not None]
+ if not keys:
+ keys = None
+ db = pandas.io.sql.SQLiteDatabase(con)
+ table = pandas.io.sql.SQLiteTable(
+ name, db, frame=frame, index=keys is not None, keys=keys,
+ if_exists='append')
+ table.create()
+
+
+def _InsertOrReplaceStatement(name, keys):
+ columns = ','.join(keys)
+ values = ','.join('?' for _ in keys)
+ return 'INSERT OR REPLACE INTO %s(%s) VALUES (%s)' % (name, columns, values)
+
+
+def InsertOrReplaceRecords(con, name, frame):
+ """Insert or replace records from a DataFrame into a SQLite database.
+
+ Assumes that the table already exists. Any new records with a matching
+ PRIMARY KEY, usually the frame.index, will replace existing records.
+
+ Args:
+ con: A sqlite connection object.
+ name: Name of SQL table.
+ frame: DataFrame with records to write.
+ """
+ db = pandas.io.sql.SQLiteDatabase(con)
+ table = pandas.io.sql.SQLiteTable(
+ name, db, frame=frame, index=True, if_exists='append')
+ assert table.exists()
+ keys, data = table.insert_data()
+ insert_statement = _InsertOrReplaceStatement(name, keys)
+ with db.run_transaction() as c:
+ c.executemany(insert_statement, zip(*data))
diff --git a/tools/perf/cli_tools/soundwave/pandas_sqlite_test.py b/tools/perf/cli_tools/soundwave/pandas_sqlite_test.py
new file mode 100644
index 0000000..648f420
--- /dev/null
+++ b/tools/perf/cli_tools/soundwave/pandas_sqlite_test.py
@@ -0,0 +1,73 @@
+# Copyright 2018 The Chromium 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 sqlite3
+import unittest
+
+from cli_tools.soundwave import pandas_sqlite
+from core.external_modules import pandas
+
+
+@unittest.skipIf(pandas is None, 'pandas not available')
+class TestPandasSQLite(unittest.TestCase):
+ def testCreateTableIfNotExists_newTable(self):
+ df = pandas_sqlite.DataFrame(
+ [('bug_id', int), ('summary', str), ('status', str)], index='bug_id')
+ con = sqlite3.connect(':memory:')
+ try:
+ self.assertFalse(pandas.io.sql.has_table('bugs', con))
+ pandas_sqlite.CreateTableIfNotExists(con, 'bugs', df)
+ self.assertTrue(pandas.io.sql.has_table('bugs', con))
+ finally:
+ con.close()
+
+ def testCreateTableIfNotExists_alreadyExists(self):
+ df = pandas_sqlite.DataFrame(
+ [('bug_id', int), ('summary', str), ('status', str)], index='bug_id')
+ con = sqlite3.connect(':memory:')
+ try:
+ self.assertFalse(pandas.io.sql.has_table('bugs', con))
+ pandas_sqlite.CreateTableIfNotExists(con, 'bugs', df)
+ self.assertTrue(pandas.io.sql.has_table('bugs', con))
+ # It's fine to call a second time.
+ pandas_sqlite.CreateTableIfNotExists(con, 'bugs', df)
+ self.assertTrue(pandas.io.sql.has_table('bugs', con))
+ finally:
+ con.close()
+
+ def testInsertOrReplaceRecords_tableNotExistsRaises(self):
+ column_types = (('bug_id', int), ('summary', str), ('status', str))
+ rows = [(123, 'Some bug', 'Started'), (456, 'Another bug', 'Assigned')]
+ df = pandas_sqlite.DataFrame(column_types, index='bug_id', rows=rows)
+ con = sqlite3.connect(':memory:')
+ try:
+ with self.assertRaises(AssertionError):
+ pandas_sqlite.InsertOrReplaceRecords(con, 'bugs', df)
+ finally:
+ con.close()
+
+ def testInsertOrReplaceRecords_existingRecords(self):
+ column_types = (('bug_id', int), ('summary', str), ('status', str))
+ rows1 = [(123, 'Some bug', 'Started'), (456, 'Another bug', 'Assigned')]
+ df1 = pandas_sqlite.DataFrame(column_types, index='bug_id', rows=rows1)
+ rows2 = [(123, 'Some bug', 'Fixed'), (789, 'A new bug', 'Untriaged')]
+ df2 = pandas_sqlite.DataFrame(column_types, index='bug_id', rows=rows2)
+ con = sqlite3.connect(':memory:')
+ try:
+ pandas_sqlite.CreateTableIfNotExists(con, 'bugs', df1)
+
+ # Write first data frame to database.
+ pandas_sqlite.InsertOrReplaceRecords(con, 'bugs', df1)
+ df = pandas.read_sql('SELECT * FROM bugs', con, index_col='bug_id')
+ self.assertEqual(len(df), 2)
+ self.assertEqual(df.loc[123]['status'], 'Started')
+
+ # Write second data frame to database.
+ pandas_sqlite.InsertOrReplaceRecords(con, 'bugs', df2)
+ df = pandas.read_sql('SELECT * FROM bugs', con, index_col='bug_id')
+ self.assertEqual(len(df), 3) # Only one extra record added.
+ self.assertEqual(df.loc[123]['status'], 'Fixed') # Bug is now fixed.
+ self.assertItemsEqual(df.index, (123, 456, 789))
+ finally:
+ con.close()
diff --git a/tools/perf/cli_tools/soundwave/studies/__init__.py b/tools/perf/cli_tools/soundwave/studies/__init__.py
new file mode 100644
index 0000000..72b4e19
--- /dev/null
+++ b/tools/perf/cli_tools/soundwave/studies/__init__.py
@@ -0,0 +1,36 @@
+# Copyright 2018 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+from cli_tools.soundwave.studies import health_study
+from cli_tools.soundwave.studies import v8_study
+from core.external_modules import pandas
+
+
+_STUDIES = {'health': health_study, 'v8': v8_study}
+
+NAMES = sorted(_STUDIES)
+
+
+def GetStudy(study):
+ return _STUDIES[study]
+
+
+def PostProcess(df):
+ # Snap stories on the same test run to the same timestamp.
+ df['timestamp'] = df.groupby(
+ ['test_suite', 'bot', 'point_id'])['timestamp'].transform('min')
+
+ # We use all runs on the latest day for each quarter as reference.
+ df['quarter'] = df['timestamp'].dt.to_period('Q')
+ df['reference'] = df['timestamp'].dt.date == df.groupby(
+ 'quarter')['timestamp'].transform('max').dt.date
+
+ # Change unit for values in ms to seconds.
+ # TODO: Get and use unit information from the dashboard instead of trying to
+ # guess by the measurement name.
+ is_ms_unit = (df['measurement'].str.startswith('timeTo') |
+ df['measurement'].str.endswith(':duration'))
+ df.loc[is_ms_unit, 'value'] = df['value'] / 1000
+
+ return df
diff --git a/tools/perf/cli_tools/soundwave/studies/health_study.py b/tools/perf/cli_tools/soundwave/studies/health_study.py
new file mode 100644
index 0000000..eb5711a4
--- /dev/null
+++ b/tools/perf/cli_tools/soundwave/studies/health_study.py
@@ -0,0 +1,45 @@
+# Copyright 2018 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+CLOUD_PATH = 'gs://chome-health-tvdata/datasets/health_study.csv'
+
+OVERALL_PSS = ('memory:{browser}:all_processes:reported_by_os:system_memory'
+ ':proportional_resident_size_avg')
+
+BATTERY = [
+ 'power.typical_10_mobile',
+ 'application_energy_consumption_mwh'
+]
+
+STARTUP_BY_BROWSER = {
+ 'chrome': [
+ 'startup.mobile',
+ 'first_contentful_paint_time_avg',
+ 'intent_coldish_bbc'
+ ],
+ 'webview': [
+ 'system_health.webview_startup',
+ 'webview_startup_wall_time_avg',
+ 'load_chrome/load_chrome_blank'
+ ]
+}
+
+
+def IterSystemHealthBots():
+ yield 'ChromiumPerf/android-go-perf'
+ yield 'ChromiumPerfFyi/android-go_webview-perf'
+
+
+def GetBrowserFromBot(bot):
+ return 'webview' if 'webview' in bot else 'chrome'
+
+
+def IterTestPaths():
+ for bot in IterSystemHealthBots():
+ browser = GetBrowserFromBot(bot)
+ overall_pss = OVERALL_PSS.format(browser=browser)
+ for story_group in ('foreground', 'background'):
+ yield '/'.join([bot, 'memory.top_10_mobile', overall_pss, story_group])
+ yield '/'.join([bot] + BATTERY)
+ yield '/'.join([bot] + STARTUP_BY_BROWSER[browser])
diff --git a/tools/perf/cli_tools/soundwave/studies/v8_study.py b/tools/perf/cli_tools/soundwave/studies/v8_study.py
new file mode 100644
index 0000000..5a09749
--- /dev/null
+++ b/tools/perf/cli_tools/soundwave/studies/v8_study.py
@@ -0,0 +1,48 @@
+# Copyright 2018 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+from core.services import dashboard_service
+from cli_tools.soundwave.tables import timeseries
+
+
+CLOUD_PATH = 'gs://chome-health-tvdata/datasets/v8_report.csv'
+
+ANDROID_GO = 'ChromiumPerf:android-go-perf'
+V8_EFFECTIVE_SIZE = (
+ 'memory:chrome:renderer_processes:reported_by_chrome:v8:effective_size')
+
+
+TEST_SUITES = {
+ 'system_health.memory_mobile': [
+ V8_EFFECTIVE_SIZE],
+ 'system_health.common_mobile': [
+ 'timeToFirstContentfulPaint', 'timeToFirstMeaningfulPaint',
+ 'timeToInteractive'],
+ 'v8.browsing_mobile': [
+ 'Total:duration', 'V8-Only:duration', V8_EFFECTIVE_SIZE]
+}
+
+
+def GetEmergingMarketStories():
+ description = dashboard_service.Describe('system_health.memory_mobile')
+ return description['caseTags']['emerging_market']
+
+
+def IterTestPaths():
+ # We want to track emerging market stories only.
+ test_cases = GetEmergingMarketStories()
+
+ for test_suite, measurements in TEST_SUITES.iteritems():
+ # v8.browsing_mobile only runs 'browse:*' stories, while other benchmarks
+ # run all of them.
+ browse_only = 'browsing' in test_suite
+ for test_case in test_cases:
+ if browse_only and not test_case.startswith('browse:'):
+ continue
+ for measurement in measurements:
+ yield timeseries.Key(
+ test_suite=test_suite,
+ measurement=measurement,
+ bot=ANDROID_GO,
+ test_case=test_case)
diff --git a/tools/perf/cli_tools/soundwave/tables/__init__.py b/tools/perf/cli_tools/soundwave/tables/__init__.py
new file mode 100644
index 0000000..e8528f3
--- /dev/null
+++ b/tools/perf/cli_tools/soundwave/tables/__init__.py
@@ -0,0 +1,44 @@
+# Copyright 2018 The Chromium 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 contextlib
+import os
+
+import sqlite3
+
+from cli_tools.soundwave import pandas_sqlite
+from cli_tools.soundwave.tables import alerts
+from cli_tools.soundwave.tables import bugs
+from cli_tools.soundwave.tables import timeseries
+
+
+@contextlib.contextmanager
+def DbSession(filename):
+ """Context manage a session with a database connection.
+
+ Ensures that tables have been initialized.
+ """
+ if filename != ':memory:':
+ parent_dir = os.path.dirname(filename)
+ if not os.path.exists(parent_dir):
+ os.makedirs(parent_dir)
+ con = sqlite3.connect(filename)
+ try:
+ # Tell sqlite to use a write-ahead log, which drastically increases its
+ # concurrency capabilities. This helps prevent 'database is locked'
+ # exceptions when we have many workers writing to a single database. This
+ # mode is sticky, so we only need to set it once and future connections
+ # will automatically use the log. More details are available at
+ # https://www.sqlite.org/wal.html.
+ con.execute('PRAGMA journal_mode=WAL')
+ _CreateTablesIfNeeded(con)
+ yield con
+ finally:
+ con.close()
+
+
+def _CreateTablesIfNeeded(con):
+ """Creates soundwave tables in the database, if they don't already exist."""
+ for m in (alerts, bugs, timeseries):
+ pandas_sqlite.CreateTableIfNotExists(con, m.TABLE_NAME, m.DataFrame())
diff --git a/tools/perf/cli_tools/soundwave/tables/alerts.py b/tools/perf/cli_tools/soundwave/tables/alerts.py
new file mode 100644
index 0000000..521512d
--- /dev/null
+++ b/tools/perf/cli_tools/soundwave/tables/alerts.py
@@ -0,0 +1,71 @@
+# Copyright 2018 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+from cli_tools.soundwave import pandas_sqlite
+
+
+TABLE_NAME = 'alerts'
+COLUMN_TYPES = (
+ ('key', str), # unique datastore key ('agxzfmNocm9tZXBlcmZyFAsS')
+ ('timestamp', 'datetime64[ns]'), # when the alert was created
+ ('test_suite', str), # benchmark name ('loading.mobile')
+ ('measurement', str), # metric name ('timeToFirstContentfulPaint')
+ ('bot', str), # master/builder name ('ChromiumPerf.android-nexus5')
+ ('test_case', str), # story name ('Wikipedia')
+ ('start_revision', str), # git hash or commit position before anomaly
+ ('end_revision', str), # git hash or commit position after anomaly
+ ('median_before_anomaly', 'float64'), # median of values before anomaly
+ ('median_after_anomaly', 'float64'), # median of values after anomaly
+ ('units', str), # unit in which values are masured ('ms')
+ ('improvement', bool), # whether anomaly is an improvement or regression
+ ('bug_id', 'int64'), # crbug id associated with this alert, 0 if missing
+ ('status', str), # one of 'ignored', 'invalid', 'triaged', 'untriaged'
+ ('bisect_status', str), # one of 'started', 'falied', 'completed'
+)
+COLUMNS = tuple(c for c, _ in COLUMN_TYPES)
+INDEX = COLUMNS[0]
+
+
+_CODE_TO_STATUS = {
+ -2: 'ignored',
+ -1: 'invalid',
+ None: 'untriaged',
+ # Any positive integer represents a bug_id and maps to a 'triaged' status.
+}
+
+
+def DataFrame(rows=None):
+ return pandas_sqlite.DataFrame(COLUMN_TYPES, index=INDEX, rows=rows)
+
+
+def _RowFromJson(data):
+ """Turn json data from an alert into a tuple with values for that record."""
+ data = data.copy() # Do not modify the original dict.
+
+ # Name fields using newer dashboard nomenclature.
+ data['test_suite'] = data.pop('testsuite')
+ raw_test = data.pop('test')
+ if '/' in raw_test:
+ data['measurement'], data['test_case'] = raw_test.split('/', 1)
+ else:
+ # Alert was on a summary metric, i.e. a summary of the measurement across
+ # multiple test cases. Therefore, no test_case is associated with it.
+ data['measurement'], data['test_case'] = raw_test, None
+ data['bot'] = '/'.join([data.pop('master'), data.pop('bot')])
+
+ # Separate bug_id from alert status.
+ data['status'] = _CODE_TO_STATUS.get(data['bug_id'], 'triaged')
+ if data['status'] == 'triaged':
+ assert data['bug_id'] > 0
+ else:
+ # pandas cannot hold both int and None values in the same series, if so the
+ # type is coerced into float; to prevent this we use 0 to denote untriaged
+ # alerts with no bug_id assigned.
+ data['bug_id'] = 0
+
+ return tuple(data[k] for k in COLUMNS)
+
+
+def DataFrameFromJson(data):
+ return DataFrame([_RowFromJson(d) for d in data['anomalies']])
diff --git a/tools/perf/cli_tools/soundwave/tables/alerts_test.py b/tools/perf/cli_tools/soundwave/tables/alerts_test.py
new file mode 100644
index 0000000..09c15ea8
--- /dev/null
+++ b/tools/perf/cli_tools/soundwave/tables/alerts_test.py
@@ -0,0 +1,101 @@
+# Copyright 2018 The Chromium 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 datetime
+import unittest
+
+from cli_tools.soundwave import tables
+from core.external_modules import pandas
+
+
+@unittest.skipIf(pandas is None, 'pandas not available')
+class TestAlerts(unittest.TestCase):
+ def testDataFrameFromJson(self):
+ data = {
+ 'anomalies': [
+ {
+ 'key': 'abc123',
+ 'timestamp': '2009-02-13T23:31:30.000',
+ 'testsuite': 'loading.mobile',
+ 'test': 'timeToFirstInteractive/Google',
+ 'master': 'ChromiumPerf',
+ 'bot': 'android-nexus5',
+ 'start_revision': 12345,
+ 'end_revision': 12543,
+ 'median_before_anomaly': 2037.18,
+ 'median_after_anomaly': 2135.540,
+ 'units': 'ms',
+ 'improvement': False,
+ 'bug_id': 55555,
+ 'bisect_status': 'started',
+ },
+ {
+ 'key': 'xyz567',
+ 'timestamp': '2009-02-13T23:31:30.000',
+ 'testsuite': 'loading.mobile',
+ 'test': 'timeToFirstInteractive/Wikipedia',
+ 'master': 'ChromiumPerf',
+ 'bot': 'android-nexus5',
+ 'start_revision': 12345,
+ 'end_revision': 12543,
+ 'median_before_anomaly': 2037.18,
+ 'median_after_anomaly': 2135.540,
+ 'units': 'ms',
+ 'improvement': False,
+ 'bug_id': None,
+ 'bisect_status': 'started',
+ }
+ ]
+ }
+ alerts = tables.alerts.DataFrameFromJson(data)
+ self.assertEqual(len(alerts), 2)
+
+ alert = alerts.loc['abc123']
+ self.assertEqual(alert['timestamp'], datetime.datetime(
+ year=2009, month=2, day=13, hour=23, minute=31, second=30))
+ self.assertEqual(alert['bot'], 'ChromiumPerf/android-nexus5')
+ self.assertEqual(alert['test_suite'], 'loading.mobile')
+ self.assertEqual(alert['test_case'], 'Google')
+ self.assertEqual(alert['measurement'], 'timeToFirstInteractive')
+ self.assertEqual(alert['bug_id'], 55555)
+ self.assertEqual(alert['status'], 'triaged')
+
+ # We expect bug_id's to be integers.
+ self.assertTrue(pandas.api.types.is_integer_dtype(alerts['bug_id'].dtype))
+
+ # Missing bug_id's become 0.
+ self.assertEqual(alerts.loc['xyz567']['bug_id'], 0)
+
+ def testDataFrameFromJson_withSummaryMetric(self):
+ data = {
+ 'anomalies': [
+ {
+ 'key': 'abc123',
+ 'timestamp': '2009-02-13T23:31:30.000',
+ 'testsuite': 'loading.mobile',
+ 'test': 'timeToFirstInteractive',
+ 'master': 'ChromiumPerf',
+ 'bot': 'android-nexus5',
+ 'start_revision': 12345,
+ 'end_revision': 12543,
+ 'median_before_anomaly': 2037.18,
+ 'median_after_anomaly': 2135.540,
+ 'units': 'ms',
+ 'improvement': False,
+ 'bug_id': 55555,
+ 'bisect_status': 'started',
+ }
+ ]
+ }
+ alerts = tables.alerts.DataFrameFromJson(data)
+ self.assertEqual(len(alerts), 1)
+
+ alert = alerts.loc['abc123']
+ self.assertEqual(alert['measurement'], 'timeToFirstInteractive')
+ self.assertIsNone(alert['test_case'])
+
+ def testDataFrameFromJson_noAlerts(self):
+ data = {'anomalies': []}
+ alerts = tables.alerts.DataFrameFromJson(data)
+ self.assertEqual(len(alerts), 0)
diff --git a/tools/perf/cli_tools/soundwave/tables/bugs.py b/tools/perf/cli_tools/soundwave/tables/bugs.py
new file mode 100644
index 0000000..9e5f5b158
--- /dev/null
+++ b/tools/perf/cli_tools/soundwave/tables/bugs.py
@@ -0,0 +1,60 @@
+# Copyright 2018 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+from cli_tools.soundwave import pandas_sqlite
+from core.external_modules import pandas
+
+
+TABLE_NAME = 'bugs'
+COLUMN_TYPES = (
+ ('id', 'int64'), # crbug number identifying this issue
+ ('summary', unicode), # issue title ('1%-5% regression in loading ...')
+ ('published', 'datetime64[ns]'), # when the issue got created
+ ('updated', 'datetime64[ns]'), # when the issue got last updated
+ ('state', str), # usually either 'open' or 'closed'
+ ('status', str), # current state of the bug ('Assigned', 'Fixed', etc.)
+ ('author', str), # email of user who created the issue
+ ('owner', str), # email of user who currently owns the issue
+ ('cc', str), # comma-separated list of users cc'ed into the issue
+ ('components', str), # comma-separated list of components ('Blink>Loader')
+ ('labels', str), # comma-separated list of labels ('Type-Bug-Regression')
+)
+COLUMNS = tuple(c for c, _ in COLUMN_TYPES)
+DATE_COLUMNS = tuple(c for c, t in COLUMN_TYPES if t == 'datetime64[ns]')
+INDEX = COLUMNS[0]
+
+
+def DataFrame(rows=None):
+ return pandas_sqlite.DataFrame(COLUMN_TYPES, index=INDEX, rows=rows)
+
+
+def _CommaSeparate(values):
+ assert isinstance(values, list)
+ if values:
+ return ','.join(values)
+ else:
+ return None
+
+
+def DataFrameFromJson(data):
+ rows = []
+ for row in data:
+ row = row['bug'].copy()
+ for key in ('cc', 'components', 'labels'):
+ row[key] = _CommaSeparate(row[key])
+ rows.append(tuple(row[k] for k in COLUMNS))
+
+ return DataFrame(rows)
+
+
+def Get(con, bug_id):
+ """Find the record for a bug_id in the given database connection.
+
+ Returns:
+ A pandas.Series with the record if found, or None otherwise.
+ """
+ df = pandas.read_sql(
+ 'SELECT * FROM %s WHERE id=?' % TABLE_NAME, con, params=(bug_id,),
+ index_col=INDEX, parse_dates=DATE_COLUMNS)
+ return df.loc[bug_id] if len(df) else None
diff --git a/tools/perf/cli_tools/soundwave/tables/bugs_test.py b/tools/perf/cli_tools/soundwave/tables/bugs_test.py
new file mode 100644
index 0000000..9d4831d
--- /dev/null
+++ b/tools/perf/cli_tools/soundwave/tables/bugs_test.py
@@ -0,0 +1,46 @@
+# Copyright 2018 The Chromium 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 datetime
+import unittest
+
+from cli_tools.soundwave import tables
+from core.external_modules import pandas
+
+
+@unittest.skipIf(pandas is None, 'pandas not available')
+class TestBugs(unittest.TestCase):
+ def testDataFrameFromJson(self):
+ data = [
+ {
+ 'bug': {
+ 'id': 12345,
+ 'summary': u'1%-\u221e% regression in loading at 123:125',
+ 'published': '2018-04-09T17:01:09',
+ 'updated': '2018-04-12T06:38:34',
+ 'state': 'closed',
+ 'status': 'Fixed',
+ 'author': 'foo@chromium.org',
+ 'owner': 'bar@chromium.org',
+ 'cc': ['baz@chromium.org', 'foo@chromium.org'],
+ 'components': [],
+ 'labels': ['Perf-Regression', 'Foo>Label'],
+ }
+ }
+ ]
+
+ bugs = tables.bugs.DataFrameFromJson(data)
+ self.assertEqual(len(bugs), 1)
+
+ bug = bugs.loc[12345] # Get bug by id.
+ self.assertEqual(bug['published'], datetime.datetime(
+ year=2018, month=4, day=9, hour=17, minute=1, second=9))
+ self.assertEqual(bug['status'], 'Fixed')
+ self.assertEqual(bug['cc'], 'baz@chromium.org,foo@chromium.org')
+ self.assertEqual(bug['components'], None)
+
+ def testDataFrameFromJson_noBugs(self):
+ data = []
+ bugs = tables.bugs.DataFrameFromJson(data)
+ self.assertEqual(len(bugs), 0)
diff --git a/tools/perf/cli_tools/soundwave/tables/timeseries.py b/tools/perf/cli_tools/soundwave/tables/timeseries.py
new file mode 100644
index 0000000..df70566
--- /dev/null
+++ b/tools/perf/cli_tools/soundwave/tables/timeseries.py
@@ -0,0 +1,175 @@
+# Copyright 2018 The Chromium 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 collections
+
+from cli_tools.soundwave import pandas_sqlite
+from core.external_modules import pandas
+
+
+TABLE_NAME = 'timeseries'
+COLUMN_TYPES = (
+ # Index columns.
+ ('test_suite', str), # benchmark name ('loading.mobile')
+ ('measurement', str), # metric name ('timeToFirstContentfulPaint')
+ ('bot', str), # master/builder name ('ChromiumPerf.android-nexus5')
+ ('test_case', str), # story name ('Wikipedia')
+ ('point_id', 'int64'), # monotonically increasing id for time series axis
+ # Other columns.
+ ('value', 'float64'), # value recorded for test_path at given point_id
+ ('timestamp', 'datetime64[ns]'), # when the value got stored on dashboard
+ ('commit_pos', 'int64'), # chromium commit position
+ ('chromium_rev', str), # git hash of chromium revision
+ ('clank_rev', str), # git hash of clank revision
+ ('trace_url', str), # URL to a sample trace.
+ ('units', str), # unit of measurement (e.g. 'ms', 'bytes')
+ ('improvement_direction', str), # good direction ('up', 'down', 'unknown')
+)
+COLUMNS = tuple(c for c, _ in COLUMN_TYPES)
+INDEX = COLUMNS[:5]
+
+# Copied from https://goo.gl/DzGYpW.
+_CODE_TO_IMPROVEMENT_DIRECTION = {
+ 0: 'up',
+ 1: 'down',
+}
+
+
+TEST_PATH_PARTS = (
+ 'master', 'builder', 'test_suite', 'measurement', 'test_case')
+
+# Query template to find all data points of a given test_path (i.e. fixed
+# test_suite, measurement, bot, and test_case values).
+_QUERY_TIME_SERIES = (
+ 'SELECT * FROM %s WHERE %s'
+ % (TABLE_NAME, ' AND '.join('%s=?' % c for c in INDEX[:-1])))
+
+
+# Required columns to request from /timeseries2 API.
+_TIMESERIES2_COLS = [
+ 'revision',
+ 'revisions',
+ 'avg',
+ 'timestamp',
+ 'annotations']
+
+
+class Key(collections.namedtuple('Key', INDEX[:-1])):
+ """Uniquely identifies a single timeseries."""
+
+ @classmethod
+ def FromDict(cls, *args, **kwargs):
+ kwargs = dict(*args, **kwargs)
+ kwargs.setdefault('test_case', '') # test_case is optional.
+ return cls(**kwargs)
+
+ def AsDict(self):
+ return dict(zip(self._fields, self))
+
+ def AsApiParams(self):
+ """Return a dict with params for a /timeseries2 API request."""
+ params = self.AsDict()
+ if not params['test_case']:
+ del params['test_case'] # test_case is optional.
+ params['columns'] = ','.join(_TIMESERIES2_COLS)
+ return params
+
+
+def DataFrame(rows=None):
+ return pandas_sqlite.DataFrame(COLUMN_TYPES, index=INDEX, rows=rows)
+
+
+def _ParseIntValue(value, on_error=-1):
+ # Try to parse as int and, in case of error, return a pre-defined value.
+ try:
+ return int(value)
+ except StandardError:
+ return on_error
+
+
+def _ParseConfigFromTestPath(test_path):
+ if isinstance(test_path, Key):
+ return test_path.AsDict()
+
+ values = test_path.split('/', len(TEST_PATH_PARTS) - 1)
+ if len(values) < len(TEST_PATH_PARTS):
+ values.append('') # Possibly missing test_case.
+ if len(values) != len(TEST_PATH_PARTS):
+ raise ValueError(test_path)
+ config = dict(zip(TEST_PATH_PARTS, values))
+ config['bot'] = '%s/%s' % (config.pop('master'), config.pop('builder'))
+ return config
+
+
+def DataFrameFromJson(test_path, data):
+ if isinstance(test_path, Key):
+ return _DataFrameFromJsonV2(test_path, data)
+ else:
+ # TODO(crbug.com/907121): Remove when we can switch entirely to v2.
+ return _DataFrameFromJsonV1(test_path, data)
+
+
+def _DataFrameFromJsonV2(ts_key, data):
+ rows = []
+ for point in data['data']:
+ point = dict(zip(_TIMESERIES2_COLS, point))
+ rows.append(ts_key + (
+ point['revision'], # point_id
+ point['avg'], # value
+ point['timestamp'], # timestamp
+ _ParseIntValue(point['revisions']['r_commit_pos']), # commit_pos
+ point['revisions'].get('r_chromium'), # chromium_rev
+ point['revisions'].get('r_clank'), # clank_rev
+ point['annotations'].get('a_tracing_uri'), # trace_url
+ data['units'], # units
+ data['improvement_direction'], # improvement_direction
+ ))
+ return DataFrame(rows)
+
+
+def _DataFrameFromJsonV1(test_path, data):
+ assert test_path == data['test_path']
+ config = _ParseConfigFromTestPath(data['test_path'])
+ config['improvement_direction'] = _CODE_TO_IMPROVEMENT_DIRECTION.get(
+ data['improvement_direction'], 'unknown')
+ timeseries = data['timeseries']
+ # The first element in timeseries list contains header with column names.
+ header = timeseries[0]
+ rows = []
+
+ # Remaining elements contain the values for each row.
+ for values in timeseries[1:]:
+ row = config.copy()
+ row.update(zip(header, values))
+ row['point_id'] = row['revision']
+ row['commit_pos'] = _ParseIntValue(row['r_commit_pos'])
+ row['chromium_rev'] = row.get('r_chromium')
+ row['clank_rev'] = row.get('r_clank', None)
+ rows.append(tuple(row.get(k) for k in COLUMNS))
+
+ return DataFrame(rows)
+
+
+def GetTimeSeries(con, test_path, extra_cond=None):
+ """Get the records for all data points on the given test_path.
+
+ Returns:
+ A pandas.DataFrame with all records found.
+ """
+ config = _ParseConfigFromTestPath(test_path)
+ params = tuple(config[c] for c in INDEX[:-1])
+ query = _QUERY_TIME_SERIES
+ if extra_cond is not None:
+ query = ' '.join([query, extra_cond])
+ return pandas.read_sql(query, con, params=params, parse_dates=['timestamp'])
+
+
+def GetMostRecentPoint(con, test_path):
+ """Find the record for the most recent data point on the given test_path.
+
+ Returns:
+ A pandas.Series with the record if found, or None otherwise.
+ """
+ df = GetTimeSeries(con, test_path, 'ORDER BY timestamp DESC LIMIT 1')
+ return df.iloc[0] if not df.empty else None
diff --git a/tools/perf/cli_tools/soundwave/tables/timeseries_test.py b/tools/perf/cli_tools/soundwave/tables/timeseries_test.py
new file mode 100644
index 0000000..c7f0d5e
--- /dev/null
+++ b/tools/perf/cli_tools/soundwave/tables/timeseries_test.py
@@ -0,0 +1,247 @@
+# Copyright 2018 The Chromium 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 datetime
+import unittest
+
+from cli_tools.soundwave import pandas_sqlite
+from cli_tools.soundwave import tables
+from core.external_modules import pandas
+
+
+def SamplePoint(point_id, value, timestamp=None, missing_commit_pos=False):
+ """Build a sample point as returned by timeseries2 API."""
+ revisions = {
+ 'r_commit_pos': str(point_id),
+ 'r_chromium': 'chromium@%d' % point_id,
+ }
+ annotations = {
+ 'a_tracing_uri': 'http://example.com/trace/%d' % point_id
+ }
+
+ if timestamp is None:
+ timestamp = datetime.datetime.utcfromtimestamp(
+ 1234567890 + 60 * point_id).isoformat()
+ if missing_commit_pos:
+ # Some data points have a missing commit position.
+ revisions['r_commit_pos'] = None
+ return [
+ point_id,
+ revisions,
+ value,
+ timestamp,
+ annotations,
+ ]
+
+
+class TestKey(unittest.TestCase):
+ def testKeyFromDict_typical(self):
+ key1 = tables.timeseries.Key.FromDict({
+ 'test_suite': 'loading.mobile',
+ 'bot': 'ChromiumPerf:android-nexus5',
+ 'measurement': 'timeToFirstInteractive',
+ 'test_case': 'Wikipedia'})
+ key2 = tables.timeseries.Key(
+ test_suite='loading.mobile',
+ measurement='timeToFirstInteractive',
+ bot='ChromiumPerf:android-nexus5',
+ test_case='Wikipedia')
+ self.assertEqual(key1, key2)
+
+ def testKeyFromDict_defaultTestCase(self):
+ key1 = tables.timeseries.Key.FromDict({
+ 'test_suite': 'loading.mobile',
+ 'bot': 'ChromiumPerf:android-nexus5',
+ 'measurement': 'timeToFirstInteractive'})
+ key2 = tables.timeseries.Key(
+ test_suite='loading.mobile',
+ measurement='timeToFirstInteractive',
+ bot='ChromiumPerf:android-nexus5',
+ test_case='')
+ self.assertEqual(key1, key2)
+
+ def testKeyFromDict_invalidArgsRaises(self):
+ with self.assertRaises(TypeError):
+ tables.timeseries.Key.FromDict({
+ 'test_suite': 'loading.mobile',
+ 'bot': 'ChromiumPerf:android-nexus5'})
+
+
+@unittest.skipIf(pandas is None, 'pandas not available')
+class TestTimeSeries(unittest.TestCase):
+ def testDataFrameFromJsonV1(self):
+ test_path = ('ChromiumPerf/android-nexus5/loading.mobile'
+ '/timeToFirstInteractive/PageSet/Google')
+ data = {
+ 'test_path': test_path,
+ 'improvement_direction': 1,
+ 'timeseries': [
+ ['revision', 'value', 'timestamp', 'r_commit_pos', 'r_chromium'],
+ [547397, 2300.3, '2018-04-01T14:16:32.000', '547397', 'adb123'],
+ [547398, 2750.9, '2018-04-01T18:24:04.000', '547398', 'cde456'],
+ [547423, 2342.2, '2018-04-02T02:19:00.000', '547423', 'fab789'],
+ # Some timeseries have a missing commit position.
+ [547836, 2402.5, '2018-04-02T02:20:00.000', None, 'acf147'],
+ ]
+ }
+
+ timeseries = tables.timeseries.DataFrameFromJson(test_path, data)
+ # Check the integrity of the index: there should be no duplicates.
+ self.assertFalse(timeseries.index.duplicated().any())
+ self.assertEqual(len(timeseries), 4)
+
+ # Check values on the first point of the series.
+ point = timeseries.reset_index().iloc[0]
+ self.assertEqual(point['test_suite'], 'loading.mobile')
+ self.assertEqual(point['measurement'], 'timeToFirstInteractive')
+ self.assertEqual(point['bot'], 'ChromiumPerf/android-nexus5')
+ self.assertEqual(point['test_case'], 'PageSet/Google')
+ self.assertEqual(point['improvement_direction'], 'down')
+ self.assertEqual(point['point_id'], 547397)
+ self.assertEqual(point['value'], 2300.3)
+ self.assertEqual(point['timestamp'], datetime.datetime(
+ year=2018, month=4, day=1, hour=14, minute=16, second=32))
+ self.assertEqual(point['commit_pos'], 547397)
+ self.assertEqual(point['chromium_rev'], 'adb123')
+ self.assertEqual(point['clank_rev'], None)
+
+ def testDataFrameFromJsonV2(self):
+ test_path = tables.timeseries.Key(
+ test_suite='loading.mobile',
+ measurement='timeToFirstInteractive',
+ bot='ChromiumPerf:android-nexus5',
+ test_case='Wikipedia')
+ data = {
+ 'improvement_direction': 'down',
+ 'units': 'ms',
+ 'data': [
+ SamplePoint(547397, 2300.3, timestamp='2018-04-01T14:16:32.000'),
+ SamplePoint(547398, 2750.9),
+ SamplePoint(547423, 2342.2),
+ SamplePoint(547836, 2402.5, missing_commit_pos=True),
+ ]
+ }
+
+ timeseries = tables.timeseries.DataFrameFromJson(test_path, data)
+ # Check the integrity of the index: there should be no duplicates.
+ self.assertFalse(timeseries.index.duplicated().any())
+ self.assertEqual(len(timeseries), 4)
+
+ # Check values on the first point of the series.
+ point = timeseries.reset_index().iloc[0]
+ self.assertEqual(point['test_suite'], 'loading.mobile')
+ self.assertEqual(point['measurement'], 'timeToFirstInteractive')
+ self.assertEqual(point['bot'], 'ChromiumPerf:android-nexus5')
+ self.assertEqual(point['test_case'], 'Wikipedia')
+ self.assertEqual(point['improvement_direction'], 'down')
+ self.assertEqual(point['units'], 'ms')
+ self.assertEqual(point['point_id'], 547397)
+ self.assertEqual(point['value'], 2300.3)
+ self.assertEqual(point['timestamp'], datetime.datetime(
+ year=2018, month=4, day=1, hour=14, minute=16, second=32))
+ self.assertEqual(point['commit_pos'], 547397)
+ self.assertEqual(point['chromium_rev'], 'chromium@547397')
+ self.assertEqual(point['clank_rev'], None)
+
+ def testDataFrameFromJson_withSummaryMetric(self):
+ test_path = tables.timeseries.Key(
+ test_suite='loading.mobile',
+ measurement='timeToFirstInteractive',
+ bot='ChromiumPerf:android-nexus5',
+ test_case='')
+ data = {
+ 'improvement_direction': 'down',
+ 'units': 'ms',
+ 'data': [
+ SamplePoint(547397, 2300.3),
+ SamplePoint(547398, 2750.9),
+ ],
+ }
+
+ timeseries = tables.timeseries.DataFrameFromJson(
+ test_path, data).reset_index()
+ self.assertTrue((timeseries['test_case'] == '').all())
+
+ def testGetTimeSeries(self):
+ test_path = tables.timeseries.Key(
+ test_suite='loading.mobile',
+ measurement='timeToFirstInteractive',
+ bot='ChromiumPerf:android-nexus5',
+ test_case='Wikipedia')
+ data = {
+ 'improvement_direction': 'down',
+ 'units': 'ms',
+ 'data': [
+ SamplePoint(547397, 2300.3),
+ SamplePoint(547398, 2750.9),
+ SamplePoint(547423, 2342.2),
+ ]
+ }
+
+ timeseries_in = tables.timeseries.DataFrameFromJson(test_path, data)
+ with tables.DbSession(':memory:') as con:
+ pandas_sqlite.InsertOrReplaceRecords(con, 'timeseries', timeseries_in)
+ timeseries_out = tables.timeseries.GetTimeSeries(con, test_path)
+ # Both DataFrame's should be equal, except the one we get out of the db
+ # does not have an index defined.
+ timeseries_in = timeseries_in.reset_index()
+ self.assertTrue(timeseries_in.equals(timeseries_out))
+
+ def testGetTimeSeries_withSummaryMetric(self):
+ test_path = tables.timeseries.Key(
+ test_suite='loading.mobile',
+ measurement='timeToFirstInteractive',
+ bot='ChromiumPerf:android-nexus5',
+ test_case='')
+ data = {
+ 'improvement_direction': 'down',
+ 'units': 'ms',
+ 'data': [
+ SamplePoint(547397, 2300.3),
+ SamplePoint(547398, 2750.9),
+ SamplePoint(547423, 2342.2),
+ ]
+ }
+
+ timeseries_in = tables.timeseries.DataFrameFromJson(test_path, data)
+ with tables.DbSession(':memory:') as con:
+ pandas_sqlite.InsertOrReplaceRecords(con, 'timeseries', timeseries_in)
+ timeseries_out = tables.timeseries.GetTimeSeries(con, test_path)
+ # Both DataFrame's should be equal, except the one we get out of the db
+ # does not have an index defined.
+ timeseries_in = timeseries_in.reset_index()
+ self.assertTrue(timeseries_in.equals(timeseries_out))
+
+ def testGetMostRecentPoint_success(self):
+ test_path = tables.timeseries.Key(
+ test_suite='loading.mobile',
+ measurement='timeToFirstInteractive',
+ bot='ChromiumPerf:android-nexus5',
+ test_case='Wikipedia')
+ data = {
+ 'improvement_direction': 'down',
+ 'units': 'ms',
+ 'data': [
+ SamplePoint(547397, 2300.3),
+ SamplePoint(547398, 2750.9),
+ SamplePoint(547423, 2342.2),
+ ]
+ }
+
+ timeseries = tables.timeseries.DataFrameFromJson(test_path, data)
+ with tables.DbSession(':memory:') as con:
+ pandas_sqlite.InsertOrReplaceRecords(con, 'timeseries', timeseries)
+ point = tables.timeseries.GetMostRecentPoint(con, test_path)
+ self.assertEqual(point['point_id'], 547423)
+
+ def testGetMostRecentPoint_empty(self):
+ test_path = tables.timeseries.Key(
+ test_suite='loading.mobile',
+ measurement='timeToFirstInteractive',
+ bot='ChromiumPerf:android-nexus5',
+ test_case='Wikipedia')
+
+ with tables.DbSession(':memory:') as con:
+ point = tables.timeseries.GetMostRecentPoint(con, test_path)
+ self.assertIsNone(point)
diff --git a/tools/perf/cli_tools/soundwave/worker_pool.py b/tools/perf/cli_tools/soundwave/worker_pool.py
new file mode 100644
index 0000000..ff035a3
--- /dev/null
+++ b/tools/perf/cli_tools/soundwave/worker_pool.py
@@ -0,0 +1,82 @@
+# Copyright 2018 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+"""
+Use a pool of workers to concurrently process a sequence of items.
+
+Example usage:
+
+ from soundwave import worker_pool
+
+ def MyWorker(args):
+ # This is called once for each worker to initialize it.
+
+ def Process(item):
+ # This will be called once for each item processed by this worker.
+
+ # Hook up the Process function so the worker_pool module can find it.
+ worker_pool.Process = Process
+
+ args.processes = 10 # Set number of processes to be used by the pool.
+ worker_pool.Run('This might take a while: ', MyWorker, args, items)
+"""
+import logging
+import multiprocessing
+import sys
+
+from core.external_modules import pandas
+
+
+# Worker implementations override this value
+Process = NotImplemented # pylint: disable=invalid-name
+
+
+def ProgressIndicator(label, iterable, stream=None):
+ if stream is None:
+ stream = sys.stdout
+ stream.write(label)
+ stream.flush()
+ for _ in iterable:
+ stream.write('.')
+ stream.flush()
+ stream.write('\n')
+ stream.flush()
+
+
+def Run(label, worker, args, items, stream=None):
+ """Use a pool of workers to concurrently process a sequence of items.
+
+ Args:
+ label: A string displayed by the progress indicator when the job starts.
+ worker: A function with the worker implementation. See example above.
+ args: An argparse.Namespace() object used to initialize the workers. The
+ value of args.processes is the number of processes used by the pool.
+ items: An iterable with items to process by the pool of workers.
+ stream: A file-like object for the progress indicator output, defaults to
+ sys.stdout.
+
+ Returns:
+ Total time in seconds spent by the pool to process all items.
+ """
+ pool = multiprocessing.Pool(
+ processes=args.processes, initializer=worker, initargs=(args,))
+ time_started = pandas.Timestamp.utcnow()
+ try:
+ ProgressIndicator(label, pool.imap_unordered(_Worker, items), stream=stream)
+ time_finished = pandas.Timestamp.utcnow()
+ finally:
+ # Ensure resources (e.g. db connections from workers) are freed up.
+ pool.terminate()
+ pool.join()
+ return (time_finished - time_started).total_seconds()
+
+
+def _Worker(item):
+ try:
+ Process(item) # pylint: disable=not-callable
+ except KeyboardInterrupt:
+ pass
+ except:
+ logging.exception('Worker failed with exception')
+ raise
diff --git a/tools/perf/cli_tools/soundwave/worker_pool_test.py b/tools/perf/cli_tools/soundwave/worker_pool_test.py
new file mode 100644
index 0000000..47197b3d
--- /dev/null
+++ b/tools/perf/cli_tools/soundwave/worker_pool_test.py
@@ -0,0 +1,50 @@
+# Copyright 2018 The Chromium 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 argparse
+import os
+import shutil
+import sqlite3
+import tempfile
+import unittest
+
+from cli_tools.soundwave import pandas_sqlite
+from cli_tools.soundwave import worker_pool
+from core.external_modules import pandas
+
+
+def TestWorker(args):
+ con = sqlite3.connect(args.database_file)
+
+ def Process(item):
+ # Add item to the database.
+ df = pandas.DataFrame({'item': [item]})
+ df.to_sql('items', con, index=False, if_exists='append')
+
+ worker_pool.Process = Process
+
+
+@unittest.skipIf(pandas is None, 'pandas not available')
+class TestWorkerPool(unittest.TestCase):
+ def testWorkerPoolRun(self):
+ tempdir = tempfile.mkdtemp()
+ try:
+ args = argparse.Namespace()
+ args.database_file = os.path.join(tempdir, 'test.db')
+ args.processes = 3
+ schema = pandas_sqlite.DataFrame([('item', int)])
+ items = range(20) # We'll write these in the database.
+ con = sqlite3.connect(args.database_file)
+ try:
+ pandas_sqlite.CreateTableIfNotExists(con, 'items', schema)
+ with open(os.devnull, 'w') as devnull:
+ worker_pool.Run(
+ 'Processing:', TestWorker, args, items, stream=devnull)
+ df = pandas.read_sql('SELECT * FROM items', con)
+ # Check all of our items were written.
+ self.assertItemsEqual(df['item'], items)
+ finally:
+ con.close()
+ finally:
+ shutil.rmtree(tempdir)
diff --git a/tools/perf/cli_tools/update_wpr/update_wpr.py b/tools/perf/cli_tools/update_wpr/update_wpr.py
index 2f8d00e..be33cf2 100644
--- a/tools/perf/cli_tools/update_wpr/update_wpr.py
+++ b/tools/perf/cli_tools/update_wpr/update_wpr.py
@@ -18,12 +18,9 @@
import webbrowser
from core import cli_helpers
-from core import path_util
-
-path_util.AddSoundwaveToPath()
-from services import luci_auth # pylint: disable=import-error
-from services import pinpoint_service # pylint: disable=import-error
-from services import request # pylint: disable=import-error
+from core.services import luci_auth
+from core.services import pinpoint_service
+from core.services import request
SRC_ROOT = os.path.abspath(
diff --git a/tools/perf/cli_tools/update_wpr/update_wpr_unittest.py b/tools/perf/cli_tools/update_wpr/update_wpr_unittest.py
index 65a82bf..489c754 100644
--- a/tools/perf/cli_tools/update_wpr/update_wpr_unittest.py
+++ b/tools/perf/cli_tools/update_wpr/update_wpr_unittest.py
@@ -9,11 +9,8 @@
import mock
-from core import path_util
from cli_tools.update_wpr import update_wpr
-
-path_util.AddSoundwaveToPath()
-from services import request # pylint: disable=import-error
+from core.services import request
WPR_UPDATER = 'cli_tools.update_wpr.update_wpr.'
@@ -325,7 +322,7 @@
WPR_UPDATER + 'WprUpdater._GetBranchIssueUrl',
return_value='<issue-url>').start()
new_job = mock.patch(
- 'services.pinpoint_service.NewJob',
+ 'core.services.pinpoint_service.NewJob',
return_value={'jobUrl': '<url>'}).start()
self.assertEqual(
self.wpr_updater.StartPinpointJobs(),
@@ -348,8 +345,9 @@
return_value='<issue-url>').start()
self.wpr_updater.device_id = '<serial>'
new_job = mock.patch(
- 'services.pinpoint_service.NewJob', side_effect=request.ServerError(
- mock.Mock(), mock.Mock(status=500), '')).start()
+ 'core.services.pinpoint_service.NewJob',
+ side_effect=request.ServerError(
+ mock.Mock(), mock.Mock(status=500), '')).start()
self.assertEqual(
self.wpr_updater.StartPinpointJobs(['<config>']), ([], ['<config>']))
new_job.assert_called_once_with(
diff --git a/tools/perf/core/cli_utils.py b/tools/perf/core/cli_utils.py
new file mode 100644
index 0000000..44b2967
--- /dev/null
+++ b/tools/perf/core/cli_utils.py
@@ -0,0 +1,34 @@
+# Copyright 2018 The Chromium 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 datetime
+import logging
+
+from core import gsutil
+
+
+def VerboseLevel(count):
+ if count == 0:
+ return logging.WARNING
+ elif count == 1:
+ return logging.INFO
+ else:
+ return logging.DEBUG
+
+
+def ConfigureLogging(verbose_count):
+ logging.basicConfig(level=VerboseLevel(verbose_count))
+
+
+def OpenWrite(filepath):
+ """Open file for writing, optionally supporting cloud storage paths."""
+ if filepath.startswith('gs://'):
+ return gsutil.OpenWrite(filepath)
+ else:
+ return open(filepath, 'w')
+
+
+def DaysAgoToTimestamp(num_days):
+ """Return an ISO formatted timestamp for a number of days ago."""
+ timestamp = datetime.datetime.utcnow() - datetime.timedelta(days=num_days)
+ return timestamp.isoformat()
diff --git a/tools/perf/core/gsutil.py b/tools/perf/core/gsutil.py
new file mode 100644
index 0000000..d75e754
--- /dev/null
+++ b/tools/perf/core/gsutil.py
@@ -0,0 +1,33 @@
+# Copyright 2018 The Chromium 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 contextlib
+import os
+import shutil
+import subprocess
+import tempfile
+
+
+GSUTIL_BIN = 'gsutil'
+
+
+def Copy(source_path, dest_path):
+ subprocess.check_call([GSUTIL_BIN, 'cp', source_path, dest_path])
+
+
+@contextlib.contextmanager
+def OpenWrite(cloudpath):
+ """Allows to "open" a cloud storage path for writing.
+
+ Works by opening a local temporary file for writing, then copying the file
+ to cloud storage when writing is done.
+ """
+ tempdir = tempfile.mkdtemp()
+ try:
+ localpath = os.path.join(tempdir, os.path.basename(cloudpath))
+ with open(localpath, 'w') as f:
+ yield f
+ Copy(localpath, cloudpath)
+ finally:
+ shutil.rmtree(tempdir)
diff --git a/tools/perf/core/path_util.py b/tools/perf/core/path_util.py
index 39c5ae4..11cec5d 100644
--- a/tools/perf/core/path_util.py
+++ b/tools/perf/core/path_util.py
@@ -41,12 +41,6 @@
return os.path.join(GetChromiumSrcDir(), 'build', 'android')
-def GetSoundwaveDir():
- return os.path.join(
- GetChromiumSrcDir(), 'third_party', 'catapult', 'experimental',
- 'soundwave')
-
-
def AddTelemetryToPath():
telemetry_path = GetTelemetryDir()
if telemetry_path not in sys.path:
@@ -66,13 +60,6 @@
sys.path.insert(1, py_utils_dir)
-def AddSoundwaveToPath():
- AddPyUtilsToPath() # needed by some soundwave scripts
- soundwave_services_path = GetSoundwaveDir()
- if soundwave_services_path not in sys.path:
- sys.path.insert(1, soundwave_services_path)
-
-
def GetWprDir():
return os.path.join(
GetChromiumSrcDir(), 'third_party', 'catapult', 'telemetry',
diff --git a/tools/perf/core/services/__init__.py b/tools/perf/core/services/__init__.py
new file mode 100644
index 0000000..1adf20d2
--- /dev/null
+++ b/tools/perf/core/services/__init__.py
@@ -0,0 +1,3 @@
+# Copyright 2018 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
diff --git a/tools/perf/core/services/buildbucket_service.py b/tools/perf/core/services/buildbucket_service.py
new file mode 100644
index 0000000..4d36f6d
--- /dev/null
+++ b/tools/perf/core/services/buildbucket_service.py
@@ -0,0 +1,66 @@
+# Copyright 2019 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+"""Make requests to the Buildbucket RPC API.
+
+For more details on the API see: go/buildbucket-rpc
+"""
+
+from core.services import request
+
+
+SERVICE_URL = 'https://cr-buildbucket.appspot.com/prpc/buildbucket.v2.Builds/'
+
+
+def Request(method, **kwargs):
+ """Send a request to some buildbucket service method."""
+ kwargs.setdefault('use_auth', True)
+ kwargs.setdefault('method', 'POST')
+ kwargs.setdefault('content_type', 'json')
+ kwargs.setdefault('accept', 'json')
+ return request.Request(SERVICE_URL + method, **kwargs)
+
+
+def GetBuild(project, bucket, builder, build_number):
+ """Get the status of a build by its build number.
+
+ Args:
+ project: The LUCI project name (e.g. 'chromium').
+ bucket: The LUCI bucket name (e.g. 'ci' or 'try').
+ builder: The builder name (e.g. 'linux_chromium_rel_ng').
+ build_number: An int with the build number to get.
+ """
+ return Request('GetBuild', data={
+ 'builder': {
+ 'project': project,
+ 'bucket': bucket,
+ 'builder': builder,
+ },
+ 'buildNumber': build_number,
+ })
+
+
+def GetBuilds(project, bucket, builder, only_completed=True):
+ """Get a list of recent builds from a given builder.
+
+ Args:
+ project: The LUCI project name (e.g. 'chromium').
+ bucket: The LUCI bucket name (e.g. 'ci' or 'try').
+ builder: The builder name (e.g. 'linux_chromium_rel_ng').
+ only_completed: An optional bool to indicate whehter builds that have
+ not yet finished should be included in the results. The default is to
+ include only completed builds.
+ """
+ data = {
+ 'predicate': {
+ 'builder': {
+ 'project': project,
+ 'bucket': bucket,
+ 'builder': builder
+ }
+ }
+ }
+ if only_completed:
+ data['predicate']['status'] = 'ENDED_MASK'
+ return Request('SearchBuilds', data=data)
diff --git a/tools/perf/core/services/buildbucket_service_test.py b/tools/perf/core/services/buildbucket_service_test.py
new file mode 100644
index 0000000..02946b4
--- /dev/null
+++ b/tools/perf/core/services/buildbucket_service_test.py
@@ -0,0 +1,69 @@
+# Copyright 2018 The Chromium 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 unittest
+
+import mock
+
+from core.services import buildbucket_service
+
+
+class TestBuildbucketApi(unittest.TestCase):
+ def setUp(self):
+ self.mock_request = mock.patch('core.services.request.Request').start()
+ self.mock_request.return_value = 'OK'
+
+ def tearDown(self):
+ mock.patch.stopall()
+
+ def testGetBuild(self):
+ self.assertEqual(buildbucket_service.GetBuild(
+ 'chromium', 'try', 'linux_chromium_rel_ng', 227278), 'OK')
+ self.mock_request.assert_called_once_with(
+ buildbucket_service.SERVICE_URL + 'GetBuild', method='POST',
+ use_auth=True, content_type='json', accept='json',
+ data={
+ 'builder': {
+ 'project': 'chromium',
+ 'bucket': 'try',
+ 'builder': 'linux_chromium_rel_ng',
+ },
+ 'buildNumber': 227278
+ })
+
+ def testGetBuilds(self):
+ self.assertEqual(buildbucket_service.GetBuilds(
+ 'chromium', 'try', 'linux_chromium_rel_ng'), 'OK')
+ self.mock_request.assert_called_once_with(
+ buildbucket_service.SERVICE_URL + 'SearchBuilds', method='POST',
+ use_auth=True, content_type='json', accept='json',
+ data={
+ 'predicate': {
+ 'builder': {
+ 'project': 'chromium',
+ 'bucket': 'try',
+ 'builder': 'linux_chromium_rel_ng',
+ },
+ 'status': 'ENDED_MASK'
+ }
+ })
+
+ def testGetBuildsIncludeUnfinished(self):
+ self.assertEqual(
+ buildbucket_service.GetBuilds(
+ 'chromium', 'try', 'linux_chromium_rel_ng',
+ only_completed=False),
+ 'OK')
+ self.mock_request.assert_called_once_with(
+ buildbucket_service.SERVICE_URL + 'SearchBuilds', method='POST',
+ use_auth=True, content_type='json', accept='json',
+ data={
+ 'predicate': {
+ 'builder': {
+ 'project': 'chromium',
+ 'bucket': 'try',
+ 'builder': 'linux_chromium_rel_ng',
+ }
+ }
+ })
diff --git a/tools/perf/core/services/dashboard_service.py b/tools/perf/core/services/dashboard_service.py
new file mode 100644
index 0000000..0bd39a6
--- /dev/null
+++ b/tools/perf/core/services/dashboard_service.py
@@ -0,0 +1,140 @@
+# Copyright 2018 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+"""Make requests to the Chrome Perf Dashboard API.
+
+For more details on the API see:
+https://chromium.googlesource.com/catapult.git/+/HEAD/dashboard/dashboard/api/README.md
+"""
+
+import urllib
+
+from core.services import request
+
+SERVICE_URL = 'https://chromeperf.appspot.com/api'
+
+
+def Request(endpoint, **kwargs):
+ """Send a request to some dashboard service endpoint."""
+ kwargs.setdefault('use_auth', True)
+ kwargs.setdefault('method', 'POST')
+ kwargs.setdefault('accept', 'json')
+ return request.Request(SERVICE_URL + endpoint, **kwargs)
+
+
+def Describe(test_suite):
+ """Obtain information about a given test_suite.
+
+ Args:
+ test_suite: A string with the name of the test suite.
+
+ Returns:
+ A dict with information about: bots, caseTags, cases, and measurements.
+ """
+ return Request('/describe', params={'test_suite': test_suite})
+
+
+def Timeseries2(**kwargs):
+ """Get timeseries data for a particular test path.
+
+ Args:
+ test_suite: A string with the test suite or benchmark name.
+ measurement: A string with the metric name, e.g. timeToFirstContentfulPaint.
+ bot: A string with the bot name, usually of the form 'master:builder'.
+ columns: A string with a comma separated list of column names to retrieve;
+ may contain: revision, avg, std, count, max, min, sum, revisions,
+ timestamp, alert, histogram, diagnostics.
+ test_case: An optional string with the name of a test case or story.
+ **kwargs: For other options and full details see the API docs.
+
+ Returns:
+ A dict with timeseries data, alerts, Histograms, and SparseDiagnostics.
+
+ Raises:
+ TypeError if any required arguments are missing.
+ KeyError if the timeseries is not found.
+ """
+ for col in ('test_suite', 'measurement', 'bot', 'columns'):
+ if col not in kwargs:
+ raise TypeError('Missing required argument: %s' % col)
+ try:
+ return Request('/timeseries2', params=kwargs)
+ except request.ClientError as exc:
+ if exc.response.status == 404:
+ raise KeyError('Timeseries not found')
+ raise # Re-raise the original exception.
+
+
+def Timeseries(test_path, days=30):
+ """Get timeseries for the given test path.
+
+ TODO(crbug.com/907121): Remove when no longer needed.
+
+ Args:
+ test_path: test path to get timeseries for.
+ days: Number of days to get data points for.
+
+ Returns:
+ A dict with timeseries data for the given test_path
+
+ Raises:
+ KeyError if the test_path is not found.
+ """
+ try:
+ return Request(
+ '/timeseries/%s' % urllib.quote(test_path), params={'num_days': days})
+ except request.ClientError as exc:
+ if 'Invalid test_path' in exc.json['error']:
+ raise KeyError(test_path)
+ else:
+ raise
+
+
+def ListTestPaths(test_suite, sheriff):
+ """Lists test paths for the given test_suite.
+
+ TODO(crbug.com/907121): Remove when no longer needed.
+
+ Args:
+ test_suite: String with test suite to get paths for.
+ sheriff: Include only test paths monitored by the given sheriff rotation,
+ use 'all' to return all test paths regardless of rotation.
+
+ Returns:
+ A list of test paths. Ex. ['TestPath1', 'TestPath2']
+ """
+ return Request(
+ '/list_timeseries/%s' % test_suite, params={'sheriff': sheriff})
+
+
+def Bugs(bug_id):
+ """Get all the information about a given bug id."""
+ return Request('/bugs/%d' % bug_id)
+
+
+def IterAlerts(**kwargs):
+ """Returns alerts matching the supplied query parameters.
+
+ The response for the dashboard may be returned in multiple chunks, this
+ function will take care of following `next_cursor`s in responses and
+ iterate over all the chunks.
+
+ Args:
+ test_suite: Match alerts on a given test suite (benchmark).
+ sheriff: Match only alerts of a given sheriff rotation.
+ min_timestamp, max_timestamp: Match only alerts on a given time range.
+ limit: Max number of responses per chunk (defaults to 1000).
+ **kwargs: See API docs for other possible query params.
+
+ Yields:
+ Data for all the matching alerts in chunks.
+ """
+ kwargs.setdefault('limit', 1000)
+ while True:
+ response = Request('/alerts', params=kwargs)
+ yield response
+ if 'next_cursor' in response:
+ kwargs['cursor'] = response['next_cursor']
+ else:
+ return
diff --git a/tools/perf/core/services/dashboard_service_test.py b/tools/perf/core/services/dashboard_service_test.py
new file mode 100644
index 0000000..208ba44
--- /dev/null
+++ b/tools/perf/core/services/dashboard_service_test.py
@@ -0,0 +1,115 @@
+# Copyright 2018 The Chromium 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 unittest
+
+import httplib2
+import mock
+
+from core.services import dashboard_service
+from core.services import request
+
+
+def TestResponse(code, content):
+ def Request(url, *args, **kwargs):
+ del args # Unused.
+ del kwargs # Unused.
+ response = httplib2.Response({'status': str(code)})
+ if code != 200:
+ raise request.BuildRequestError(url, response, content)
+ else:
+ return content
+ return Request
+
+
+class TestDashboardApi(unittest.TestCase):
+ def setUp(self):
+ self.mock_request = mock.patch('core.services.request.Request').start()
+ self.mock_request.side_effect = TestResponse(200, 'OK')
+
+ def tearDown(self):
+ mock.patch.stopall()
+
+ def testDescribe(self):
+ self.assertEqual(dashboard_service.Describe('my_test'), 'OK')
+ self.mock_request.assert_called_once_with(
+ dashboard_service.SERVICE_URL + '/describe', method='POST',
+ params={'test_suite': 'my_test'}, use_auth=True, accept='json')
+
+ def testListTestPaths(self):
+ self.assertEqual(
+ dashboard_service.ListTestPaths('my_test', 'a_rotation'), 'OK')
+ self.mock_request.assert_called_once_with(
+ dashboard_service.SERVICE_URL + '/list_timeseries/my_test',
+ method='POST', params={'sheriff': 'a_rotation'}, use_auth=True,
+ accept='json')
+
+ def testTimeseries2(self):
+ response = dashboard_service.Timeseries2(
+ test_suite='loading.mobile',
+ measurement='timeToFirstContenrfulPaint',
+ bot='ChromiumPerf:androd-go-perf',
+ columns='revision,avg')
+ self.assertEqual(response, 'OK')
+ self.mock_request.assert_called_once_with(
+ dashboard_service.SERVICE_URL + '/timeseries2',
+ params={'test_suite': 'loading.mobile',
+ 'measurement': 'timeToFirstContenrfulPaint',
+ 'bot': 'ChromiumPerf:androd-go-perf',
+ 'columns': 'revision,avg'},
+ method='POST', use_auth=True, accept='json')
+
+ def testTimeseries2_notFoundRaisesKeyError(self):
+ self.mock_request.side_effect = TestResponse(404, 'Not found')
+ with self.assertRaises(KeyError):
+ dashboard_service.Timeseries2(
+ test_suite='loading.mobile',
+ measurement='timeToFirstContenrfulPaint',
+ bot='ChromiumPerf:androd-go-perf',
+ columns='revision,avg')
+
+ def testTimeseries2_missingArgsRaisesTypeError(self):
+ with self.assertRaises(TypeError):
+ dashboard_service.Timeseries2(
+ test_suite='loading.mobile',
+ measurement='timeToFirstContenrfulPaint')
+
+ def testTimeseries(self):
+ response = dashboard_service.Timeseries('some test path')
+ self.assertEqual(response, 'OK')
+ self.mock_request.assert_called_once_with(
+ dashboard_service.SERVICE_URL + '/timeseries/some%20test%20path',
+ params={'num_days': 30}, method='POST', use_auth=True, accept='json')
+
+ def testTimeseries_notFoundRaisesKeyError(self):
+ self.mock_request.side_effect = TestResponse(
+ 400, '{"error": "Invalid test_path"}')
+ with self.assertRaises(KeyError):
+ dashboard_service.Timeseries('some test path')
+
+ def testBugs(self):
+ self.assertEqual(dashboard_service.Bugs(123), 'OK')
+ self.mock_request.assert_called_once_with(
+ dashboard_service.SERVICE_URL + '/bugs/123', method='POST',
+ use_auth=True, accept='json')
+
+ def testIterAlerts(self):
+ pages = {'page1': {'data': 'foo', 'next_cursor': 'page2'},
+ 'page2': {'data': 'bar'}}
+
+ def RequestStub(endpoint, method=None, params=None, **kwargs):
+ del kwargs # Unused.
+ self.assertEqual(endpoint, dashboard_service.SERVICE_URL + '/alerts')
+ self.assertEqual(method, 'POST')
+ self.assertDictContainsSubset(
+ {'test_suite': 'loading.mobile', 'limit': 1000}, params)
+ cursor = params.get('cursor', 'page1')
+ return pages[cursor]
+
+ self.mock_request.side_effect = RequestStub
+ response = [
+ resp['data']
+ for resp in dashboard_service.IterAlerts(test_suite='loading.mobile')]
+ self.assertEqual(response, ['foo', 'bar'])
+ self.assertEqual(self.mock_request.call_count, 2)
diff --git a/tools/perf/core/services/isolate_service.py b/tools/perf/core/services/isolate_service.py
new file mode 100644
index 0000000..28a44ad
--- /dev/null
+++ b/tools/perf/core/services/isolate_service.py
@@ -0,0 +1,67 @@
+# Copyright 2018 The Chromium 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 base64
+import json
+import os
+import zlib
+
+from core.services import request
+
+
+SERVICE_URL = 'https://chrome-isolated.appspot.com/_ah/api/isolateservice/v1'
+CACHE_DIR = os.path.abspath(os.path.join(
+ os.path.dirname(__file__), '..', '..', '_cached_data', 'isolates'))
+
+
+def Request(endpoint, **kwargs):
+ """Send a request to some isolate service endpoint."""
+ kwargs.setdefault('use_auth', True)
+ kwargs.setdefault('accept', 'json')
+ return request.Request(SERVICE_URL + endpoint, **kwargs)
+
+
+def Retrieve(digest):
+ """Retrieve the content stored at some isolate digest."""
+ return zlib.decompress(RetrieveCompressed(digest))
+
+
+def RetrieveFile(digest, filename):
+ """Retrieve a particular filename from an isolate container."""
+ container = json.loads(Retrieve(digest))
+ return Retrieve(container['files'][filename]['h'])
+
+
+def RetrieveCompressed(digest):
+ """Retrieve the compressed content stored at some isolate digest.
+
+ Responses are cached locally to speed up retrieving content multiple times
+ for the same digest.
+ """
+ cache_file = os.path.join(CACHE_DIR, digest)
+ if os.path.exists(cache_file):
+ with open(cache_file, 'rb') as f:
+ return f.read()
+ else:
+ if not os.path.isdir(CACHE_DIR):
+ os.makedirs(CACHE_DIR)
+ content = _RetrieveCompressed(digest)
+ with open(cache_file, 'wb') as f:
+ f.write(content)
+ return content
+
+
+def _RetrieveCompressed(digest):
+ """Retrieve the compressed content stored at some isolate digest."""
+ data = Request(
+ '/retrieve', method='POST', content_type='json',
+ data={'namespace': {'namespace': 'default-gzip'}, 'digest': digest})
+
+ if 'url' in data:
+ return request.Request(data['url'])
+ if 'content' in data:
+ return base64.b64decode(data['content'])
+ else:
+ raise NotImplementedError(
+ 'Isolate %s in unknown format %s' % (digest, json.dumps(data)))
diff --git a/tools/perf/core/services/isolate_service_test.py b/tools/perf/core/services/isolate_service_test.py
new file mode 100644
index 0000000..150845b
--- /dev/null
+++ b/tools/perf/core/services/isolate_service_test.py
@@ -0,0 +1,77 @@
+# Copyright 2018 The Chromium 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 base64
+import json
+import os
+import shutil
+import tempfile
+import unittest
+import zlib
+
+import mock
+
+from core.services import isolate_service
+
+
+def ContentResponse(content):
+ return [{'content': base64.b64encode(zlib.compress(content))}]
+
+
+def UrlResponse(url, content):
+ return [{'url': url}, zlib.compress(content)]
+
+
+class TestIsolateApi(unittest.TestCase):
+ def setUp(self):
+ self.temp_dir = tempfile.mkdtemp()
+ mock.patch('core.services.isolate_service.CACHE_DIR', os.path.join(
+ self.temp_dir, 'isolate_cache')).start()
+ self.mock_request = mock.patch('core.services.request.Request').start()
+
+ def tearDown(self):
+ shutil.rmtree(self.temp_dir)
+ mock.patch.stopall()
+
+ def testRetrieve_content(self):
+ self.mock_request.side_effect = ContentResponse('OK!')
+ self.assertEqual(isolate_service.Retrieve('hash'), 'OK!')
+
+ def testRetrieve_fromUrl(self):
+ self.mock_request.side_effect = UrlResponse('http://get/response', 'OK!')
+ self.assertEqual(isolate_service.Retrieve('hash'), 'OK!')
+
+ def testRetrieveCompressed_content(self):
+ self.mock_request.side_effect = ContentResponse('OK!')
+ self.assertEqual(
+ isolate_service.RetrieveCompressed('hash'), zlib.compress('OK!'))
+
+ def testRetrieveCompressed_fromUrl(self):
+ self.mock_request.side_effect = UrlResponse('http://get/response', 'OK!')
+ self.assertEqual(
+ isolate_service.RetrieveCompressed('hash'), zlib.compress('OK!'))
+
+ def testRetrieveCompressed_usesCache(self):
+ self.mock_request.side_effect = ContentResponse('OK!')
+ self.assertEqual(
+ isolate_service.RetrieveCompressed('hash'), zlib.compress('OK!'))
+ self.assertEqual(
+ isolate_service.RetrieveCompressed('hash'), zlib.compress('OK!'))
+ # We retrieve the same hash twice, but the request is only made once.
+ self.assertEqual(self.mock_request.call_count, 1)
+
+ def testRetrieveFile_succeeds(self):
+ self.mock_request.side_effect = (
+ ContentResponse(json.dumps({'files': {'foo': {'h': 'hash2'}}})) +
+ UrlResponse('http://get/file/contents', 'nice!'))
+
+ self.assertEqual(isolate_service.RetrieveFile('hash1', 'foo'), 'nice!')
+
+ def testRetrieveFile_fails(self):
+ self.mock_request.side_effect = (
+ ContentResponse(json.dumps({'files': {'foo': {'h': 'hash2'}}})) +
+ UrlResponse('http://get/file/contents', 'nice!'))
+
+ with self.assertRaises(KeyError):
+ isolate_service.RetrieveFile('hash1', 'bar') # File not in isolate.
diff --git a/tools/perf/core/services/luci_auth.py b/tools/perf/core/services/luci_auth.py
new file mode 100644
index 0000000..0fe4c68
--- /dev/null
+++ b/tools/perf/core/services/luci_auth.py
@@ -0,0 +1,48 @@
+# Copyright 2018 The Chromium 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 re
+import subprocess
+import sys
+
+
+_RE_INFO_USER_EMAIL = r'Logged in as (?P<email>\S+)\.$'
+
+
+class AuthorizationError(Exception):
+ pass
+
+
+def _RunCommand(command):
+ try:
+ return subprocess.check_output(
+ ['luci-auth', command], stderr=subprocess.STDOUT,
+ universal_newlines=True)
+ except subprocess.CalledProcessError as exc:
+ raise AuthorizationError(exc.output.strip())
+
+
+def CheckLoggedIn():
+ """Check that the user is currently logged in.
+
+ Otherwise sys.exit immediately with the error message from luci-auth
+ instructing the user how to log in.
+ """
+ try:
+ GetAccessToken()
+ except AuthorizationError as exc:
+ sys.exit(exc.message)
+
+
+def GetAccessToken():
+ """Get an access token to make requests on behalf of the logged in user."""
+ return _RunCommand('token').rstrip()
+
+
+def GetUserEmail():
+ """Get the email address of the currently logged in user."""
+ output = _RunCommand('info')
+ m = re.match(_RE_INFO_USER_EMAIL, output, re.MULTILINE)
+ assert m, 'Failed to parse luci-auth info output.'
+ return m.group('email')
diff --git a/tools/perf/core/services/luci_auth_test.py b/tools/perf/core/services/luci_auth_test.py
new file mode 100644
index 0000000..8bd54fe
--- /dev/null
+++ b/tools/perf/core/services/luci_auth_test.py
@@ -0,0 +1,59 @@
+# Copyright 2018 The Chromium 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 subprocess
+import unittest
+
+import mock
+
+from core.services import luci_auth
+
+
+class TestLuciAuth(unittest.TestCase):
+ def setUp(self):
+ self.check_output = mock.patch('subprocess.check_output').start()
+
+ def _MockSubprocessOutput(self, output, return_code=0):
+ if not return_code:
+ self.check_output.return_value = output
+ else:
+ def SideEffect(cmd, *args, **kwargs):
+ del args # Unused.
+ del kwargs # Unused.
+ raise subprocess.CalledProcessError(return_code, cmd, output=output)
+ self.check_output.side_effect = SideEffect
+
+ def tearDown(self):
+ mock.patch.stopall()
+
+ @mock.patch('sys.exit')
+ def testCheckLoggedIn_success(self, sys_exit):
+ self._MockSubprocessOutput('access-token')
+ self.check_output.return_value = 'access-token'
+ luci_auth.CheckLoggedIn()
+ self.assertFalse(sys_exit.mock_calls)
+
+ @mock.patch('sys.exit')
+ def testCheckLoggedIn_failure(self, sys_exit):
+ self._MockSubprocessOutput('Not logged in.', return_code=1)
+ luci_auth.CheckLoggedIn()
+ sys_exit.assert_called_once_with('Not logged in.')
+
+ def testGetAccessToken_success(self):
+ self._MockSubprocessOutput('access-token')
+ self.assertEqual(luci_auth.GetAccessToken(), 'access-token')
+
+ def testGetAccessToken_failure(self):
+ self._MockSubprocessOutput('Not logged in.', return_code=1)
+ with self.assertRaises(luci_auth.AuthorizationError):
+ luci_auth.GetAccessToken()
+
+ def testGetUserEmail(self):
+ self._MockSubprocessOutput(
+ 'Logged in as someone@example.com.\n'
+ 'OAuth token details:\n'
+ ' Client ID: abcd1234foo.bar.example.com\n'
+ ' Scopes:\n'
+ ' https://www.example.com/auth/userinfo.email\n')
+ self.assertEqual(luci_auth.GetUserEmail(), 'someone@example.com')
diff --git a/tools/perf/core/services/pinpoint_service.py b/tools/perf/core/services/pinpoint_service.py
new file mode 100644
index 0000000..c418384
--- /dev/null
+++ b/tools/perf/core/services/pinpoint_service.py
@@ -0,0 +1,38 @@
+# Copyright 2018 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+from core.services import luci_auth
+from core.services import request
+
+
+SERVICE_URL = 'https://pinpoint-dot-chromeperf.appspot.com/api'
+
+
+def Request(endpoint, **kwargs):
+ """Send a request to some pinpoint endpoint."""
+ kwargs.setdefault('use_auth', True)
+ kwargs.setdefault('accept', 'json')
+ return request.Request(SERVICE_URL + endpoint, **kwargs)
+
+
+def Job(job_id, with_state=False, with_tags=False):
+ """Get job information from its id."""
+ params = []
+ if with_state:
+ params.append(('o', 'STATE'))
+ if with_tags:
+ params.append(('o', 'TAGS'))
+ return Request('/job/%s' % job_id, params=params)
+
+
+def Jobs():
+ """List jobs for the authenticated user."""
+ return Request('/jobs')
+
+
+def NewJob(**kwargs):
+ """Create a new pinpoint job."""
+ if 'user' not in kwargs:
+ kwargs['user'] = luci_auth.GetUserEmail()
+ return Request('/new', method='POST', data=kwargs)
diff --git a/tools/perf/core/services/pinpoint_service_test.py b/tools/perf/core/services/pinpoint_service_test.py
new file mode 100644
index 0000000..c5ed68a
--- /dev/null
+++ b/tools/perf/core/services/pinpoint_service_test.py
@@ -0,0 +1,48 @@
+# Copyright 2018 The Chromium 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 unittest
+
+import mock
+
+from core.services import pinpoint_service
+
+
+class TestPinpointService(unittest.TestCase):
+ def setUp(self):
+ self.get_user_email = mock.patch(
+ 'core.services.luci_auth.GetUserEmail').start()
+ self.get_user_email.return_value = 'user@example.com'
+ self.mock_request = mock.patch('core.services.request.Request').start()
+ self.mock_request.return_value = 'OK'
+
+ def tearDown(self):
+ mock.patch.stopall()
+
+ def testJob(self):
+ self.assertEqual(pinpoint_service.Job('1234'), 'OK')
+ self.mock_request.assert_called_once_with(
+ pinpoint_service.SERVICE_URL + '/job/1234', params=[], use_auth=True,
+ accept='json')
+
+ def testJob_withState(self):
+ self.assertEqual(pinpoint_service.Job('1234', with_state=True), 'OK')
+ self.mock_request.assert_called_once_with(
+ pinpoint_service.SERVICE_URL + '/job/1234', params=[('o', 'STATE')],
+ use_auth=True, accept='json')
+
+ def testJobs(self):
+ self.mock_request.return_value = ['job1', 'job2', 'job3']
+ self.assertEqual(pinpoint_service.Jobs(), ['job1', 'job2', 'job3'])
+ self.mock_request.assert_called_once_with(
+ pinpoint_service.SERVICE_URL + '/jobs', use_auth=True, accept='json')
+
+ def testNewJob(self):
+ self.assertEqual(pinpoint_service.NewJob(
+ name='test_job', configuration='some_config'), 'OK')
+ self.mock_request.assert_called_once_with(
+ pinpoint_service.SERVICE_URL + '/new', method='POST',
+ data={'name': 'test_job', 'configuration': 'some_config',
+ 'user': 'user@example.com'},
+ use_auth=True, accept='json')
diff --git a/tools/perf/core/services/request.py b/tools/perf/core/services/request.py
new file mode 100644
index 0000000..fda439b
--- /dev/null
+++ b/tools/perf/core/services/request.py
@@ -0,0 +1,152 @@
+# Copyright 2018 The Chromium 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 json
+import logging
+import urllib
+
+import httplib2
+
+from py_utils import retry_util # pylint: disable=import-error
+
+from core.services import luci_auth
+
+
+# Some services pad JSON responses with a security prefix to prevent against
+# XSSI attacks. If found, the prefix is stripped off before attempting to parse
+# a JSON response.
+# See e.g.: https://gerrit-review.googlesource.com/Documentation/rest-api.html#output
+JSON_SECURITY_PREFIX = ")]}'"
+
+
+class RequestError(OSError):
+ """Exception class for errors while making a request."""
+ def __init__(self, request, response, content):
+ self.request = request
+ self.response = response
+ self.content = content
+ message = u'%s returned HTTP Error %d: %s' % (
+ self.request, self.response.status, self.error_message)
+ # Note: the message is a unicode object, possibly with special characters,
+ # so it needs to be turned into a str as expected by the constructor of
+ # the base class.
+ super(RequestError, self).__init__(message.encode('utf-8'))
+
+ def __reduce__(self):
+ # Method needed to make the exception pickleable [1], otherwise it causes
+ # the multiprocess pool to hang when raised by a worker [2].
+ # [1]: https://stackoverflow.com/a/36342588
+ # [2]: https://github.com/uqfoundation/multiprocess/issues/33
+ return (type(self), (self.request, self.response, self.content))
+
+ @property
+ def json(self):
+ """Attempt to load the content as a json object."""
+ try:
+ return json.loads(self.content)
+ except StandardError:
+ return None
+
+ @property
+ def error_message(self):
+ """Returns a unicode object with the error message found in the content."""
+ try:
+ # Try to find error message within json content.
+ return self.json['error']
+ except StandardError:
+ # Otherwise fall back to entire content itself, converting str to unicode.
+ return self.content.decode('utf-8')
+
+
+class ClientError(RequestError):
+ """Exception for 4xx HTTP client errors."""
+ pass
+
+
+class ServerError(RequestError):
+ """Exception for 5xx HTTP server errors."""
+ pass
+
+
+def BuildRequestError(request, response, content):
+ """Build the correct RequestError depending on the response status."""
+ if response['status'].startswith('4'):
+ error = ClientError
+ elif response['status'].startswith('5'):
+ error = ServerError
+ else: # Fall back to the base class.
+ error = RequestError
+ return error(request, response, content)
+
+
+@retry_util.RetryOnException(ServerError, retries=3)
+def Request(url, method='GET', params=None, data=None, accept=None,
+ content_type='urlencoded', use_auth=False, retries=None):
+ """Perform an HTTP request of a given resource.
+
+ Args:
+ url: A string with the URL to request.
+ method: A string with the HTTP method to perform, e.g. 'GET' or 'POST'.
+ params: An optional dict or sequence of key, value pairs to be added as
+ a query to the url.
+ data: An optional dict or sequence of key, value pairs to send as payload
+ data in the body of the request.
+ accept: An optional string to specify the expected response format.
+ Currently only 'json' is supported, which attempts to parse the response
+ content as json. If omitted, the default is to return the raw response
+ content as a string.
+ content_type: A string specifying how to encode the payload data,
+ can be either 'urlencoded' (default) or 'json'.
+ use_auth: A boolean indecating whether to send authorized requests, if True
+ luci-auth is used to get an access token for the logged in user.
+ retries: Number of times to retry the request in case of ServerError. Note,
+ the request is _not_ retried if the response is a ClientError.
+
+ Returns:
+ A string with the content of the response when it has a successful status.
+
+ Raises:
+ A ClientError if the response has a 4xx status, or ServerError if the
+ response has a 5xx status.
+ """
+ del retries # Handled by the decorator.
+
+ if params:
+ url = '%s?%s' % (url, urllib.urlencode(params))
+
+ body = None
+ headers = {}
+
+ if accept == 'json':
+ headers['Accept'] = 'application/json'
+ elif accept is not None:
+ raise NotImplementedError('Invalid accept format: %s' % accept)
+
+ if data is not None:
+ if content_type == 'json':
+ body = json.dumps(data, sort_keys=True, separators=(',', ':'))
+ headers['Content-Type'] = 'application/json'
+ elif content_type == 'urlencoded':
+ body = urllib.urlencode(data)
+ headers['Content-Type'] = 'application/x-www-form-urlencoded'
+ else:
+ raise NotImplementedError('Invalid content type: %s' % content_type)
+ else:
+ headers['Content-Length'] = '0'
+
+ if use_auth:
+ headers['Authorization'] = 'Bearer %s' % luci_auth.GetAccessToken()
+
+ logging.info('Making API request: %s', url)
+ http = httplib2.Http()
+ response, content = http.request(
+ url, method=method, body=body, headers=headers)
+ if response.status != 200:
+ raise BuildRequestError(url, response, content)
+
+ if accept == 'json':
+ if content[:4] == JSON_SECURITY_PREFIX:
+ content = content[4:] # Strip off security prefix if found.
+ content = json.loads(content)
+ return content
diff --git a/tools/perf/core/services/request_test.py b/tools/perf/core/services/request_test.py
new file mode 100644
index 0000000..d05f778
--- /dev/null
+++ b/tools/perf/core/services/request_test.py
@@ -0,0 +1,126 @@
+# Copyright 2018 The Chromium 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 json
+import pickle
+import unittest
+
+import httplib2
+import mock
+
+from core.services import request
+
+
+def Response(code, content):
+ return httplib2.Response({'status': str(code)}), content
+
+
+class TestRequest(unittest.TestCase):
+ def setUp(self):
+ self.http = mock.Mock()
+ mock.patch('httplib2.Http', return_value=self.http).start()
+ mock.patch('time.sleep').start()
+
+ def tearDown(self):
+ mock.patch.stopall()
+
+ def testRequest_simple(self):
+ self.http.request.return_value = Response(200, 'OK!')
+ self.assertEqual(request.Request('http://example.com/'), 'OK!')
+ self.http.request.assert_called_once_with(
+ 'http://example.com/', method='GET', body=None, headers=mock.ANY)
+
+ def testRequest_acceptJson(self):
+ self.http.request.return_value = Response(200, '{"code": "ok!"}')
+ self.assertEqual(
+ request.Request('http://example.com/', accept='json'), {'code': 'ok!'})
+ self.http.request.assert_called_once_with(
+ 'http://example.com/', method='GET', body=None, headers=mock.ANY)
+
+ def testRequest_acceptJsonWithSecurityPrefix(self):
+ self.http.request.return_value = Response(200, ')]}\'{"code": "ok!"}')
+ self.assertEqual(
+ request.Request('http://example.com/', accept='json'), {'code': 'ok!'})
+ self.http.request.assert_called_once_with(
+ 'http://example.com/', method='GET', body=None, headers=mock.ANY)
+
+ def testRequest_postWithParams(self):
+ self.http.request.return_value = Response(200, 'OK!')
+ self.assertEqual(request.Request(
+ 'http://example.com/', params={'q': 'foo'}, method='POST'), 'OK!')
+ self.http.request.assert_called_once_with(
+ 'http://example.com/?q=foo', method='POST', body=None, headers=mock.ANY)
+
+ def testRequest_postWithData(self):
+ self.http.request.return_value = Response(200, 'OK!')
+ self.assertEqual(request.Request(
+ 'http://example.com/', data={'q': 'foo'}, method='POST'), 'OK!')
+ self.http.request.assert_called_once_with(
+ 'http://example.com/', method='POST', body='q=foo', headers=mock.ANY)
+
+ def testRequest_postWithJsonData(self):
+ self.http.request.return_value = Response(200, 'OK!')
+ self.assertEqual(request.Request(
+ 'http://example.com/', data={'q': 'foo'}, content_type='json',
+ method='POST'), 'OK!')
+ self.http.request.assert_called_once_with(
+ 'http://example.com/', method='POST', body='{"q":"foo"}',
+ headers=mock.ANY)
+
+ def testRequest_retryOnServerError(self):
+ self.http.request.side_effect = [
+ Response(500, 'Oops. Something went wrong!'),
+ Response(200, 'All is now OK.')
+ ]
+ self.assertEqual(request.Request('http://example.com/'), 'All is now OK.')
+
+ def testRequest_failOnClientError(self):
+ self.http.request.side_effect = [
+ Response(400, 'Bad request!'),
+ Response(200, 'This is not called.')
+ ]
+ with self.assertRaises(request.ClientError):
+ request.Request('http://example.com/')
+
+ @mock.patch('core.services.luci_auth.GetAccessToken')
+ def testRequest_withLuciAuth(self, get_access_token):
+ get_access_token.return_value = 'access-token'
+ self.http.request.return_value = Response(200, 'OK!')
+ self.assertEqual(
+ request.Request('http://example.com/', use_auth=True), 'OK!')
+ self.http.request.assert_called_once_with(
+ 'http://example.com/', method='GET', body=None, headers={
+ 'Content-Length': '0',
+ 'Authorization': 'Bearer access-token'})
+
+
+class TestRequestErrors(unittest.TestCase):
+ def testClientErrorPickleable(self):
+ error = request.ClientError(
+ 'api', *Response(400, 'You made a bad request!'))
+ error = pickle.loads(pickle.dumps(error))
+ self.assertIsInstance(error, request.ClientError)
+ self.assertEqual(error.request, 'api')
+ self.assertEqual(error.response.status, 400)
+ self.assertEqual(error.content, 'You made a bad request!')
+
+ def testServerErrorPickleable(self):
+ error = request.ServerError(
+ 'api', *Response(500, 'Oops, I had a problem!'))
+ error = pickle.loads(pickle.dumps(error))
+ self.assertIsInstance(error, request.ServerError)
+ self.assertEqual(error.request, 'api')
+ self.assertEqual(error.response.status, 500)
+ self.assertEqual(error.content, 'Oops, I had a problem!')
+
+ def testJsonErrorMessageToString(self):
+ message = u'Something went wrong. That\u2019s all we know.'
+ error = request.ServerError(
+ '/endpoint', *Response(500, json.dumps({'error': message})))
+ self.assertIn('Something went wrong.', str(error))
+
+ def testErrorMessageToString(self):
+ content = u'Something went wrong. That\u2019s all we know.'.encode('utf-8')
+ error = request.ServerError('/endpoint', *Response(500, content))
+ self.assertIn('Something went wrong.', str(error))
diff --git a/tools/perf/examples/pinpoint_cli/bisect_job.json b/tools/perf/examples/pinpoint_cli/bisect_job.json
new file mode 100644
index 0000000..30ba37e
--- /dev/null
+++ b/tools/perf/examples/pinpoint_cli/bisect_job.json
@@ -0,0 +1,16 @@
+{
+ "name": "Example bisect job",
+ "bug_id": "893896",
+ "target": "performance_test_suite",
+ "configuration": "android-go-perf",
+ "benchmark": "memory.top_10_mobile",
+ "comparison_mode": "performance",
+ "story": "http.m.youtube.com.results.q.science",
+ "chart": "memory:chrome:all_processes:reported_by_os:system_memory:private_footprint_size",
+ "tir_label": "foreground",
+ "trace": "http_m_youtube_com_results_q_science",
+ "statistic": "avg",
+ "repository": "chromium",
+ "start_git_hash": "daa361c6e17be19c30de3896efeb63ca0034e9b8",
+ "end_git_hash": "30588560782b21f7910332d49796948a3b482c19"
+}
diff --git a/tools/perf/examples/pinpoint_cli/try_job.json b/tools/perf/examples/pinpoint_cli/try_job.json
new file mode 100644
index 0000000..a51757ac
--- /dev/null
+++ b/tools/perf/examples/pinpoint_cli/try_job.json
@@ -0,0 +1,11 @@
+{
+ "name": "Try job to test my change",
+ "target": "performance_test_suite",
+ "configuration": "android-go-perf",
+ "benchmark": "system_health.memory_mobile",
+ "extra_test_args": "--story-tag-filter emerging_market",
+ "patch": "https://chromium-review.googlesource.com/c/v8/v8/+/1278496",
+ "repository": "chromium",
+ "start_git_hash": "HEAD",
+ "end_git_hash": "HEAD"
+}
diff --git a/tools/perf/examples/soundwave/startup_timeseries.json b/tools/perf/examples/soundwave/startup_timeseries.json
new file mode 100644
index 0000000..ea99b39
--- /dev/null
+++ b/tools/perf/examples/soundwave/startup_timeseries.json
@@ -0,0 +1,8 @@
+[
+ {
+ "test_suite": "startup.mobile",
+ "measurement": "first_contentful_paint_time",
+ "bot": "ChromiumPerf:android-go-perf",
+ "test_case": "intent_coldish_bbc"
+ }
+]
diff --git a/tools/perf/export_csv b/tools/perf/export_csv
new file mode 100755
index 0000000..bd87d348
--- /dev/null
+++ b/tools/perf/export_csv
@@ -0,0 +1,57 @@
+#!/usr/bin/env vpython
+# Copyright 2018 The Chromium 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 argparse
+import contextlib
+import csv
+import os
+import sqlite3
+import sys
+
+
+DEFAULT_DATABASE_PATH = os.path.abspath(os.path.join(
+ os.path.dirname(__file__), '_cached_data', 'soundwave', 'soundwave.db'))
+
+
+@contextlib.contextmanager
+def OutputStream(filename):
+ if filename is None or filename == '-':
+ yield sys.stdout
+ else:
+ with open(filename, 'w') as f:
+ yield f
+
+
+def EncodeUnicode(v):
+ return v.encode('utf-8') if isinstance(v, unicode) else v
+
+
+def main():
+ parser = argparse.ArgumentParser()
+ parser.add_argument(
+ 'table', help='Name of a table to export')
+ parser.add_argument(
+ '--database-file', default=DEFAULT_DATABASE_PATH,
+ help='File path for database where to store data.')
+ parser.add_argument(
+ '--output', '-o',
+ help='Where to write the csv output, defaults to stdout.')
+ args = parser.parse_args()
+
+ con = sqlite3.connect(args.database_file)
+ try:
+ cur = con.execute('SELECT * FROM %s' % args.table)
+ header = [c[0] for c in cur.description]
+ with OutputStream(args.output) as out:
+ writer = csv.writer(out)
+ writer.writerow(header)
+ for row in cur:
+ writer.writerow([EncodeUnicode(v) for v in row])
+ finally:
+ con.close()
+
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/tools/perf/pinpoint_cli b/tools/perf/pinpoint_cli
new file mode 100755
index 0000000..6222893
--- /dev/null
+++ b/tools/perf/pinpoint_cli
@@ -0,0 +1,72 @@
+#!/usr/bin/env vpython
+# Copyright 2018 The Chromium 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 argparse
+import json
+import os
+import sys
+
+from core import path_util
+path_util.AddPyUtilsToPath()
+path_util.AddTracingToPath()
+
+from core import cli_utils
+from core import external_modules
+from cli_tools.pinpoint_cli import commands
+from core.services import luci_auth
+
+
+def main():
+ parser = argparse.ArgumentParser()
+ parser.add_argument(
+ '-v', '--verbose', action='count', default=0,
+ help='Increase verbosity level')
+ subparsers = parser.add_subparsers(dest='action')
+ subparsers.required = True
+
+ subparser = subparsers.add_parser(
+ 'status', help='check the status of some pinpoint jobs')
+ subparser.add_argument(
+ 'job_ids', metavar='JOB_ID', nargs='+',
+ help='one or more pinpoint job ids')
+
+ subparser = subparsers.add_parser(
+ 'get-csv', help='download the perf results from jobs as a csv file')
+ subparser.add_argument(
+ '--only-differences', action='store_true',
+ help='on bisect jobs, only get data for changes immediately before/after'
+ ' differences in the comparison metric.')
+ subparser.add_argument(
+ '--output', metavar='OUTPUT_CSV', default='job_results.csv',
+ help='path to a file where to store perf results as a csv file'
+ ' (default: %(default)s)')
+ subparser.add_argument(
+ 'job_ids', metavar='JOB_ID', nargs='+',
+ help='one or more pinpoint job ids')
+
+ subparser = subparsers.add_parser(
+ 'start-job', help='start a new pinpoint job')
+ subparser.add_argument(
+ 'config_path', metavar='CONFIG_PATH',
+ help='path to a json file with a pinpoint job configuration (see'
+ " examples/pinpoint_cli directory) or '-' to read it from stdin")
+ args = parser.parse_args()
+ cli_utils.ConfigureLogging(args.verbose)
+
+ luci_auth.CheckLoggedIn()
+ if args.action == 'status':
+ return commands.CheckJobStatus(args.job_ids)
+ elif args.action == 'get-csv':
+ return commands.DownloadJobResultsAsCsv(
+ args.job_ids, args.only_differences, args.output)
+ elif args.action == 'start-job':
+ return commands.StartJobFromConfig(args.config_path)
+ else:
+ raise NotImplementedError(args.action)
+
+
+if __name__ == '__main__':
+ external_modules.RequireModules()
+ sys.exit(main())
diff --git a/tools/perf/soundwave b/tools/perf/soundwave
new file mode 100755
index 0000000..a438e7e6
--- /dev/null
+++ b/tools/perf/soundwave
@@ -0,0 +1,100 @@
+#!/usr/bin/env vpython
+# Copyright 2018 The Chromium 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 argparse
+import os
+import sys
+
+from core import path_util
+path_util.AddPyUtilsToPath()
+
+from core import cli_utils
+from core import external_modules
+from core.services import luci_auth
+from cli_tools.soundwave import commands
+from cli_tools.soundwave import studies
+
+
+DEFAULT_DATABASE_PATH = os.path.abspath(os.path.join(
+ os.path.dirname(__file__), '_cached_data', 'soundwave', 'soundwave.db'))
+
+
+def main():
+ parser = argparse.ArgumentParser()
+ # Default args for all actions.
+ parser.add_argument(
+ '-s', '--sheriff', default='Chromium Perf Sheriff',
+ help='Only get data for this sheriff rotation, default: "%(default)s". '
+ 'You can use the special value "all" to disable filtering by '
+ 'sheriff rotation.')
+ parser.add_argument(
+ '-d', '--days', default=30, type=int,
+ help='Number of days to collect data for (default: %(default)s)')
+ parser.add_argument(
+ '--continue', action='store_true', dest='use_cache',
+ help='Skip refreshing some data for elements already in local db.')
+ parser.add_argument(
+ '--processes', type=int, default=40,
+ help='Number of concurrent processes to use for fetching data.')
+ parser.add_argument(
+ '--database-file', default=DEFAULT_DATABASE_PATH,
+ help='File path for database where to store data.')
+ parser.add_argument(
+ '-v', '--verbose', action='count', default=0,
+ help='Increase verbosity level')
+ subparsers = parser.add_subparsers(dest='action')
+ subparsers.required = True
+ # Subparser args for fetching alerts data.
+ subparser = subparsers.add_parser('alerts')
+ subparser.add_argument(
+ '-b', '--benchmark', required=True,
+ help='Fetch alerts for this benchmark.')
+ # Subparser args for fetching timeseries data.
+ subparser = subparsers.add_parser('timeseries')
+ group = subparser.add_mutually_exclusive_group(required=True)
+ group.add_argument(
+ '-b', '--benchmark', help='Fetch timeseries for this benchmark.')
+ group.add_argument(
+ '--study', choices=studies.NAMES,
+ help='Fetch timeseries needed for a specific study.')
+ group.add_argument(
+ '-i', '--input-file',
+ help='Fetch timeseries listed in this json file (see e.g.'
+ ' examples/soundwave directory).')
+ subparser.add_argument(
+ '-f', '--filters', action='append',
+ help='Only get data for timeseries whose path contains all the given '
+ 'substrings.')
+ group = subparser.add_mutually_exclusive_group()
+ group.add_argument(
+ '--output-csv', metavar='PATH',
+ help='Export the timeseries data to a csv file, the PATH given may be '
+ 'either a local or a cloud storage (i.e. gs://...) path.')
+ group.add_argument(
+ '--upload-csv', action='store_true',
+ help='Export the timeseries data to the default cloud storage path for '
+ 'a given --study.')
+
+ args = parser.parse_args()
+
+ cli_utils.ConfigureLogging(args.verbose)
+ luci_auth.CheckLoggedIn()
+ if args.action == 'alerts':
+ commands.FetchAlertsData(args)
+ elif args.action == 'timeseries':
+ if args.study is not None:
+ args.study = studies.GetStudy(args.study)
+ if args.upload_csv:
+ args.output_csv = args.study.CLOUD_PATH
+ elif args.upload_csv:
+ return 'ERROR: --upload-csv also requires a --study to be specified'
+ commands.FetchTimeseriesData(args)
+ else:
+ raise NotImplementedError(args.action)
+
+
+if __name__ == '__main__':
+ external_modules.RequireModules()
+ sys.exit(main())
diff --git a/tools/perf/update_wpr b/tools/perf/update_wpr
index 54b4886..74d12e1 100755
--- a/tools/perf/update_wpr
+++ b/tools/perf/update_wpr
@@ -5,6 +5,9 @@
import sys
+from core import path_util
+path_util.AddPyUtilsToPath()
+
from cli_tools import update_wpr