metrics.py 11 KB

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