metrics.py 10 KB

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