1import enum 2import itertools 3from datetime import datetime 4import platform 5import json 6import logging 7import os 8from typing import List 9 10from AnyQt.QtCore import QCoreApplication, QSettings 11 12from orangecanvas import config 13from orangecanvas.scheme import SchemeNode, SchemeLink, Scheme 14 15log = logging.getLogger(__name__) 16 17 18class EventType(enum.IntEnum): 19 NodeAdd = 0 20 NodeRemove = 1 21 LinkAdd = 2 22 LinkRemove = 3 23 24 25class ActionType(enum.IntEnum): 26 Unclassified = 0 27 ToolboxClick = 1 28 ToolboxDrag = 2 29 QuickMenu = 3 30 ExtendFromSource = 4 31 ExtendFromSink = 5 32 InsertDrag = 6 33 InsertMenu = 7 34 Undo = 8 35 Redo = 9 36 Duplicate = 10 37 Load = 11 38 39 40class UsageStatistics: 41 """ 42 Tracks usage statistics if enabled (is disabled by default). 43 44 Data is tracked and stored in application data directory in 45 'usage-statistics.json' file. 46 47 It is the application's responsibility to ask for permission and 48 appropriately handle the collected statistics. 49 50 Data tracked per canvas session: 51 date, 52 application version, 53 operating system, 54 anaconda boolean, 55 UUID (in Orange3), 56 a sequence of actions of type ActionType 57 58 An action consists of one or more events of type EventType. 59 Events refer to nodes according to a unique integer ID. 60 Each node is also associated with a widget name, assigned in a NodeAdd event. 61 Link events also reference corresponding source/sink channel names. 62 63 Some actions carry metadata (e.g. search query for QuickMenu, Extend). 64 65 Parameters 66 ---------- 67 parent: SchemeEditWidget 68 """ 69 _is_enabled = False 70 statistics_sessions = [] 71 last_search_query = None 72 source_open = False 73 sink_open = False 74 75 Unclassified, ToolboxClick, ToolboxDrag, QuickMenu, ExtendFromSink, ExtendFromSource, \ 76 InsertDrag, InsertMenu, Undo, Redo, Duplicate, Load \ 77 = list(ActionType) 78 79 def __init__(self, parent): 80 self.parent = parent 81 82 self._actions = [] 83 self._events = [] 84 self._widget_ids = {} 85 self._id_iter = itertools.count() 86 87 self._action_type = ActionType.Unclassified 88 self._metadata = None 89 90 UsageStatistics.statistics_sessions.append(self) 91 92 @classmethod 93 def is_enabled(cls) -> bool: 94 """ 95 Returns 96 ------- 97 enabled : bool 98 Is usage collection enabled. 99 """ 100 return cls._is_enabled 101 102 @classmethod 103 def set_enabled(cls, state: bool) -> None: 104 """ 105 Enable/disable usage collection. 106 107 Parameters 108 ---------- 109 state : bool 110 """ 111 if cls._is_enabled == state: 112 return 113 114 cls._is_enabled = state 115 log.info("{} usage statistics tracking".format( 116 "Enabling" if state else "Disabling" 117 )) 118 for session in UsageStatistics.statistics_sessions: 119 if state: 120 # log current scheme state after enabling of statistics 121 scheme = session.parent.scheme() 122 session.log_scheme(scheme) 123 else: 124 session.drop_statistics() 125 126 def begin_action(self, action_type): 127 """ 128 Sets the type of action that will be logged upon next call to a log method. 129 130 Each call to begin_action() should be matched with a call to end_action(). 131 132 Parameters 133 ---------- 134 action_type : ActionType 135 """ 136 if not self.is_enabled(): 137 return 138 139 if self._action_type != self.Unclassified: 140 raise ValueError("Tried to set " + str(action_type) + \ 141 " but " + str(self._action_type) + " was already set.") 142 143 self._prepare_action(action_type) 144 145 def begin_extend_action(self, from_sink, extended_widget): 146 """ 147 Sets the type of action to widget extension in the specified direction, 148 noting the extended widget and query. 149 150 Each call to begin_extend_action() should be matched with a call to end_action(). 151 152 Parameters 153 ---------- 154 from_sink : bool 155 extended_widget : SchemeNode 156 """ 157 if not self.is_enabled(): 158 return 159 160 if self._events: 161 log.error("Tried to start extend action while current action already has events") 162 return 163 164 # set action type 165 if from_sink: 166 action_type = ActionType.ExtendFromSink 167 else: 168 action_type = ActionType.ExtendFromSource 169 170 # set metadata 171 if extended_widget not in self._widget_ids: 172 log.error("Attempted to extend widget before it was logged. No action type was set.") 173 return 174 extended_id = self._widget_ids[extended_widget] 175 176 metadata = {"Extended Widget": extended_id} 177 178 self._prepare_action(action_type, metadata) 179 180 def begin_insert_action(self, via_drag, original_link): 181 """ 182 Sets the type of action to widget insertion via the specified way, 183 noting the old link's source and sink widgets. 184 185 Each call to begin_insert_action() should be matched with a call to end_action(). 186 187 Parameters 188 ---------- 189 via_drag : bool 190 original_link : SchemeLink 191 """ 192 if not self.is_enabled(): 193 return 194 195 if self._events: 196 log.error("Tried to start insert action while current action already has events") 197 return 198 199 source_widget = original_link.source_node 200 sink_widget = original_link.sink_node 201 202 # set action type 203 if via_drag: 204 action_type = ActionType.InsertDrag 205 else: 206 action_type = ActionType.InsertMenu 207 208 # set metadata 209 if source_widget not in self._widget_ids or sink_widget not in self._widget_ids: 210 log.error("Attempted to log insert action between unknown widgets. " 211 "No action was logged.") 212 self._clear_action() 213 return 214 src_id, sink_id = self._widget_ids[source_widget], self._widget_ids[sink_widget] 215 216 metadata = {"Source Widget": src_id, 217 "Sink Widget": sink_id} 218 219 self._prepare_action(action_type, metadata) 220 221 def _prepare_action(self, action_type, metadata=None): 222 """ 223 Sets the type of action and metadata that will be logged upon next call to a log method. 224 225 Parameters 226 ---------- 227 action_type : ActionType 228 metadata : Dict[str, Any] 229 """ 230 self._action_type = action_type 231 self._metadata = metadata 232 233 def end_action(self): 234 """ 235 Ends the started action, concatenating the relevant events and adding it to 236 the list of actions. 237 """ 238 if not self.is_enabled(): 239 return 240 241 if not self._events: 242 log.info("End action called but no events were logged.") 243 self._clear_action() 244 return 245 246 action = { 247 "Type": self._action_type, 248 "Events": self._events 249 } 250 251 # add metadata 252 if self._metadata: 253 action.update(self._metadata) 254 255 # add search query if relevant 256 if self._action_type in {ActionType.ExtendFromSource, ActionType.ExtendFromSink, 257 ActionType.QuickMenu}: 258 action["Query"] = self.last_search_query 259 260 self._actions.append(action) 261 self._clear_action() 262 263 def _clear_action(self): 264 """ 265 Clear the current action. 266 """ 267 self._events = [] 268 self._action_type = ActionType.Unclassified 269 self._metadata = None 270 self.last_search_query = "" 271 272 def log_node_add(self, widget): 273 """ 274 Logs an node addition action, based on the currently set action type. 275 276 Parameters 277 ---------- 278 widget : SchemeNode 279 """ 280 if not self.is_enabled(): 281 return 282 283 # get or generate id for widget 284 if widget in self._widget_ids: 285 widget_id = self._widget_ids[widget] 286 else: 287 widget_id = next(self._id_iter) 288 self._widget_ids[widget] = widget_id 289 290 event = { 291 "Type": EventType.NodeAdd, 292 "Widget Name": widget.description.id, 293 "Widget": widget_id 294 } 295 296 self._events.append(event) 297 298 def log_node_remove(self, widget): 299 """ 300 Logs an node removal action. 301 302 Parameters 303 ---------- 304 widget : SchemeNode 305 """ 306 if not self.is_enabled(): 307 return 308 309 # get id for widget 310 if widget not in self._widget_ids: 311 log.error("Attempted to log node removal before its addition. No action was logged.") 312 self._clear_action() 313 return 314 widget_id = self._widget_ids[widget] 315 316 event = { 317 "Type": EventType.NodeRemove, 318 "Widget": widget_id 319 } 320 321 self._events.append(event) 322 323 def log_link_add(self, link): 324 """ 325 Logs a link addition action. 326 327 Parameters 328 ---------- 329 link : SchemeLink 330 """ 331 if not self.is_enabled(): 332 return 333 334 self._log_link(EventType.LinkAdd, link) 335 336 def log_link_remove(self, link): 337 """ 338 Logs a link removal action. 339 340 Parameters 341 ---------- 342 link : SchemeLink 343 """ 344 if not self.is_enabled(): 345 return 346 347 self._log_link(EventType.LinkRemove, link) 348 349 def _log_link(self, action_type, link): 350 source_widget = link.source_node 351 sink_widget = link.sink_node 352 353 # get id for widgets 354 if source_widget not in self._widget_ids or sink_widget not in self._widget_ids: 355 log.error("Attempted to log link action between unknown widgets. No action was logged.") 356 self._clear_action() 357 return 358 359 src_id, sink_id = self._widget_ids[source_widget], self._widget_ids[sink_widget] 360 361 event = { 362 "Type": action_type, 363 "Source Widget": src_id, 364 "Sink Widget": sink_id, 365 "Source Channel": link.source_channel.name, 366 "Sink Channel": link.sink_channel.name, 367 "Source Open": UsageStatistics.source_open, 368 "Sink Open:": UsageStatistics.sink_open, 369 } 370 371 self._events.append(event) 372 373 def log_scheme(self, scheme): 374 """ 375 Log all nodes and links in a scheme. 376 377 Parameters 378 ---------- 379 scheme : Scheme 380 """ 381 if not self.is_enabled(): 382 return 383 384 if not scheme or not scheme.nodes: 385 return 386 387 self.begin_action(ActionType.Load) 388 389 # first log nodes 390 for node in scheme.nodes: 391 self.log_node_add(node) 392 393 # then log links 394 for link in scheme.links: 395 self.log_link_add(link) 396 397 self.end_action() 398 399 def drop_statistics(self): 400 """ 401 Clear all data in the statistics session. 402 """ 403 self._actions = [] 404 self._widget_ids = {} 405 self._id_iter = itertools.count() 406 407 def write_statistics(self): 408 """ 409 Write the statistics session to file, and clear it. 410 """ 411 if not self.is_enabled(): 412 return 413 414 statistics_path = self.filename() 415 statistics = { 416 "Date": str(datetime.now().date()), 417 "Application Version": QCoreApplication.applicationVersion(), 418 "Operating System": platform.system() + " " + platform.release(), 419 "Launch Count": QSettings().value('startup/launch-count', 0, type=int), 420 "Session": self._actions 421 } 422 423 if os.path.isfile(statistics_path): 424 with open(statistics_path) as f: 425 data = json.load(f) 426 else: 427 data = [] 428 429 data.append(statistics) 430 431 with open(statistics_path, 'w') as f: 432 json.dump(data, f) 433 434 self.drop_statistics() 435 436 def close(self): 437 """ 438 Close statistics session, effectively not updating it upon 439 toggling statistics tracking. 440 """ 441 UsageStatistics.statistics_sessions.remove(self) 442 443 @staticmethod 444 def set_last_search_query(query): 445 if not UsageStatistics.is_enabled(): 446 return 447 448 UsageStatistics.last_search_query = query 449 450 @staticmethod 451 def set_source_anchor_open(is_open): 452 if not UsageStatistics.is_enabled(): 453 return 454 455 UsageStatistics.source_open = is_open 456 457 @staticmethod 458 def set_sink_anchor_open(is_open): 459 if not UsageStatistics.is_enabled(): 460 return 461 462 UsageStatistics.sink_open = is_open 463 464 @staticmethod 465 def filename() -> str: 466 """ 467 Return the filename path where the statistics are saved 468 """ 469 return os.path.join(config.data_dir(), "usage-statistics.json") 470 471 @staticmethod 472 def load() -> 'List[dict]': 473 """ 474 Load and return the usage statistics data. 475 476 Returns 477 ------- 478 data : dict 479 """ 480 if not UsageStatistics.is_enabled(): 481 return [] 482 try: 483 with open(UsageStatistics.filename(), "r", encoding="utf-8") as f: 484 return json.load(f) 485 except (FileNotFoundError, PermissionError, IsADirectoryError, 486 UnicodeDecodeError, json.JSONDecodeError): 487 return [] 488