metrics.py 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305
  1. #!/usr/bin/env python
  2. # Copyright (c) 2018 The Chromium Authors. All rights reserved.
  3. # Use of this source code is governed by a BSD-style license that can be
  4. # found in the LICENSE file.
  5. from __future__ import print_function
  6. import contextlib
  7. import functools
  8. import json
  9. import os
  10. import sys
  11. import tempfile
  12. import threading
  13. import time
  14. import traceback
  15. try:
  16. import urllib2 as urllib
  17. except ImportError: # For Py3 compatibility
  18. import urllib.request as urllib
  19. import detect_host_arch
  20. import gclient_utils
  21. import metrics_utils
  22. import subprocess2
  23. DEPOT_TOOLS = os.path.dirname(os.path.abspath(__file__))
  24. CONFIG_FILE = os.path.join(DEPOT_TOOLS, 'metrics.cfg')
  25. UPLOAD_SCRIPT = os.path.join(DEPOT_TOOLS, 'upload_metrics.py')
  26. DISABLE_METRICS_COLLECTION = os.environ.get('DEPOT_TOOLS_METRICS') == '0'
  27. DEFAULT_COUNTDOWN = 10
  28. INVALID_CONFIG_WARNING = (
  29. 'WARNING: Your metrics.cfg file was invalid or nonexistent. A new one will '
  30. 'be created.'
  31. )
  32. PERMISSION_DENIED_WARNING = (
  33. 'Could not write the metrics collection config:\n\t%s\n'
  34. 'Metrics collection will be disabled.'
  35. )
  36. class _Config(object):
  37. def __init__(self):
  38. self._initialized = False
  39. self._config = {}
  40. def _ensure_initialized(self):
  41. if self._initialized:
  42. return
  43. try:
  44. config = json.loads(gclient_utils.FileRead(CONFIG_FILE))
  45. except (IOError, ValueError):
  46. config = {}
  47. self._config = config.copy()
  48. if 'is-googler' not in self._config:
  49. # /should-upload is only accessible from Google IPs, so we only need to
  50. # check if we can reach the page. An external developer would get access
  51. # denied.
  52. try:
  53. req = urllib.urlopen(metrics_utils.APP_URL + '/should-upload')
  54. self._config['is-googler'] = req.getcode() == 200
  55. except (urllib.URLError, urllib.HTTPError):
  56. self._config['is-googler'] = False
  57. # Make sure the config variables we need are present, and initialize them to
  58. # safe values otherwise.
  59. self._config.setdefault('countdown', DEFAULT_COUNTDOWN)
  60. self._config.setdefault('opt-in', None)
  61. self._config.setdefault('version', metrics_utils.CURRENT_VERSION)
  62. if config != self._config:
  63. print(INVALID_CONFIG_WARNING, file=sys.stderr)
  64. self._write_config()
  65. self._initialized = True
  66. def _write_config(self):
  67. try:
  68. gclient_utils.FileWrite(CONFIG_FILE, json.dumps(self._config))
  69. except IOError as e:
  70. print(PERMISSION_DENIED_WARNING % e, file=sys.stderr)
  71. self._config['opt-in'] = False
  72. @property
  73. def version(self):
  74. self._ensure_initialized()
  75. return self._config['version']
  76. @property
  77. def is_googler(self):
  78. self._ensure_initialized()
  79. return self._config['is-googler']
  80. @property
  81. def opted_in(self):
  82. self._ensure_initialized()
  83. return self._config['opt-in']
  84. @opted_in.setter
  85. def opted_in(self, value):
  86. self._ensure_initialized()
  87. self._config['opt-in'] = value
  88. self._config['version'] = metrics_utils.CURRENT_VERSION
  89. self._write_config()
  90. @property
  91. def countdown(self):
  92. self._ensure_initialized()
  93. return self._config['countdown']
  94. @property
  95. def should_collect_metrics(self):
  96. # DEPOT_TOOLS_REPORT_BUILD is set on bots to report metrics.
  97. if os.getenv(metrics_utils.DEPOT_TOOLS_REPORT_BUILD):
  98. return True
  99. # Don't report metrics if user is not a Googler.
  100. if not self.is_googler:
  101. return False
  102. # Don't report metrics if user has opted out.
  103. if self.opted_in is False:
  104. return False
  105. # Don't report metrics if countdown hasn't reached 0.
  106. if self.opted_in is None and self.countdown > 0:
  107. return False
  108. return True
  109. def decrease_countdown(self):
  110. self._ensure_initialized()
  111. if self.countdown == 0:
  112. return
  113. self._config['countdown'] -= 1
  114. if self.countdown == 0:
  115. self._config['version'] = metrics_utils.CURRENT_VERSION
  116. self._write_config()
  117. def reset_config(self):
  118. # Only reset countdown if we're already collecting metrics.
  119. if self.should_collect_metrics:
  120. self._ensure_initialized()
  121. self._config['countdown'] = DEFAULT_COUNTDOWN
  122. self._config['opt-in'] = None
  123. class MetricsCollector(object):
  124. def __init__(self):
  125. self._metrics_lock = threading.Lock()
  126. self._reported_metrics = {}
  127. self._config = _Config()
  128. self._collecting_metrics = False
  129. self._collect_custom_metrics = True
  130. @property
  131. def config(self):
  132. return self._config
  133. @property
  134. def collecting_metrics(self):
  135. return self._collecting_metrics
  136. def add(self, name, value):
  137. if self._collect_custom_metrics:
  138. with self._metrics_lock:
  139. self._reported_metrics[name] = value
  140. def add_repeated(self, name, value):
  141. if self._collect_custom_metrics:
  142. with self._metrics_lock:
  143. self._reported_metrics.setdefault(name, []).append(value)
  144. @contextlib.contextmanager
  145. def pause_metrics_collection(self):
  146. collect_custom_metrics = self._collect_custom_metrics
  147. self._collect_custom_metrics = False
  148. try:
  149. yield
  150. finally:
  151. self._collect_custom_metrics = collect_custom_metrics
  152. def _upload_metrics_data(self):
  153. """Upload the metrics data to the AppEngine app."""
  154. # We invoke a subprocess, and use stdin.write instead of communicate(),
  155. # so that we are able to return immediately, leaving the upload running in
  156. # the background.
  157. p = subprocess2.Popen(['vpython3', UPLOAD_SCRIPT], stdin=subprocess2.PIPE)
  158. p.stdin.write(json.dumps(self._reported_metrics).encode('utf-8'))
  159. def _collect_metrics(self, func, command_name, *args, **kwargs):
  160. # If we're already collecting metrics, just execute the function.
  161. # e.g. git-cl split invokes git-cl upload several times to upload each
  162. # split CL.
  163. if self.collecting_metrics:
  164. # Don't collect metrics for this function.
  165. # e.g. Don't record the arguments git-cl split passes to git-cl upload.
  166. with self.pause_metrics_collection():
  167. return func(*args, **kwargs)
  168. self._collecting_metrics = True
  169. self.add('metrics_version', metrics_utils.CURRENT_VERSION)
  170. self.add('command', command_name)
  171. try:
  172. start = time.time()
  173. result = func(*args, **kwargs)
  174. exception = None
  175. # pylint: disable=bare-except
  176. except:
  177. exception = sys.exc_info()
  178. finally:
  179. self.add('execution_time', time.time() - start)
  180. exit_code = metrics_utils.return_code_from_exception(exception)
  181. self.add('exit_code', exit_code)
  182. # Add metrics regarding environment information.
  183. self.add('timestamp', int(time.time()))
  184. self.add('python_version', metrics_utils.get_python_version())
  185. self.add('host_os', gclient_utils.GetMacWinAixOrLinux())
  186. self.add('host_arch', detect_host_arch.HostArch())
  187. depot_tools_age = metrics_utils.get_repo_timestamp(DEPOT_TOOLS)
  188. if depot_tools_age is not None:
  189. self.add('depot_tools_age', int(depot_tools_age))
  190. git_version = metrics_utils.get_git_version()
  191. if git_version:
  192. self.add('git_version', git_version)
  193. bot_metrics = metrics_utils.get_bot_metrics()
  194. if bot_metrics:
  195. self.add('bot_metrics', bot_metrics)
  196. self._upload_metrics_data()
  197. if exception:
  198. gclient_utils.reraise(exception[0], exception[1], exception[2])
  199. return result
  200. def collect_metrics(self, command_name):
  201. """A decorator used to collect metrics over the life of a function.
  202. This decorator executes the function and collects metrics about the system
  203. environment and the function performance.
  204. """
  205. def _decorator(func):
  206. # Do this first so we don't have to read, and possibly create a config
  207. # file.
  208. if DISABLE_METRICS_COLLECTION:
  209. return func
  210. if not self.config.should_collect_metrics:
  211. return func
  212. # Otherwise, collect the metrics.
  213. # Needed to preserve the __name__ and __doc__ attributes of func.
  214. @functools.wraps(func)
  215. def _inner(*args, **kwargs):
  216. return self._collect_metrics(func, command_name, *args, **kwargs)
  217. return _inner
  218. return _decorator
  219. @contextlib.contextmanager
  220. def print_notice_and_exit(self):
  221. """A context manager used to print the notice and terminate execution.
  222. This decorator executes the function and prints the monitoring notice if
  223. necessary. If an exception is raised, we will catch it, and print it before
  224. printing the metrics collection notice.
  225. This will call sys.exit() with an appropriate exit code to ensure the notice
  226. is the last thing printed."""
  227. # Needed to preserve the __name__ and __doc__ attributes of func.
  228. try:
  229. yield
  230. exception = None
  231. # pylint: disable=bare-except
  232. except:
  233. exception = sys.exc_info()
  234. # Print the exception before the metrics notice, so that the notice is
  235. # clearly visible even if gclient fails.
  236. if exception:
  237. if isinstance(exception[1], KeyboardInterrupt):
  238. sys.stderr.write('Interrupted\n')
  239. elif not isinstance(exception[1], SystemExit):
  240. traceback.print_exception(*exception)
  241. # Check if the version has changed
  242. if (not DISABLE_METRICS_COLLECTION and self.config.is_googler
  243. and self.config.opted_in is not False
  244. and self.config.version != metrics_utils.CURRENT_VERSION):
  245. metrics_utils.print_version_change(self.config.version)
  246. self.config.reset_config()
  247. # Print the notice
  248. if (not DISABLE_METRICS_COLLECTION and self.config.is_googler
  249. and self.config.opted_in is None):
  250. metrics_utils.print_notice(self.config.countdown)
  251. self.config.decrease_countdown()
  252. sys.exit(metrics_utils.return_code_from_exception(exception))
  253. collector = MetricsCollector()