Commit 32e3d1e3 authored by Edward Lemur's avatar Edward Lemur Committed by Commit Bot

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: 's avatarAaron Gable <agable@chromium.org>
parent d64781e1
......@@ -78,3 +78,6 @@ testing_support/google_appengine
# Ignore emacs / vim backup files.
*~
# Ignore the monitoring config. It is unique for each user.
/metrics.cfg
......@@ -104,6 +104,7 @@ import gclient_eval
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 @@ def CMDverify(parser, args):
'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')
......
# 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.
#!/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()
#!/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
......@@ -14,6 +14,10 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
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
......
......@@ -37,6 +37,7 @@ class GClientSmokeBase(fake_repos.FakeReposTestBase):
# 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:
......
......@@ -18,6 +18,10 @@ import unittest
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
......
#!/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()
......@@ -52,6 +52,7 @@ class RollDepTest(fake_repos.FakeReposTestBase):
# 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 @@ class RollDepTest(fake_repos.FakeReposTestBase):
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 @@ class RollDepTest(fake_repos.FakeReposTestBase):
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 @@ class RollDepTest(fake_repos.FakeReposTestBase):
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()
......
#!/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())
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment