From 5eb51c6cf5fdb8ea43720c47e9928e49fea2a884 Mon Sep 17 00:00:00 2001 From: Kernc Date: Thu, 15 Sep 2016 02:01:18 +0200 Subject: [PATCH] canvas: add remote error reporting on unhandled exception --- Orange/canvas/__main__.py | 8 +- Orange/canvas/application/errorreporting.py | 211 ++++++++++++++++++++ Orange/canvas/application/outputview.py | 9 +- 3 files changed, 221 insertions(+), 7 deletions(-) create mode 100644 Orange/canvas/application/errorreporting.py diff --git a/Orange/canvas/__main__.py b/Orange/canvas/__main__.py index beed93c7dc5..356e8528e89 100644 --- a/Orange/canvas/__main__.py +++ b/Orange/canvas/__main__.py @@ -24,6 +24,7 @@ from Orange.canvas.application.application import CanvasApplication from Orange.canvas.application.canvasmain import CanvasMainWindow from Orange.canvas.application.outputview import TextStream, ExceptHook +from Orange.canvas.application.errorreporting import ErrorReporting from Orange.canvas.gui.splashscreen import SplashScreen from Orange.canvas.config import cache_dir @@ -364,11 +365,12 @@ def handle_response(result): stderr.stream.connect(sys.stderr.write) stderr.flushed.connect(sys.stderr.flush) - sys.excepthook = ExceptHook(stream=stderr) - log.info("Entering main event loop.") try: - with patch('sys.stderr', stderr),\ + with patch('sys.excepthook', + ExceptHook(stream=stderr, canvas=canvas_window, + handledException=ErrorReporting.handle_exception)),\ + patch('sys.stderr', stderr),\ patch('sys.stdout', stdout): status = app.exec_() except BaseException: diff --git a/Orange/canvas/application/errorreporting.py b/Orange/canvas/application/errorreporting.py new file mode 100644 index 00000000000..c8e833e640e --- /dev/null +++ b/Orange/canvas/application/errorreporting.py @@ -0,0 +1,211 @@ +import os +import sys +import time +import logging +import platform +import traceback +import uuid +from html import escape +from threading import Thread + +from tempfile import mkstemp +from collections import OrderedDict +from urllib.parse import urljoin, urlencode +from urllib.request import pathname2url, urlopen +from unittest.mock import patch + +from PyQt4.QtCore import pyqtSlot, QSettings, Qt +from PyQt4.QtGui import ( + QApplication, QCheckBox, QDesktopServices, QDialog, QFont, QHBoxLayout, + QLabel, QMessageBox, QPushButton, QStyle, QTextBrowser, + QVBoxLayout, QWidget +) + +try: + from Orange.widgets.widget import OWWidget + from Orange.version import full_version as VERSION_STR +except ImportError: + # OWWidget (etc.) is not available because this is not Orange + class OWWidget: pass + VERSION_STR = '???' + + +REPORT_POST_URL = 'http://qa.orange.biolab.si/error-report/v1/' + +log = logging.getLogger() + + +class ErrorReporting(QDialog): + _cache = set() # For errors already handled during one session + + class DataField: + EXCEPTION = 'Exception' + MODULE = 'Module' + WIDGET_NAME = 'Widget Name' + WIDGET_MODULE = 'Widget Module' + VERSION = 'Version' + ENVIRONMENT = 'Environment' + MACHINE_ID = 'Machine ID' + WIDGET_SCHEME = 'Widget Scheme' + STACK_TRACE = 'Stack Trace' + + def __init__(self, data): + icon = QApplication.style().standardIcon(QStyle.SP_MessageBoxWarning) + F = self.DataField + + def _finished(*, key=(data[F.MODULE], data[F.WIDGET_MODULE]), + filename=data[F.WIDGET_SCHEME]): + self._cache.add(key) + try: os.remove(filename) + except Exception: pass + + super().__init__(None, Qt.Window, modal=True, + sizeGripEnabled=True, windowIcon=icon, + windowTitle='Unexpected Error', + finished=_finished) + self._data = data + + layout = QVBoxLayout(self) + self.setLayout(layout) + labels = QWidget(self) + labels_layout = QHBoxLayout(self) + labels.setLayout(labels_layout) + labels_layout.addWidget(QLabel(pixmap=icon.pixmap(50, 50))) + labels_layout.addWidget(QLabel( + 'The program encountered an unexpected error. Please
' + 'report it anonymously to the developers.

' + 'The following data will be reported:')) + labels_layout.addStretch(1) + layout.addWidget(labels) + font = QFont('Monospace', 10) + font.setStyleHint(QFont.Monospace) + font.setFixedPitch(True) + textbrowser = QTextBrowser(self, + font=font, + openLinks=False, + lineWrapMode=QTextBrowser.NoWrap, + anchorClicked=QDesktopServices.openUrl) + layout.addWidget(textbrowser) + + def _reload_text(): + add_scheme = cb.isChecked() + settings.setValue('error-reporting/add-scheme', add_scheme) + lines = [''] + for k, v in data.items(): + if k.startswith('_'): + continue + _v, v = v, escape(v) + if k == F.WIDGET_SCHEME: + if not add_scheme: + continue + v = '{}'.format(urljoin('file:', pathname2url(_v)), v) + if k == F.STACK_TRACE: + v = v.replace('\n', '
').replace(' ', ' ') + lines.append(''.format(k, v)) + lines.append('
{}:{}
') + textbrowser.setHtml(''.join(lines)) + + settings = QSettings() + cb = QCheckBox( + 'Include workflow (data will NOT be transmitted)', self, + checked=settings.value('error-reporting/add-scheme', True, type=bool)) + cb.stateChanged.connect(_reload_text) + _reload_text() + + layout.addWidget(cb) + buttons = QWidget(self) + buttons_layout = QHBoxLayout(self) + buttons.setLayout(buttons_layout) + buttons_layout.addWidget(QPushButton('Send Report (Thanks!)', default=True, clicked=self.accept)) + buttons_layout.addWidget(QPushButton("Don't Send", default=False, clicked=self.reject)) + layout.addWidget(buttons) + + def accept(self): + super().accept() + F = self.DataField + data = self._data.copy() + + if QSettings().value('error-reporting/add-scheme', True, type=bool): + data[F.WIDGET_SCHEME] = data['_' + F.WIDGET_SCHEME] + del data['_' + F.WIDGET_SCHEME] + + def _post_report(data): + MAX_RETRIES = 2 + for _retry in range(MAX_RETRIES): + try: + urlopen(REPORT_POST_URL, + timeout=10, + data=urlencode(data).encode('utf8')) + except Exception as e: + if _retry == MAX_RETRIES - 1: + e.__context__ = None + log.exception('Error reporting failed', exc_info=e) + time.sleep(10) + continue + break + + Thread(target=_post_report, args=(data,)).start() + + @classmethod + @patch('sys.excepthook', sys.__excepthook__) # Prevent recursion + @pyqtSlot(object) + def handle_exception(cls, exc): + (etype, evalue, tb), canvas = exc + exception = traceback.format_exception_only(etype, evalue)[-1].strip() + stacktrace = ''.join(traceback.format_exception(etype, evalue, tb)) + + def _find_last_frame(tb): + while tb.tb_next: + tb = tb.tb_next + return tb + + frame = _find_last_frame(tb) + err_module = '{}:{}'.format( + frame.tb_frame.f_globals.get('__name__', frame.tb_frame.f_code.co_filename), + frame.tb_lineno) + + def _find_widget_frame(tb): + while tb: + if isinstance(tb.tb_frame.f_locals.get('self'), OWWidget): + return tb + tb = tb.tb_next + + widget, frame = None, _find_widget_frame(tb) + if frame: + widget = frame.tb_frame.f_locals['self'].__class__ + widget_module = '{}:{}'.format(widget.__module__, frame.tb_lineno) + + # If this exact error was already reported in this session, + # just warn about it + if (err_module, widget_module) in cls._cache: + QMessageBox(QMessageBox.Warning, 'Error Encountered', + 'Error encountered{}:

{}'.format( + ' in widget {}'.format(widget.name) if widget else '', + stacktrace.replace('\n', '
').replace(' ', ' ')), + QMessageBox.Ignore).exec() + return + + F = cls.DataField + data = OrderedDict() + data[F.EXCEPTION] = exception + data[F.MODULE] = err_module + if widget: + data[F.WIDGET_NAME] = widget.name + data[F.WIDGET_MODULE] = widget_module + if canvas: + filename = mkstemp(prefix='ows-', suffix='.ows.xml')[1] + # Prevent excepthook printing the same exception when + # canvas tries to instantiate the broken widget again + with patch('sys.excepthook', lambda *_: None): + canvas.save_scheme_to(canvas.current_document().scheme(), filename) + data[F.WIDGET_SCHEME] = filename + with open(filename) as f: + data['_' + F.WIDGET_SCHEME] = f.read() + data[F.VERSION] = VERSION_STR + data[F.ENVIRONMENT] = 'Python {} on {} {} {} {}'.format( + platform.python_version(), platform.system(), platform.release(), + platform.version(), platform.machine()) + data[F.MACHINE_ID] = str(uuid.getnode()) + data[F.STACK_TRACE] = stacktrace + + cls(data=data).exec() diff --git a/Orange/canvas/application/outputview.py b/Orange/canvas/application/outputview.py index 11b7e6c2278..fa0cbbdc472 100644 --- a/Orange/canvas/application/outputview.py +++ b/Orange/canvas/application/outputview.py @@ -211,11 +211,12 @@ def flush(self): class ExceptHook(QObject): - handledException = Signal() + handledException = Signal(object) - def __init__(self, parent=None, stream=None): - QObject.__init__(self, parent) + def __init__(self, parent=None, stream=None, canvas=None, **kwargs): + QObject.__init__(self, parent, **kwargs) self._stream = stream + self._canvas = canvas def __call__(self, exc_type, exc_value, tb): if self._stream: @@ -227,4 +228,4 @@ def __call__(self, exc_type, exc_value, tb): text.append('-' * 79 + '\n') self._stream.writelines(text) - self.handledException.emit() + self.handledException.emit(((exc_type, exc_value, tb), self._canvas))