metrics.py 11 KB

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