1"""Collect and send usage analytics""" 2 3from __future__ import unicode_literals 4 5from dvc.utils.compat import str 6 7import os 8import json 9import errno 10 11import dvc.logger as logger 12from dvc import VERSION 13 14 15class Analytics(object): 16 """Class for collecting and sending usage analytics. 17 18 Args: 19 info (dict): optional existing analytics report. 20 """ 21 22 URL = "https://analytics.dvc.org" 23 TIMEOUT_POST = 5 24 25 USER_ID_FILE = "user_id" 26 27 PARAM_DVC_VERSION = "dvc_version" 28 PARAM_USER_ID = "user_id" 29 PARAM_SYSTEM_INFO = "system_info" 30 31 PARAM_OS = "os" 32 33 PARAM_WINDOWS_VERSION_MAJOR = "windows_version_major" 34 PARAM_WINDOWS_VERSION_MINOR = "windows_version_minor" 35 PARAM_WINDOWS_VERSION_BUILD = "windows_version_build" 36 PARAM_WINDOWS_VERSION_SERVICE_PACK = "windows_version_service_pack" 37 38 PARAM_MAC_VERSION = "mac_version" 39 40 PARAM_LINUX_DISTRO = "linux_distro" 41 PARAM_LINUX_DISTRO_VERSION = "linux_distro_version" 42 PARAM_LINUX_DISTRO_LIKE = "linux_distro_like" 43 44 PARAM_SCM_CLASS = "scm_class" 45 PARAM_IS_BINARY = "is_binary" 46 PARAM_CMD_CLASS = "cmd_class" 47 PARAM_CMD_RETURN_CODE = "cmd_return_code" 48 49 def __init__(self, info=None): 50 from dvc.config import Config 51 from dvc.lock import Lock 52 53 if info is None: 54 info = {} 55 56 self.info = info 57 58 cdir = Config.get_global_config_dir() 59 try: 60 os.makedirs(cdir) 61 except OSError as exc: 62 if exc.errno != errno.EEXIST: 63 raise 64 65 self.user_id_file = os.path.join(cdir, self.USER_ID_FILE) 66 self.user_id_file_lock = Lock(cdir, self.USER_ID_FILE + ".lock") 67 68 @staticmethod 69 def load(path): 70 """Loads analytics report from json file specified by path. 71 72 Args: 73 path (str): path to json file with analytics report. 74 """ 75 with open(path, "r") as fobj: 76 analytics = Analytics(info=json.load(fobj)) 77 os.unlink(path) 78 return analytics 79 80 def _write_user_id(self): 81 import uuid 82 83 with open(self.user_id_file, "w+") as fobj: 84 user_id = str(uuid.uuid4()) 85 info = {self.PARAM_USER_ID: user_id} 86 json.dump(info, fobj) 87 return user_id 88 89 def _read_user_id(self): 90 if not os.path.exists(self.user_id_file): 91 return None 92 93 with open(self.user_id_file, "r") as fobj: 94 try: 95 info = json.load(fobj) 96 return info[self.PARAM_USER_ID] 97 except json.JSONDecodeError as exc: 98 logger.debug("Failed to load user_id: {}".format(exc)) 99 return None 100 101 def _get_user_id(self): 102 from dvc.lock import LockError 103 104 try: 105 with self.user_id_file_lock: 106 user_id = self._read_user_id() 107 if user_id is None: 108 user_id = self._write_user_id() 109 return user_id 110 except LockError: 111 msg = "Failed to acquire '{}'" 112 logger.debug(msg.format(self.user_id_file_lock.lock_file)) 113 114 def _collect_windows(self): 115 import sys 116 117 version = sys.getwindowsversion() # pylint: disable=no-member 118 info = {} 119 info[self.PARAM_OS] = "windows" 120 info[self.PARAM_WINDOWS_VERSION_MAJOR] = version.major 121 info[self.PARAM_WINDOWS_VERSION_MINOR] = version.minor 122 info[self.PARAM_WINDOWS_VERSION_BUILD] = version.build 123 info[self.PARAM_WINDOWS_VERSION_SERVICE_PACK] = version.service_pack 124 return info 125 126 def _collect_darwin(self): 127 import platform 128 129 info = {} 130 info[self.PARAM_OS] = "mac" 131 info[self.PARAM_MAC_VERSION] = platform.mac_ver()[0] 132 return info 133 134 def _collect_linux(self): 135 import distro 136 137 info = {} 138 info[self.PARAM_OS] = "linux" 139 info[self.PARAM_LINUX_DISTRO] = distro.id() 140 info[self.PARAM_LINUX_DISTRO_VERSION] = distro.version() 141 info[self.PARAM_LINUX_DISTRO_LIKE] = distro.like() 142 return info 143 144 def _collect_system_info(self): 145 import platform 146 147 system = platform.system() 148 149 if system == "Windows": 150 return self._collect_windows() 151 152 if system == "Darwin": 153 return self._collect_darwin() 154 155 if system == "Linux": 156 return self._collect_linux() 157 158 raise NotImplementedError 159 160 def collect(self): 161 """Collect analytics report.""" 162 from dvc.scm import SCM 163 from dvc.utils import is_binary 164 from dvc.repo import Repo 165 from dvc.exceptions import NotDvcRepoError 166 167 self.info[self.PARAM_DVC_VERSION] = VERSION 168 self.info[self.PARAM_IS_BINARY] = is_binary() 169 self.info[self.PARAM_USER_ID] = self._get_user_id() 170 171 self.info[self.PARAM_SYSTEM_INFO] = self._collect_system_info() 172 173 try: 174 scm = SCM(root_dir=Repo.find_root()) 175 self.info[self.PARAM_SCM_CLASS] = type(scm).__name__ 176 except NotDvcRepoError: 177 pass 178 179 def collect_cmd(self, args, ret): 180 """Collect analytics info from a CLI command.""" 181 from dvc.command.daemon import CmdDaemonAnalytics 182 183 assert isinstance(ret, int) or ret is None 184 185 if ret is not None: 186 self.info[self.PARAM_CMD_RETURN_CODE] = ret 187 188 if args is not None and hasattr(args, "func"): 189 assert args.func != CmdDaemonAnalytics 190 self.info[self.PARAM_CMD_CLASS] = args.func.__name__ 191 192 def dump(self): 193 """Save analytics report to a temporary file. 194 195 Returns: 196 str: path to the temporary file that contains the analytics report. 197 """ 198 import tempfile 199 200 with tempfile.NamedTemporaryFile(delete=False, mode="w") as fobj: 201 json.dump(self.info, fobj) 202 return fobj.name 203 204 @staticmethod 205 def _is_enabled(cmd=None): 206 from dvc.config import Config 207 from dvc.repo import Repo 208 from dvc.exceptions import NotDvcRepoError 209 from dvc.command.daemon import CmdDaemonBase 210 211 if os.getenv("DVC_TEST"): 212 return False 213 214 if isinstance(cmd, CmdDaemonBase): 215 return False 216 217 if cmd is None or not hasattr(cmd, "config"): 218 try: 219 dvc_dir = Repo.find_dvc_dir() 220 config = Config(dvc_dir) 221 assert config is not None 222 except NotDvcRepoError: 223 config = Config(validate=False) 224 assert config is not None 225 else: 226 config = cmd.config 227 assert config is not None 228 229 core = config.config.get(Config.SECTION_CORE, {}) 230 enabled = core.get(Config.SECTION_CORE_ANALYTICS, True) 231 logger.debug( 232 "Analytics is {}abled.".format("en" if enabled else "dis") 233 ) 234 return enabled 235 236 @staticmethod 237 def send_cmd(cmd, args, ret): 238 """Collect and send analytics for CLI command. 239 240 Args: 241 args (list): parsed args for the CLI command. 242 ret (int): return value of the CLI command. 243 """ 244 from dvc.daemon import daemon 245 246 if not Analytics._is_enabled(cmd): 247 return 248 249 analytics = Analytics() 250 analytics.collect_cmd(args, ret) 251 daemon(["analytics", analytics.dump()]) 252 253 def send(self): 254 """Collect and send analytics.""" 255 import requests 256 257 if not self._is_enabled(): 258 return 259 260 self.collect() 261 262 logger.debug("Sending analytics: {}".format(self.info)) 263 264 try: 265 requests.post(self.URL, json=self.info, timeout=self.TIMEOUT_POST) 266 except requests.exceptions.RequestException as exc: 267 logger.debug("Failed to send analytics: {}".format(str(exc))) 268