metrics.py 11 KB

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