Add a library for monitoring.

Change-Id: I64c3d143186be938042c12e2455bdb034d3bcba8
Reviewed-on: https://chromium-review.googlesource.com/1079639
Commit-Queue: Edward Lesmes <ehmaldonado@chromium.org>
Reviewed-by: Aaron Gable <agable@chromium.org>
diff --git a/.gitignore b/.gitignore
index 568a8c5..fb5762a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -78,3 +78,6 @@
 
 # Ignore emacs / vim backup files.
 *~
+
+# Ignore the monitoring config. It is unique for each user.
+/metrics.cfg
diff --git a/gclient.py b/gclient.py
index 11a39b4..1a3054e 100755
--- a/gclient.py
+++ b/gclient.py
@@ -104,6 +104,7 @@
 import gclient_scm
 import gclient_utils
 import git_cache
+import metrics
 from third_party.repo.progress import Progress
 import subcommand
 import subprocess2
@@ -2842,6 +2843,36 @@
         'dependencies from disallowed hosts; check your DEPS file.')
   return 0
 
+
+@subcommand.epilog("""For more information on what metrics are we collecting and
+why, please read metrics.README.md or visit
+<short link to metrics.README.md in gitiles>.""")
+def CMDmetrics(parser, args):
+  """Reports, and optionally modifies, the status of metric collection."""
+  parser.add_option('--opt-in', action='store_true', dest='enable_metrics',
+                    help='Opt-in to metrics collection.',
+                    default=None)
+  parser.add_option('--opt-out', action='store_false', dest='enable_metrics',
+                    help='Opt-out of metrics collection.')
+  options, args = parser.parse_args(args)
+  if args:
+    parser.error('Unused arguments: "%s"' % '" "'.join(args))
+  if not metrics.collector.config.is_googler:
+    print("You're not a Googler. Metrics collection is disabled for you.")
+    return 0
+
+  if options.enable_metrics is not None:
+    metrics.collector.config.opted_in = options.enable_metrics
+
+  if metrics.collector.config.opted_in is None:
+    print("You haven't opted in or out of metrics collection.")
+  elif metrics.collector.config.opted_in:
+    print("You have opted in. Thanks!")
+  else:
+    print("You have opted out. Please consider opting in.")
+  return 0
+
+
 class OptionParser(optparse.OptionParser):
   gclientfile_default = os.environ.get('GCLIENT_FILE', '.gclient')
 
diff --git a/metrics.README.md b/metrics.README.md
new file mode 100644
index 0000000..f6c970c
--- /dev/null
+++ b/metrics.README.md
@@ -0,0 +1,49 @@
+# Why am I seeing this message?
+
+We're starting to collect metrics about how developers use gclient and other
+tools in depot\_tools to better understand the performance and failure modes of
+the tools, as well of the pain points and workflows of depot\_tools users.
+
+Pleas consider opting in. It will allow us to know what features are the most
+important, what features can we deprecate, and what features should we develop
+to better cover your use case.
+
+You will be opted in by default after 10 executions of depot\_tools commands,
+after which the message will change to let you know metrics collection is taking
+place.
+
+## What metrics are you collecting?
+
+First, some words about what data we are **NOT** collecting:
+
+- We won’t record any information that identifies you personally.
+- We won't record the command line flag values.
+- We won't record information about the current directory or environment flags.
+
+The metrics we're collecting are:
+
+- A timestamp, with a week resolution.
+- The age of your depot\_tools checkout, with a week resolution.
+- Your version of Python (in the format major.minor.micro).
+- The OS of your machine (i.e. win, linux or mac).
+- The arch of your machine (e.g. x64, arm, etc).
+- The command that you ran (e.g. `gclient sync`).
+- The flag names (but not their values) that you passed to the command
+  (e.g. `--force`, `--revision`).
+- The execution time.
+- The exit code.
+- The project you're working on. We only record data about projects you can
+  fetch using depot\_tools' fetch command (e.g. Chromium, WebRTC, V8, etc)
+- The age of your project checkout, with a week resolution.
+- What features are you using in your DEPS and .gclient files. For example:
+  - Are you setting `use\_relative\_paths=True`?
+  - Are you using `recursedeps`?
+
+
+# How can I stop seeing this message?
+
+You will stop seeing it once you have explicitly opted in or out of depot\_tools
+metrics collection.
+
+You can run `gclient metrics --opt-in` or `gclient metrics --opt-out` to do so.
+And you can opt-in or out at any time.
diff --git a/metrics.py b/metrics.py
new file mode 100644
index 0000000..ccac340
--- /dev/null
+++ b/metrics.py
@@ -0,0 +1,194 @@
+#!/usr/bin/env python
+# Copyright (c) 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 functools
+import json
+import os
+import subprocess
+import sys
+import tempfile
+import threading
+import time
+import traceback
+import urllib2
+
+import detect_host_arch
+import gclient_utils
+import metrics_utils
+
+
+DEPOT_TOOLS = os.path.dirname(os.path.abspath(__file__))
+CONFIG_FILE = os.path.join(DEPOT_TOOLS, 'metrics.cfg')
+UPLOAD_SCRIPT = os.path.join(DEPOT_TOOLS, 'upload_metrics.py')
+
+APP_URL = 'https://cit-cli-metrics.appspot.com'
+
+DISABLE_METRICS_COLLECTION = os.environ.get('DEPOT_TOOLS_METRICS') == '0'
+DEFAULT_COUNTDOWN = 10
+
+INVALID_CONFIG_WARNING = (
+    'WARNING: Your metrics.cfg file was invalid or nonexistent. A new one has '
+    'been created'
+)
+
+
+class _Config(object):
+  def __init__(self):
+    self._initialized = False
+    self._config = {}
+
+  def _ensure_initialized(self):
+    if self._initialized:
+      return
+
+    try:
+      config = json.loads(gclient_utils.FileRead(CONFIG_FILE))
+    except (IOError, ValueError):
+      config = {}
+
+    self._config = config.copy()
+
+    if 'is-googler' not in self._config:
+      # /should-upload is only accessible from Google IPs, so we only need to
+      # check if we can reach the page. An external developer would get access
+      # denied.
+      try:
+        req = urllib2.urlopen(APP_URL + '/should-upload')
+        self._config['is-googler'] = req.getcode() == 200
+      except (urllib2.URLError, urllib2.HTTPError):
+        self._config['is-googler'] = False
+
+    # Make sure the config variables we need are present, and initialize them to
+    # safe values otherwise.
+    self._config.setdefault('countdown', DEFAULT_COUNTDOWN)
+    self._config.setdefault('opt-in', None)
+
+    if config != self._config:
+      print INVALID_CONFIG_WARNING
+      self._write_config()
+
+    self._initialized = True
+
+  def _write_config(self):
+    gclient_utils.FileWrite(CONFIG_FILE, json.dumps(self._config))
+
+  @property
+  def is_googler(self):
+    self._ensure_initialized()
+    return self._config['is-googler']
+
+  @property
+  def opted_in(self):
+    self._ensure_initialized()
+    return self._config['opt-in']
+
+  @opted_in.setter
+  def opted_in(self, value):
+    self._ensure_initialized()
+    self._config['opt-in'] = value
+    self._write_config()
+
+  @property
+  def countdown(self):
+    self._ensure_initialized()
+    return self._config['countdown']
+
+  def decrease_countdown(self):
+    self._ensure_initialized()
+    if self.countdown == 0:
+      return
+    self._config['countdown'] -= 1
+    self._write_config()
+
+
+class MetricsCollector(object):
+  def __init__(self):
+    self._metrics_lock = threading.Lock()
+    self._reported_metrics = {}
+    self._config = _Config()
+
+  @property
+  def config(self):
+    return self._config
+
+  def add(self, name, value):
+    with self._metrics_lock:
+      self._reported_metrics[name] = value
+
+  def _upload_metrics_data(self):
+    """Upload the metrics data to the AppEngine app."""
+    # We invoke a subprocess, and use stdin.write instead of communicate(),
+    # so that we are able to return immediately, leaving the upload running in
+    # the background.
+    p = subprocess.Popen([sys.executable, UPLOAD_SCRIPT], stdin=subprocess.PIPE)
+    p.stdin.write(json.dumps(self._reported_metrics))
+
+  def _collect_metrics(self, func, command_name, *args, **kwargs):
+    self.add('command', command_name)
+    try:
+      start = time.time()
+      func(*args, **kwargs)
+      exception = None
+    # pylint: disable=bare-except
+    except:
+      exception = sys.exc_info()
+    finally:
+      self.add('execution_time', time.time() - start)
+
+    # Print the exception before the metrics notice, so that the notice is
+    # clearly visible even if gclient fails.
+    if exception and not isinstance(exception[1], SystemExit):
+      traceback.print_exception(*exception)
+
+    exit_code = metrics_utils.return_code_from_exception(exception)
+    self.add('exit_code', exit_code)
+
+    # Print the metrics notice only if the user has not explicitly opted in
+    # or out.
+    if self.config.opted_in is None:
+      metrics_utils.print_notice(self.config.countdown)
+
+    # Add metrics regarding environment information.
+    self.add('timestamp', metrics_utils.seconds_to_weeks(time.time()))
+    self.add('python_version', metrics_utils.get_python_version())
+    self.add('host_os', gclient_utils.GetMacWinOrLinux())
+    self.add('host_arch', detect_host_arch.HostArch())
+    self.add('depot_tools_age', metrics_utils.get_repo_timestamp(DEPOT_TOOLS))
+
+    self._upload_metrics_data()
+    sys.exit(exit_code)
+
+  def collect_metrics(self, command_name):
+    """A decorator used to collect metrics over the life of a function.
+
+    This decorator executes the function and collects metrics about the system
+    environment and the function performance. It also catches all the Exceptions
+    and invokes sys.exit once the function is done executing.
+    """
+    def _decorator(func):
+      # Do this first so we don't have to read, and possibly create a config
+      # file.
+      if DISABLE_METRICS_COLLECTION:
+        return func
+      # If the user has opted out or the user is not a googler, then there is no
+      # need to do anything.
+      if self.config.opted_in == False or not self.config.is_googler:
+        return func
+      # If the user hasn't opted in or out, and the countdown is not yet 0, just
+      # display the notice.
+      if self.config.opted_in == None and self.config.countdown > 0:
+        metrics_utils.print_notice(self.config.countdown)
+        self.config.decrease_countdown()
+        return func
+      # Otherwise, collect the metrics.
+      # Needed to preserve the __name__ and __doc__ attributes of func.
+      @functools.wraps(func)
+      def _inner(*args, **kwargs):
+        self._collect_metrics(func, command_name, *args, **kwargs)
+      return _inner
+    return _decorator
+
+
+collector = MetricsCollector()
diff --git a/metrics_utils.py b/metrics_utils.py
new file mode 100644
index 0000000..9f51182
--- /dev/null
+++ b/metrics_utils.py
@@ -0,0 +1,88 @@
+#!/usr/bin/env python
+# Copyright (c) 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 scm
+import subprocess2
+import sys
+
+from third_party import colorama
+
+
+NOTICE_COUNTDOWN_HEADER = (
+  '*****************************************************\n'
+  '*  METRICS COLLECTION WILL START IN %2d EXECUTIONS   *'
+)
+NOTICE_COLLECTION_HEADER = (
+  '*****************************************************\n'
+  '*      METRICS COLLECTION IS TAKING PLACE           *'
+)
+NOTICE_FOOTER = (
+  '*                                                   *\n'
+  '* For more information, and for how to disable this *\n'
+  '* message, please see metrics.README.md in your     *\n'
+  '* depot_tools checkout.                             *\n'
+  '*****************************************************\n'
+)
+
+
+def get_python_version():
+  """Return the python version in the major.minor.micro format."""
+  return '{v.major}.{v.minor}.{v.micro}'.format(v=sys.version_info)
+
+
+def return_code_from_exception(exception):
+  """Returns the exit code that would result of raising the exception."""
+  if exception is None:
+    return 0
+  if isinstance(exception[1], SystemExit):
+    return exception[1].code
+  return 1
+
+
+def seconds_to_weeks(duration):
+  """Transform a |duration| from seconds to weeks approximately.
+
+  Drops the lowest 19 bits of the integer representation, which ammounts to
+  about 6 days.
+  """
+  return int(duration) >> 19
+
+
+def get_repo_timestamp(path_to_repo):
+  """Get an approximate timestamp for the upstream of |path_to_repo|.
+
+  Returns the top two bits of the timestamp of the HEAD for the upstream of the
+  branch path_to_repo is checked out at.
+  """
+  # Get the upstream for the current branch. If we're not in a branch, fallback
+  # to HEAD.
+  try:
+    upstream = scm.GIT.GetUpstreamBranch(path_to_repo)
+  except subprocess2.CalledProcessError:
+    upstream = 'HEAD'
+
+  # Get the timestamp of the HEAD for the upstream of the current branch.
+  p = subprocess2.Popen(
+      ['git', '-C', path_to_repo, 'log', '-n1', upstream, '--format=%at'],
+      stdout=subprocess2.PIPE, stderr=subprocess2.PIPE)
+  stdout, _ = p.communicate()
+
+  # If there was an error, give up.
+  if p.returncode != 0:
+    return None
+
+  # Get the age of the checkout in weeks.
+  return seconds_to_weeks(stdout.strip())
+
+
+def print_notice(countdown):
+  """Print a notice to let the user know the status of metrics collection."""
+  colorama.init()
+  print colorama.Fore.RED + '\033[1m'
+  if countdown:
+    print NOTICE_COUNTDOWN_HEADER % countdown
+  else:
+    print NOTICE_COLLECTION_HEADER
+  print NOTICE_FOOTER + colorama.Style.RESET_ALL
diff --git a/tests/gclient_eval_unittest.py b/tests/gclient_eval_unittest.py
index e34879b..d97c10e 100755
--- a/tests/gclient_eval_unittest.py
+++ b/tests/gclient_eval_unittest.py
@@ -14,6 +14,10 @@
 
 from third_party import schema
 
+import metrics
+# We have to disable monitoring before importing gclient.
+metrics.DISABLE_METRICS_COLLECTION = True
+
 import gclient
 import gclient_eval
 
diff --git a/tests/gclient_smoketest.py b/tests/gclient_smoketest.py
index 5673b0e..f95b6d7 100755
--- a/tests/gclient_smoketest.py
+++ b/tests/gclient_smoketest.py
@@ -37,6 +37,7 @@
     # Make sure it doesn't try to auto update when testing!
     self.env = os.environ.copy()
     self.env['DEPOT_TOOLS_UPDATE'] = '0'
+    self.env['DEPOT_TOOLS_METRICS'] = '0'
 
   def gclient(self, cmd, cwd=None):
     if not cwd:
diff --git a/tests/gclient_test.py b/tests/gclient_test.py
index a772a36..3919e64 100755
--- a/tests/gclient_test.py
+++ b/tests/gclient_test.py
@@ -18,6 +18,10 @@
 
 sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
 
+import metrics
+# We have to disable monitoring before importing gclient.
+metrics.DISABLE_METRICS_COLLECTION = True
+
 import gclient
 import gclient_utils
 import gclient_scm
diff --git a/tests/metrics_test.py b/tests/metrics_test.py
new file mode 100644
index 0000000..26cd0e0
--- /dev/null
+++ b/tests/metrics_test.py
@@ -0,0 +1,295 @@
+#!/usr/bin/env python
+# Copyright (c) 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 os
+import sys
+import unittest
+
+ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+sys.path.insert(0, ROOT_DIR)
+
+import metrics
+import cStringIO
+
+from third_party import mock
+
+
+class TimeMock(object):
+  def __init__(self):
+    self._count = 0
+
+  def __call__(self):
+    self._count += 1
+    return self._count * 1000
+
+
+class MetricsCollectorTest(unittest.TestCase):
+  def setUp(self):
+    self.collector = metrics.MetricsCollector()
+
+    # Keep track of the URL requests, file reads/writes and subprocess spawned.
+    self.urllib2 = mock.Mock()
+    self.print_notice = mock.Mock()
+    self.Popen = mock.Mock()
+    self.FileWrite = mock.Mock()
+    self.FileRead = mock.Mock()
+
+    mock.patch('metrics.urllib2', self.urllib2).start()
+    mock.patch('metrics.subprocess.Popen', self.Popen).start()
+    mock.patch('metrics.gclient_utils.FileWrite', self.FileWrite).start()
+    mock.patch('metrics.gclient_utils.FileRead', self.FileRead).start()
+    mock.patch('metrics.metrics_utils.print_notice', self.print_notice).start()
+
+    # Patch the methods used to get the system information, so we have a known
+    # environment.
+    mock.patch('metrics.tempfile.mkstemp',
+               lambda: (None, '/tmp/metrics.json')).start()
+    mock.patch('metrics.time.time',
+               TimeMock()).start()
+    mock.patch('metrics.metrics_utils.get_python_version',
+               lambda: '2.7.13').start()
+    mock.patch('metrics.gclient_utils.GetMacWinOrLinux',
+               lambda: 'linux').start()
+    mock.patch('metrics.detect_host_arch.HostArch',
+               lambda: 'x86').start()
+    mock.patch('metrics_utils.get_repo_timestamp',
+               lambda _: 1234).start()
+
+    self.default_metrics = {
+        "python_version": "2.7.13",
+        "execution_time": 1000,
+        "timestamp": 0,
+        "exit_code": 0,
+        "command": "fun",
+        "depot_tools_age": 1234,
+        "host_arch": "x86",
+        "host_os": "linux",
+    }
+
+    self.addCleanup(mock.patch.stopall)
+
+  def assert_collects_metrics(self, update_metrics=None):
+    expected_metrics = self.default_metrics
+    self.default_metrics.update(update_metrics or {})
+    # Assert we invoked the script to upload them.
+    self.Popen.assert_called_with(
+        [sys.executable, metrics.UPLOAD_SCRIPT], stdin=metrics.subprocess.PIPE)
+    # Assert we collected the right metrics.
+    write_call = self.Popen.return_value.stdin.write.call_args
+    collected_metrics = json.loads(write_call[0][0])
+    self.assertEqual(collected_metrics, expected_metrics)
+
+
+  def test_collects_system_information(self):
+    """Tests that we collect information about the runtime environment."""
+    self.FileRead.side_effect = [
+        '{"is-googler": true, "countdown": 0, "opt-in": null}'
+    ]
+    @self.collector.collect_metrics('fun')
+    def fun():
+      pass
+
+    with self.assertRaises(SystemExit) as cm:
+      fun()
+    self.assertEqual(cm.exception.code, 0)
+    self.assert_collects_metrics()
+
+  def test_collects_added_metrics(self):
+    """Tests that we can collect custom metrics."""
+    self.FileRead.side_effect = [
+        '{"is-googler": true, "countdown": 0, "opt-in": null}'
+    ]
+    @self.collector.collect_metrics('fun')
+    def fun():
+      self.collector.add('foo', 'bar')
+
+    with self.assertRaises(SystemExit) as cm:
+      fun()
+    self.assertEqual(cm.exception.code, 0)
+    self.assert_collects_metrics({'foo': 'bar'})
+
+  def test_collects_metrics_when_opted_in(self):
+    """Tests that metrics are collected when the user opts-in."""
+    self.FileRead.side_effect = [
+        '{"is-googler": true, "countdown": 1234, "opt-in": true}'
+    ]
+    @self.collector.collect_metrics('fun')
+    def fun():
+      pass
+
+    with self.assertRaises(SystemExit) as cm:
+      fun()
+    self.assertEqual(cm.exception.code, 0)
+    self.assert_collects_metrics()
+
+  @mock.patch('metrics.DISABLE_METRICS_COLLECTION', True)
+  def test_metrics_collection_disabled(self):
+    """Tests that metrics collection can be disabled via a global variable."""
+    @self.collector.collect_metrics('fun')
+    def fun():
+      pass
+
+    fun()
+
+    # We shouldn't have tried to read the config file.
+    self.assertFalse(self.FileRead.called)
+    # Nor tried to upload any metrics.
+    self.assertFalse(self.Popen.called)
+
+  def test_metrics_collection_disabled_not_googler(self):
+    """Tests that metrics collection is disabled for non googlers."""
+    self.FileRead.side_effect = [
+        '{"is-googler": false, "countdown": 0, "opt-in": null}'
+    ]
+    @self.collector.collect_metrics('fun')
+    def fun():
+      pass
+
+    fun()
+
+    self.assertFalse(self.collector.config.is_googler)
+    self.assertIsNone(self.collector.config.opted_in)
+    self.assertEqual(self.collector.config.countdown, 0)
+    # Assert that we did not try to upload any metrics.
+    self.assertFalse(self.Popen.called)
+
+  def test_metrics_collection_disabled_opted_out(self):
+    """Tests that metrics collection is disabled if the user opts out."""
+    self.FileRead.side_effect = [
+        '{"is-googler": true, "countdown": 0, "opt-in": false}'
+    ]
+    @self.collector.collect_metrics('fun')
+    def fun():
+      pass
+
+    fun()
+
+    self.assertTrue(self.collector.config.is_googler)
+    self.assertFalse(self.collector.config.opted_in)
+    self.assertEqual(self.collector.config.countdown, 0)
+    # Assert that we did not try to upload any metrics.
+    self.assertFalse(self.Popen.called)
+
+  def test_metrics_collection_disabled_non_zero_countdown(self):
+    """Tests that metrics collection is disabled until the countdown expires."""
+    self.FileRead.side_effect = [
+        '{"is-googler": true, "countdown": 1, "opt-in": null}'
+    ]
+    @self.collector.collect_metrics('fun')
+    def fun():
+      pass
+
+    fun()
+
+    self.assertTrue(self.collector.config.is_googler)
+    self.assertFalse(self.collector.config.opted_in)
+    # The countdown should've decreased after the invocation.
+    self.assertEqual(self.collector.config.countdown, 0)
+    # Assert that we did not try to upload any metrics.
+    self.assertFalse(self.Popen.called)
+
+  def test_prints_notice_non_zero_countdown(self):
+    """Tests that a notice is printed while the countdown is non-zero."""
+    self.FileRead.side_effect = [
+        '{"is-googler": true, "countdown": 1234, "opt-in": null}'
+    ]
+    @self.collector.collect_metrics('fun')
+    def fun():
+      pass
+    fun()
+    self.print_notice.assert_called_once_with(1234)
+
+  def test_prints_notice_zero_countdown(self):
+    """Tests that a notice is printed when the countdown reaches 0."""
+    self.FileRead.side_effect = [
+        '{"is-googler": true, "countdown": 0, "opt-in": null}'
+    ]
+    @self.collector.collect_metrics('fun')
+    def fun():
+      pass
+
+    with self.assertRaises(SystemExit) as cm:
+      fun()
+    self.assertEqual(cm.exception.code, 0)
+    self.print_notice.assert_called_once_with(0)
+
+  def test_doesnt_print_notice_opted_in(self):
+    """Tests that a notice is not printed when the user opts-in."""
+    self.FileRead.side_effect = [
+        '{"is-googler": true, "countdown": 0, "opt-in": true}'
+    ]
+    @self.collector.collect_metrics('fun')
+    def fun():
+      pass
+
+    with self.assertRaises(SystemExit) as cm:
+      fun()
+    self.assertEqual(cm.exception.code, 0)
+    self.assertFalse(self.print_notice.called)
+
+  def test_doesnt_print_notice_opted_out(self):
+    """Tests that a notice is not printed when the user opts-out."""
+    self.FileRead.side_effect = [
+        '{"is-googler": true, "countdown": 0, "opt-in": false}'
+    ]
+    @self.collector.collect_metrics('fun')
+    def fun():
+      pass
+
+    fun()
+    self.assertFalse(self.print_notice.called)
+
+  def test_handles_exceptions(self):
+    """Tests that exception are caught and we exit with an appropriate code."""
+    self.FileRead.side_effect = [
+        '{"is-googler": true, "countdown": 0, "opt-in": true}'
+    ]
+    @self.collector.collect_metrics('fun')
+    def fun():
+      raise ValueError
+
+    # When an exception is raised, we should catch it, print the traceback and
+    # invoke sys.exit with a non-zero exit code.
+    with self.assertRaises(SystemExit) as cm:
+      fun()
+    self.assertEqual(cm.exception.code, 1)
+    self.assert_collects_metrics({'exit_code': 1})
+
+  def test_handles_system_exit(self):
+    """Tests that the sys.exit code is respected and metrics are collected."""
+    self.FileRead.side_effect = [
+        '{"is-googler": true, "countdown": 0, "opt-in": true}'
+    ]
+    @self.collector.collect_metrics('fun')
+    def fun():
+      sys.exit(0)
+
+    # When an exception is raised, we should catch it, print the traceback and
+    # invoke sys.exit with a non-zero exit code.
+    with self.assertRaises(SystemExit) as cm:
+      fun()
+    self.assertEqual(cm.exception.code, 0)
+    self.assert_collects_metrics({'exit_code': 0})
+
+  def test_handles_system_exit_non_zero(self):
+    """Tests that the sys.exit code is respected and metrics are collected."""
+    self.FileRead.side_effect = [
+        '{"is-googler": true, "countdown": 0, "opt-in": true}'
+    ]
+    @self.collector.collect_metrics('fun')
+    def fun():
+      sys.exit(123)
+
+    # When an exception is raised, we should catch it, print the traceback and
+    # invoke sys.exit with a non-zero exit code.
+    with self.assertRaises(SystemExit) as cm:
+      fun()
+    self.assertEqual(cm.exception.code, 123)
+    self.assert_collects_metrics({'exit_code': 123})
+
+
+if __name__ == '__main__':
+  unittest.main()
diff --git a/tests/roll_dep_test.py b/tests/roll_dep_test.py
index 56f97cb..42c6ed5 100644
--- a/tests/roll_dep_test.py
+++ b/tests/roll_dep_test.py
@@ -52,6 +52,7 @@
     # Make sure it doesn't try to auto update when testing!
     self.env = os.environ.copy()
     self.env['DEPOT_TOOLS_UPDATE'] = '0'
+    self.env['DEPOT_TOOLS_METRICS'] = '0'
 
     self.enabled = self.FAKE_REPOS.set_up_git()
     self.src_dir = os.path.join(self.root_dir, 'src')
@@ -64,7 +65,7 @@
   def call(self, cmd, cwd=None):
     cwd = cwd or self.src_dir
     process = subprocess.Popen(cmd, cwd=cwd, stdout=subprocess.PIPE,
-                               stderr=subprocess.PIPE,
+                               stderr=subprocess.PIPE, env=self.env,
                                shell=sys.platform.startswith('win'))
     stdout, stderr = process.communicate()
     logging.debug("XXX: %s\n%s\nXXX" % (' '.join(cmd), stdout))
@@ -75,9 +76,12 @@
   def testRollsDep(self):
     if not self.enabled:
       return
-    stdout = self.call([ROLL_DEP, 'src/foo'])[0]
+    stdout, stderr, returncode = self.call([ROLL_DEP, 'src/foo'])
     expected_revision = self.githash('repo_2', 3)
 
+    self.assertEqual(stderr, '')
+    self.assertEqual(returncode, 0)
+
     with open(os.path.join(self.src_dir, 'DEPS')) as f:
       contents = f.read()
 
@@ -99,10 +103,13 @@
   def testRollsDepToSpecificRevision(self):
     if not self.enabled:
       return
-    stdout = self.call([ROLL_DEP, 'src/foo',
-                        '--roll-to', self.githash('repo_2', 2)])[0]
+    stdout, stderr, returncode = self.call(
+        [ROLL_DEP, 'src/foo', '--roll-to', self.githash('repo_2', 2)])
     expected_revision = self.githash('repo_2', 2)
 
+    self.assertEqual(stderr, '')
+    self.assertEqual(returncode, 0)
+
     with open(os.path.join(self.src_dir, 'DEPS')) as f:
       contents = f.read()
 
diff --git a/upload_metrics.py b/upload_metrics.py
new file mode 100644
index 0000000..4be5b23
--- /dev/null
+++ b/upload_metrics.py
@@ -0,0 +1,24 @@
+#!/usr/bin/env python
+# Copyright (c) 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 sys
+import urllib2
+
+
+APP_URL = 'https://cit-cli-metrics.appspot.com'
+
+
+def main():
+  metrics = raw_input()
+  try:
+    urllib2.urlopen(APP_URL + '/upload', metrics)
+  except urllib2.HTTPError:
+    pass
+
+  return 0
+
+
+if __name__ == '__main__':
+  sys.exit(main())