[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