diff --git a/cura/CrashHandler.py b/cura/CrashHandler.py index 1ec00787d7..09fda25a73 100644 --- a/cura/CrashHandler.py +++ b/cura/CrashHandler.py @@ -1,4 +1,4 @@ -# Copyright (c) 2018 Ultimaker B.V. +# Copyright (c) 2019 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. import platform @@ -7,13 +7,14 @@ import faulthandler import tempfile import os import os.path -import time +import uuid import json -import ssl -import urllib.request -import urllib.error +import locale +from typing import cast -import certifi +from sentry_sdk.hub import Hub +from sentry_sdk.utils import event_from_exception +from sentry_sdk import configure_scope from PyQt5.QtCore import QT_VERSION_STR, PYQT_VERSION_STR, QUrl from PyQt5.QtWidgets import QDialog, QDialogButtonBox, QVBoxLayout, QLabel, QTextEdit, QGroupBox, QCheckBox, QPushButton @@ -24,7 +25,6 @@ from UM.Logger import Logger from UM.View.GL.OpenGL import OpenGL from UM.i18n import i18nCatalog from UM.Resources import Resources - from cura import ApplicationMetadata catalog = i18nCatalog("cura") @@ -46,9 +46,8 @@ skip_exception_types = [ GeneratorExit ] -class CrashHandler: - crash_url = "https://stats.ultimaker.com/api/cura" +class CrashHandler: def __init__(self, exception_type, value, tb, has_started = True): self.exception_type = exception_type self.value = value @@ -56,21 +55,20 @@ class CrashHandler: self.has_started = has_started self.dialog = None # Don't create a QDialog before there is a QApplication - # While we create the GUI, the information will be stored for sending afterwards - self.data = dict() - self.data["time_stamp"] = time.time() - Logger.log("c", "An uncaught error has occurred!") for line in traceback.format_exception(exception_type, value, tb): for part in line.rstrip("\n").split("\n"): Logger.log("c", part) - + self.data = {} # If Cura has fully started, we only show fatal errors. # If Cura has not fully started yet, we always show the early crash dialog. Otherwise, Cura will just crash # without any information. if has_started and exception_type in skip_exception_types: return + with configure_scope() as scope: + scope.set_tag("during_startup", not has_started) + if not has_started: self._send_report_checkbox = None self.early_crash_dialog = self._createEarlyCrashDialog() @@ -179,25 +177,42 @@ class CrashHandler: try: from UM.Application import Application self.cura_version = Application.getInstance().getVersion() + self.cura_locale = Application.getInstance().getPreferences().getValue("general/language") except: self.cura_version = catalog.i18nc("@label unknown version of Cura", "Unknown") + self.cura_locale = "??_??" + + self.data["cura_version"] = self.cura_version + self.data["os"] = {"type": platform.system(), "version": platform.version()} + self.data["qt_version"] = QT_VERSION_STR + self.data["pyqt_version"] = PYQT_VERSION_STR + self.data["locale_os"] = locale.getlocale(locale.LC_MESSAGES)[0] if hasattr(locale, "LC_MESSAGES") else \ + locale.getdefaultlocale()[0] + self.data["locale_cura"] = self.cura_locale crash_info = "" + catalog.i18nc("@label Cura version number", "Cura version") + ": " + str(self.cura_version) + "
" - crash_info += "" + catalog.i18nc("@label Cura build type", "Cura build type") + ": " + str(ApplicationMetadata.CuraBuildType) + "
" + crash_info += "" + catalog.i18nc("@label", "Cura language") + ": " + str(self.cura_locale) + "
" + crash_info += "" + catalog.i18nc("@label", "OS language") + ": " + str(self.data["locale_os"]) + "
" crash_info += "" + catalog.i18nc("@label Type of platform", "Platform") + ": " + str(platform.platform()) + "
" crash_info += "" + catalog.i18nc("@label", "Qt version") + ": " + str(QT_VERSION_STR) + "
" crash_info += "" + catalog.i18nc("@label", "PyQt version") + ": " + str(PYQT_VERSION_STR) + "
" crash_info += "" + catalog.i18nc("@label OpenGL version", "OpenGL") + ": " + str(self._getOpenGLInfo()) + "
" + label.setText(crash_info) layout.addWidget(label) group.setLayout(layout) - self.data["cura_version"] = self.cura_version - self.data["cura_build_type"] = ApplicationMetadata.CuraBuildType - self.data["os"] = {"type": platform.system(), "version": platform.version()} - self.data["qt_version"] = QT_VERSION_STR - self.data["pyqt_version"] = PYQT_VERSION_STR + with configure_scope() as scope: + scope.set_tag("qt_version", QT_VERSION_STR) + scope.set_tag("pyqt_version", PYQT_VERSION_STR) + scope.set_tag("os", platform.system()) + scope.set_tag("os_version", platform.version()) + scope.set_tag("locale_os", self.data["locale_os"]) + scope.set_tag("locale_cura", self.cura_locale) + scope.set_tag("is_enterprise", ApplicationMetadata.IsEnterpriseVersion) + + scope.set_user({"id": str(uuid.getnode())}) return group @@ -215,6 +230,30 @@ class CrashHandler: self.data["opengl"] = {"version": opengl_instance.getOpenGLVersion(), "vendor": opengl_instance.getGPUVendorName(), "type": opengl_instance.getGPUType()} + active_machine_definition_id = "unknown" + active_machine_manufacturer = "unknown" + + try: + from cura.CuraApplication import CuraApplication + application = cast(CuraApplication, Application.getInstance()) + machine_manager = application.getMachineManager() + global_stack = machine_manager.activeMachine + if global_stack is None: + active_machine_definition_id = "empty" + active_machine_manufacturer = "empty" + else: + active_machine_definition_id = global_stack.definition.getId() + active_machine_manufacturer = global_stack.definition.getMetaDataEntry("manufacturer", "unknown") + except: + pass + + with configure_scope() as scope: + scope.set_tag("opengl_version", opengl_instance.getOpenGLVersion()) + scope.set_tag("gpu_vendor", opengl_instance.getGPUVendorName()) + scope.set_tag("gpu_type", opengl_instance.getGPUType()) + scope.set_tag("active_machine", active_machine_definition_id) + scope.set_tag("active_machine_manufacturer", active_machine_manufacturer) + return info def _exceptionInfoWidget(self): @@ -296,6 +335,10 @@ class CrashHandler: "module_name": module_name, "version": module_version, "is_plugin": isPlugin} self.data["exception"] = exception_dict + with configure_scope() as scope: + scope.set_tag("is_plugin", isPlugin) + scope.set_tag("module", module_name) + return group def _logInfoWidget(self): @@ -353,31 +396,11 @@ class CrashHandler: # Before sending data, the user comments are stored self.data["user_info"] = self.user_description_text_area.toPlainText() - # Convert data to bytes - binary_data = json.dumps(self.data).encode("utf-8") - - # CURA-6698 Create an SSL context and use certifi CA certificates for verification. - context = ssl.SSLContext(protocol=ssl.PROTOCOL_TLSv1_2) - context.load_verify_locations(cafile = certifi.where()) - # Submit data - kwoptions = {"data": binary_data, - "timeout": 5, - "context": context} - - Logger.log("i", "Sending crash report info to [%s]...", self.crash_url) - if not self.has_started: - print("Sending crash report info to [%s]...\n" % self.crash_url) - try: - f = urllib.request.urlopen(self.crash_url, **kwoptions) - Logger.log("i", "Sent crash report info.") - if not self.has_started: - print("Sent crash report info.\n") - f.close() - except urllib.error.HTTPError as e: - Logger.logException("e", "An HTTP error occurred while trying to send crash report") - if not self.has_started: - print("An HTTP error occurred while trying to send crash report: %s" % e) + hub = Hub.current + event, hint = event_from_exception((self.exception_type, self.value, self.traceback)) + hub.capture_event(event, hint=hint) + hub.flush() except Exception as e: # We don't want any exception to cause problems Logger.logException("e", "An exception occurred while trying to send crash report") if not self.has_started: diff --git a/cura_app.py b/cura_app.py index e14b4410bc..51f9041e86 100755 --- a/cura_app.py +++ b/cura_app.py @@ -9,8 +9,11 @@ import os import sys from UM.Platform import Platform +from cura import ApplicationMetadata from cura.ApplicationMetadata import CuraAppName +import sentry_sdk + parser = argparse.ArgumentParser(prog = "cura", add_help = False) parser.add_argument("--debug", @@ -18,8 +21,25 @@ parser.add_argument("--debug", default = False, help = "Turn on the debug mode by setting this option." ) + known_args = vars(parser.parse_known_args()[0]) +sentry_env = "production" +if ApplicationMetadata.CuraVersion == "master": + sentry_env = "development" +try: + if ApplicationMetadata.CuraVersion.split(".")[2] == "99": + sentry_env = "nightly" +except IndexError: + pass + +sentry_sdk.init("https://5034bf0054fb4b889f82896326e79b13@sentry.io/1821564", + environment = sentry_env, + release = "cura%s" % ApplicationMetadata.CuraVersion, + default_integrations = False, + max_breadcrumbs = 300, + server_name = "cura") + if not known_args["debug"]: def get_cura_dir_path(): if Platform.isWindows(): diff --git a/plugins/SentryLogger/SentryLogger.py b/plugins/SentryLogger/SentryLogger.py new file mode 100644 index 0000000000..8367cfc26e --- /dev/null +++ b/plugins/SentryLogger/SentryLogger.py @@ -0,0 +1,55 @@ +# Copyright (c) 2019 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. + +from UM.Logger import LogOutput +from typing import Set +from sentry_sdk import add_breadcrumb +from typing import Optional +import os + +home_dir = os.path.expanduser("~") + + +class SentryLogger(LogOutput): + # Sentry (https://sentry.io) is the service that Cura uses for logging crashes. This logger ensures that the + # regular log entries that we create are added as breadcrumbs so when a crash actually happens, they are already + # processed and ready for sending. + # Note that this only prepares them for sending. It only sends them when the user actually agrees to sending the + # information. + + _levels = { + "w": "warning", + "i": "info", + "c": "fatal", + "e": "error", + "d": "debug" + } + + def __init__(self) -> None: + super().__init__() + self._show_once = set() # type: Set[str] + + ## Log the message to the sentry hub as a breadcrumb + # \param log_type "e" (error), "i"(info), "d"(debug), "w"(warning) or "c"(critical) (can postfix with "_once") + # \param message String containing message to be logged + def log(self, log_type: str, message: str) -> None: + level = self._translateLogType(log_type) + message = self._pruneSensitiveData(message) + if level is None: + if message not in self._show_once: + level = self._translateLogType(log_type[0]) + if level is not None: + self._show_once.add(message) + add_breadcrumb(level = level, message = message) + else: + add_breadcrumb(level = level, message = message) + + @staticmethod + def _pruneSensitiveData(message): + if home_dir in message: + message = message.replace(home_dir, "") + return message + + @staticmethod + def _translateLogType(log_type: str) -> Optional[str]: + return SentryLogger._levels.get(log_type) diff --git a/plugins/SentryLogger/__init__.py b/plugins/SentryLogger/__init__.py new file mode 100644 index 0000000000..c464de5fd4 --- /dev/null +++ b/plugins/SentryLogger/__init__.py @@ -0,0 +1,16 @@ +# Copyright (c) 2019 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +from typing import TYPE_CHECKING, Dict, Any + +from . import SentryLogger + +if TYPE_CHECKING: + from UM.Application import Application + + +def getMetaData() -> Dict[str, Any]: + return {} + + +def register(app: "Application") -> Dict[str, Any]: + return {"logger": SentryLogger.SentryLogger()} diff --git a/plugins/SentryLogger/plugin.json b/plugins/SentryLogger/plugin.json new file mode 100644 index 0000000000..fef4e1c554 --- /dev/null +++ b/plugins/SentryLogger/plugin.json @@ -0,0 +1,8 @@ +{ + "name": "Sentry Logger", + "author": "Ultimaker B.V.", + "version": "1.0.0", + "description": "Logs certain events so that they can be used by the crash reporter", + "api": "7.0", + "i18n-catalog": "cura" +} diff --git a/resources/bundled_packages/cura.json b/resources/bundled_packages/cura.json index 22642cd38e..7cb229150c 100644 --- a/resources/bundled_packages/cura.json +++ b/resources/bundled_packages/cura.json @@ -407,6 +407,23 @@ } } }, + "SentryLogger": { + "package_info": { + "package_id": "SentryLogger", + "package_type": "plugin", + "display_name": "Sentry Logger", + "description": "Logs certain events so that they can be used by the crash reporter", + "package_version": "1.0.0", + "sdk_version": "7.0.0", + "website": "https://ultimaker.com", + "author": { + "author_id": "UltimakerPackages", + "display_name": "Ultimaker B.V.", + "email": "plugins@ultimaker.com", + "website": "https://ultimaker.com" + } + } + }, "SimulationView": { "package_info": { "package_id": "SimulationView",