diff --git a/requirements.txt b/requirements.txt index fdcddc4ae..546b3d740 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,4 @@ dataclasses mako odoorpc openupgradelib +sentry_sdk diff --git a/sentry/README.rst b/sentry/README.rst new file mode 100644 index 000000000..ced71e612 --- /dev/null +++ b/sentry/README.rst @@ -0,0 +1,225 @@ +====== +Sentry +====== + +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fserver--tools-lightgray.png?logo=github + :target: https://github.com/OCA/server-tools/tree/15.0/sentry + :alt: OCA/server-tools +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/server-tools-15-0/server-tools-15-0-sentry + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png + :target: https://runbot.odoo-community.org/runbot/149/15.0 + :alt: Try me on Runbot + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module allows painless `Sentry `__ integration with +Odoo. + +**Table of contents** + +.. contents:: + :local: + +Installation +============ + +The module can be installed just like any other Odoo module, by adding the +module's directory to Odoo *addons_path*. In order for the module to correctly +wrap the Odoo WSGI application, it also needs to be loaded as a server-wide +module. This can be done with the ``server_wide_modules`` parameter in your +Odoo config file or with the ``--load`` command-line parameter. + +This module additionally requires the sentry-sdk Python package to be available on +the system. It can be installed using pip:: + + pip install sentry-sdk + +Configuration +============= + +The following additional configuration options can be added to your Odoo +configuration file: + +============================= ==================================================================== ========================================================== + Option Description Default +============================= ==================================================================== ========================================================== +``sentry_dsn`` Sentry *Data Source Name*. You can find this value in your Sentry ``''`` + project configuration. Typically it looks something like this: + *https://:@sentry.example.com/* + This is the only required option in order to use the module. + +``sentry_enabled`` Whether or not Sentry logging is enabled. ``False`` + +``sentry_logging_level`` The minimal logging level for which to send reports to Sentry. ``warn`` + Possible values: *notset*, *debug*, *info*, *warn*, *error*, + *critical*. It is recommended to have this set to at least *warn*, + to avoid spamming yourself with Sentry events. + +``sentry_exclude_loggers`` A string of comma-separated logger names which should be excluded ``werkzeug`` + from Sentry. + +``sentry_ignored_exceptions`` A string of comma-separated exceptions which should be ignored. ``odoo.exceptions.AccessDenied, + You can use a star symbol (*) at the end, to ignore all exceptions odoo.exceptions.AccessError, + from a module, eg.: *odoo.exceptions.**. odoo.exceptions.DeferredException, + odoo.exceptions.MissingError, + odoo.exceptions.RedirectWarning, + odoo.exceptions.UserError, + odoo.exceptions.ValidationError, + odoo.exceptions.Warning, + odoo.exceptions.except_orm`` + +``sentry_include_context`` If enabled, additional context data will be extracted from current ``True`` + HTTP request and user session (if available). This has no effect + for Cron jobs, as no request/session is available inside a Cron job. + +``sentry_release`` Explicitly define a version to be sent as the release version to + Sentry. Useful in conjuntion with Sentry's "Resolve in the next + release"-functionality. Also useful if your production deployment + does not include any Git context from which a commit might be read. + Overrides *sentry_odoo_dir*. + +``sentry_odoo_dir`` Absolute path to your Odoo installation directory. This is optional + and will only be used to extract the Odoo Git commit, which will be + sent to Sentry, to allow to distinguish between Odoo updates. + Overridden by *sentry_release* +============================= ==================================================================== ========================================================== + +Other `client arguments +`_ can be +configured by prepending the argument name with *sentry_* in your Odoo config +file. Currently supported additional client arguments are: ``with_locals, +max_breadcrumbs, release, environment, server_name, shutdown_timeout, +in_app_include, in_app_exclude, default_integrations, dist, sample_rate, +send_default_pii, http_proxy, https_proxy, request_bodies, debug, +attach_stacktrace, ca_certs, propagate_traces, traces_sample_rate, +auto_enabling_integrations``. + +Example Odoo configuration +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Below is an example of Odoo configuration file with *Odoo Sentry* options:: + + [options] + sentry_dsn = https://:@sentry.example.com/ + sentry_enabled = true + sentry_logging_level = warn + sentry_exclude_loggers = werkzeug + sentry_ignore_exceptions = odoo.exceptions.AccessDenied, + odoo.exceptions.AccessError,odoo.exceptions.MissingError, + odoo.exceptions.RedirectWarning,odoo.exceptions.UserError, + odoo.exceptions.ValidationError,odoo.exceptions.Warning, + odoo.exceptions.except_orm + sentry_include_context = true + sentry_environment = production + sentry_release = 1.3.2 + sentry_odoo_dir = /home/odoo/odoo/ + +Usage +===== + +Once configured and installed, the module will report any logging event at and +above the configured Sentry logging level, no additional actions are necessary. + +.. image:: https://odoo-community.org/website/image/ir.attachment/5784_f2813bd/datas + :alt: Try me on Runbot + :target: https://runbot.odoo-community.org/runbot/149/14.0 + +Known issues / Roadmap +====================== + +* **No database separation** -- This module functions by intercepting all Odoo + logging records in a running Odoo process. This means that once installed in + one database, it will intercept and report errors for all Odoo databases, + which are used on that Odoo server. + +* **Frontend integration** -- In the future, it would be nice to add + Odoo client-side error reporting to this module as well, by integrating + `raven-js `_. Additionally, `Sentry user + feedback form `_ could be + integrated into the Odoo client error dialog window to allow users shortly + describe what they were doing when things went wrong. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us smashing it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Mohammed Barsi +* Versada +* Nicolas JEUDY +* Vauxoo + +Contributors +~~~~~~~~~~~~ + +* Mohammed Barsi +* Andrius Preimantas +* Naglis Jonaitis +* Atte Isopuro +* Florian Mounier + +Other credits +~~~~~~~~~~~~~ + +* Vauxoo + +Maintainers +~~~~~~~~~~~ + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-barsi| image:: https://github.com/barsi.png?size=40px + :target: https://github.com/barsi + :alt: barsi +.. |maintainer-naglis| image:: https://github.com/naglis.png?size=40px + :target: https://github.com/naglis + :alt: naglis +.. |maintainer-versada| image:: https://github.com/versada.png?size=40px + :target: https://github.com/versada + :alt: versada +.. |maintainer-moylop260| image:: https://github.com/moylop260.png?size=40px + :target: https://github.com/moylop260 + :alt: moylop260 +.. |maintainer-fernandahf| image:: https://github.com/fernandahf.png?size=40px + :target: https://github.com/fernandahf + :alt: fernandahf + +Current `maintainers `__: + +|maintainer-barsi| |maintainer-naglis| |maintainer-versada| |maintainer-moylop260| |maintainer-fernandahf| + +This module is part of the `OCA/server-tools `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/sentry/__init__.py b/sentry/__init__.py new file mode 100644 index 000000000..7001103db --- /dev/null +++ b/sentry/__init__.py @@ -0,0 +1 @@ +from .hooks import post_load diff --git a/sentry/__manifest__.py b/sentry/__manifest__.py new file mode 100644 index 000000000..3f7b8c75e --- /dev/null +++ b/sentry/__manifest__.py @@ -0,0 +1,27 @@ +# Copyright 2016-2017 Versada +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +{ + "name": "Sentry", + "summary": "Report Odoo errors to Sentry", + "version": "16.0.1.0.0", + "category": "Extra Tools", + "website": "https://github.com/OCA/server-tools", + "author": "Mohammed Barsi," + "Versada," + "Nicolas JEUDY," + "Odoo Community Association (OCA)," + "Vauxoo", + "maintainers": ["barsi", "naglis", "versada", "moylop260", "fernandahf"], + "license": "AGPL-3", + "application": False, + "installable": True, + "external_dependencies": { + "python": [ + "sentry_sdk", + ] + }, + "depends": [ + "base", + ], + "post_load": "post_load", +} diff --git a/sentry/const.py b/sentry/const.py new file mode 100644 index 000000000..c3135a9df --- /dev/null +++ b/sentry/const.py @@ -0,0 +1,120 @@ +# Copyright 2016-2017 Versada +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import collections +import logging + +from sentry_sdk import HttpTransport +from sentry_sdk.consts import DEFAULT_OPTIONS +from sentry_sdk.integrations.logging import LoggingIntegration + +import odoo.loglevels + + +def split_multiple(string, delimiter=",", strip_chars=None): + """Splits :param:`string` and strips :param:`strip_chars` from values.""" + if not string: + return [] + return [v.strip(strip_chars) for v in string.split(delimiter)] + + +def to_int_if_defined(value): + if value == "" or value is None: + return + return int(value) + + +def to_float_if_defined(value): + if value == "" or value is None: + return + return float(value) + + +SentryOption = collections.namedtuple("SentryOption", ["key", "default", "converter"]) + +# Mapping of Odoo logging level -> Python stdlib logging library log level. +LOG_LEVEL_MAP = { + getattr(odoo.loglevels, "LOG_%s" % x): getattr(logging, x) + for x in ("CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG", "NOTSET") +} +DEFAULT_LOG_LEVEL = "warn" + +ODOO_USER_EXCEPTIONS = [ + "odoo.exceptions.AccessDenied", + "odoo.exceptions.AccessError", + "odoo.exceptions.DeferredException", + "odoo.exceptions.MissingError", + "odoo.exceptions.RedirectWarning", + "odoo.exceptions.UserError", + "odoo.exceptions.ValidationError", + "odoo.exceptions.Warning", + "odoo.exceptions.except_orm", +] +DEFAULT_IGNORED_EXCEPTIONS = ",".join(ODOO_USER_EXCEPTIONS) + +EXCLUDE_LOGGERS = ("werkzeug",) +DEFAULT_EXCLUDE_LOGGERS = ",".join(EXCLUDE_LOGGERS) + +DEFAULT_ENVIRONMENT = "develop" + +DEFAULT_TRANSPORT = "threaded" + + +def select_transport(name=DEFAULT_TRANSPORT): + return { + "threaded": HttpTransport, + }.get(name, HttpTransport) + + +def get_sentry_logging(level=DEFAULT_LOG_LEVEL): + if level not in LOG_LEVEL_MAP: + level = DEFAULT_LOG_LEVEL + + return LoggingIntegration(level=LOG_LEVEL_MAP[level], event_level=logging.WARNING) + + +def get_sentry_options(): + return [ + SentryOption("dsn", "", str.strip), + SentryOption("transport", DEFAULT_OPTIONS["transport"], select_transport), + SentryOption("logging_level", DEFAULT_LOG_LEVEL, get_sentry_logging), + SentryOption("with_locals", DEFAULT_OPTIONS["with_locals"], None), + SentryOption( + "max_breadcrumbs", DEFAULT_OPTIONS["max_breadcrumbs"], to_int_if_defined + ), + SentryOption("release", DEFAULT_OPTIONS["release"], None), + SentryOption("environment", DEFAULT_OPTIONS["environment"], None), + SentryOption("server_name", DEFAULT_OPTIONS["server_name"], None), + SentryOption("shutdown_timeout", DEFAULT_OPTIONS["shutdown_timeout"], None), + SentryOption("integrations", DEFAULT_OPTIONS["integrations"], None), + SentryOption( + "in_app_include", DEFAULT_OPTIONS["in_app_include"], split_multiple + ), + SentryOption( + "in_app_exclude", DEFAULT_OPTIONS["in_app_exclude"], split_multiple + ), + SentryOption( + "default_integrations", DEFAULT_OPTIONS["default_integrations"], None + ), + SentryOption("dist", DEFAULT_OPTIONS["dist"], None), + SentryOption( + "sample_rate", DEFAULT_OPTIONS["sample_rate"], to_float_if_defined + ), + SentryOption("send_default_pii", DEFAULT_OPTIONS["send_default_pii"], None), + SentryOption("http_proxy", DEFAULT_OPTIONS["http_proxy"], None), + SentryOption("https_proxy", DEFAULT_OPTIONS["https_proxy"], None), + SentryOption("ignore_exceptions", DEFAULT_IGNORED_EXCEPTIONS, split_multiple), + SentryOption("request_bodies", DEFAULT_OPTIONS["request_bodies"], None), + SentryOption("attach_stacktrace", DEFAULT_OPTIONS["attach_stacktrace"], None), + SentryOption("ca_certs", DEFAULT_OPTIONS["ca_certs"], None), + SentryOption("propagate_traces", DEFAULT_OPTIONS["propagate_traces"], None), + SentryOption( + "traces_sample_rate", + DEFAULT_OPTIONS["traces_sample_rate"], + to_float_if_defined, + ), + SentryOption( + "auto_enabling_integrations", + DEFAULT_OPTIONS["auto_enabling_integrations"], + None, + ), + ] diff --git a/sentry/generalutils.py b/sentry/generalutils.py new file mode 100644 index 000000000..c659f4760 --- /dev/null +++ b/sentry/generalutils.py @@ -0,0 +1,62 @@ +try: + from collections.abc import Mapping +except ImportError: # pragma: no cover + # Python < 3.3 + from collections import Mapping # pragma: no cover + + +def string_types(): + """Taken from https://git.io/JIv5J""" + + return (str,) + + +def is_namedtuple(value): + """https://stackoverflow.com/a/2166841/1843746 + But modified to handle subclasses of namedtuples. + Taken from https://git.io/JIsfY + """ + if not isinstance(value, tuple): + return False + f = getattr(type(value), "_fields", None) + if not isinstance(f, tuple): + return False + return all(type(n) == str for n in f) + + +def iteritems(d, **kw): + """Override iteritems for support multiple versions python. + Taken from https://git.io/JIvMi + """ + return iter(d.items(**kw)) + + +def varmap(func, var, context=None, name=None): + """Executes ``func(key_name, value)`` on all values + recurisively discovering dict and list scoped + values. Taken from https://git.io/JIvMN + """ + if context is None: + context = {} + objid = id(var) + if objid in context: + return func(name, "<...>") + context[objid] = 1 + + if isinstance(var, (list, tuple)) and not is_namedtuple(var): + ret = [varmap(func, f, context, name) for f in var] + else: + ret = func(name, var) + if isinstance(ret, Mapping): + ret = {k: varmap(func, v, context, k) for k, v in iteritems(var)} + del context[objid] + return ret + + +def get_environ(environ): + """Returns our whitelisted environment variables. + Taken from https://git.io/JIsf2 + """ + for key in ("REMOTE_ADDR", "SERVER_NAME", "SERVER_PORT"): + if key in environ: + yield key, environ[key] diff --git a/sentry/hooks.py b/sentry/hooks.py new file mode 100644 index 000000000..e085de7ae --- /dev/null +++ b/sentry/hooks.py @@ -0,0 +1,135 @@ +# Copyright 2016-2017 Versada +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import logging +import warnings +from collections import abc + +import odoo.http +from odoo.service.server import server +from odoo.tools import config as odoo_config + +from . import const +from .logutils import ( + InvalidGitRepository, + SanitizeOdooCookiesProcessor, + fetch_git_sha, + get_extra_context, +) + +_logger = logging.getLogger(__name__) +HAS_SENTRY_SDK = True +try: + import sentry_sdk + from sentry_sdk.integrations.logging import ignore_logger + from sentry_sdk.integrations.threading import ThreadingIntegration + from sentry_sdk.integrations.wsgi import SentryWsgiMiddleware +except ImportError: # pragma: no cover + HAS_SENTRY_SDK = False # pragma: no cover + _logger.debug( + "Cannot import 'sentry-sdk'.\ + Please make sure it is installed." + ) # pragma: no cover + + +def before_send(event, hint): + """Add context to event if include_context is True + and sanitize sensitive data""" + if event.setdefault("tags", {})["include_context"]: + cxtest = get_extra_context(odoo.http.request) + info_request = ["tags", "user", "extra", "request"] + + for item in info_request: + info_item = event.setdefault(item, {}) + info_item.update(cxtest.setdefault(item, {})) + + raven_processor = SanitizeOdooCookiesProcessor() + raven_processor.process(event) + + return event + + +def get_odoo_commit(odoo_dir): + """Attempts to get Odoo git commit from :param:`odoo_dir`.""" + if not odoo_dir: + return + try: + return fetch_git_sha(odoo_dir) + except InvalidGitRepository: + _logger.debug("Odoo directory: '%s' not a valid git repository", odoo_dir) + + +def initialize_sentry(config): + """Setup an instance of :class:`sentry_sdk.Client`. + :param config: Sentry configuration + :param client: class used to instantiate the sentry_sdk client. + """ + enabled = config.get("sentry_enabled", False) + if not (HAS_SENTRY_SDK and enabled): + return + _logger.info("Initializing sentry...") + if config.get("sentry_odoo_dir") and config.get("sentry_release"): + _logger.debug( + "Both sentry_odoo_dir and \ + sentry_release defined, choosing sentry_release" + ) + if config.get("sentry_transport"): + warnings.warn( + "`sentry_transport` has been deprecated. " + "Its not neccesary send it, will use `HttpTranport` by default.", + DeprecationWarning, + ) + options = {} + for option in const.get_sentry_options(): + value = config.get("sentry_%s" % option.key, option.default) + if isinstance(option.converter, abc.Callable): + value = option.converter(value) + options[option.key] = value + + exclude_loggers = const.split_multiple( + config.get("sentry_exclude_loggers", const.DEFAULT_EXCLUDE_LOGGERS) + ) + + if not options.get("release"): + options["release"] = config.get( + "sentry_release", get_odoo_commit(config.get("sentry_odoo_dir")) + ) + + # Change name `ignore_exceptions` (with raven) + # to `ignore_errors' (sentry_sdk) + options["ignore_errors"] = options["ignore_exceptions"] + del options["ignore_exceptions"] + + options["before_send"] = before_send + + options["integrations"] = [ + options["logging_level"], + ThreadingIntegration(propagate_hub=True), + ] + # Remove logging_level, since in sentry_sdk is include in 'integrations' + del options["logging_level"] + + client = sentry_sdk.init(**options) + + sentry_sdk.set_tag("include_context", config.get("sentry_include_context", True)) + + if exclude_loggers: + for item in exclude_loggers: + ignore_logger(item) + + # The server app is already registered so patch it here + if server: + server.app = SentryWsgiMiddleware(server.app) + + # Patch the wsgi server in case of further registration + odoo.http.Application = SentryWsgiMiddleware(odoo.http.Application) + + with sentry_sdk.push_scope() as scope: + scope.set_extra("debug", False) + sentry_sdk.capture_message("Starting Odoo Server", "info") + + return client + + +def post_load(): + initialize_sentry(odoo_config) diff --git a/sentry/i18n/ca.po b/sentry/i18n/ca.po new file mode 100644 index 000000000..e69de29bb diff --git a/sentry/i18n/sentry.pot b/sentry/i18n/sentry.pot new file mode 100644 index 000000000..a11baf5cd --- /dev/null +++ b/sentry/i18n/sentry.pot @@ -0,0 +1,13 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 15.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" diff --git a/sentry/i18n/zh_CN.po b/sentry/i18n/zh_CN.po new file mode 100644 index 000000000..e69de29bb diff --git a/sentry/logutils.py b/sentry/logutils.py new file mode 100644 index 000000000..465641d3d --- /dev/null +++ b/sentry/logutils.py @@ -0,0 +1,120 @@ +# Copyright 2016-2017 Versada +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import os.path +import urllib.parse + +from sentry_sdk._compat import text_type +from werkzeug import datastructures + +from .generalutils import get_environ +from .processor import SanitizePasswordsProcessor + + +def get_request_info(request): + """ + Returns context data extracted from :param:`request`. + + Heavily based on flask integration for Sentry: https://git.io/vP4i9. + """ + urlparts = urllib.parse.urlsplit(request.url) + return { + "url": "{}://{}{}".format(urlparts.scheme, urlparts.netloc, urlparts.path), + "query_string": urlparts.query, + "method": request.method, + "headers": dict(datastructures.EnvironHeaders(request.environ)), + "env": dict(get_environ(request.environ)), + } + + +def get_extra_context(request): + """ + Extracts additional context from the current request (if such is set). + """ + try: + session = getattr(request, "session", {}) + except RuntimeError: + ctx = {} + else: + ctx = { + "tags": { + "database": session.get("db", None), + }, + "user": { + "email": session.get("login", None), + "id": session.get("uid", None), + }, + "extra": { + "context": session.get("context", {}), + }, + } + if request.httprequest: + ctx.update({"request": get_request_info(request.httprequest)}) + return ctx + + +class SanitizeOdooCookiesProcessor(SanitizePasswordsProcessor): + """Custom :class:`raven.processors.Processor`. + Allows to sanitize sensitive Odoo cookies, namely the "session_id" cookie. + """ + + KEYS = frozenset( + [ + "session_id", + ] + ) + + +class InvalidGitRepository(Exception): + pass + + +def fetch_git_sha(path, head=None): + """>>> fetch_git_sha(os.path.dirname(__file__)) + Taken from https://git.io/JITmC + """ + if not head: + head_path = os.path.join(path, ".git", "HEAD") + if not os.path.exists(head_path): + raise InvalidGitRepository( + "Cannot identify HEAD for git repository at %s" % (path,) + ) + + with open(head_path, "r") as fp: + head = text_type(fp.read()).strip() + + if head.startswith("ref: "): + head = head[5:] + revision_file = os.path.join(path, ".git", *head.split("/")) + else: + return head + else: + revision_file = os.path.join(path, ".git", "refs", "heads", head) + + if not os.path.exists(revision_file): + if not os.path.exists(os.path.join(path, ".git")): + raise InvalidGitRepository( + "%s does not seem to be the root of a git repository" % (path,) + ) + + # Check for our .git/packed-refs' file since a `git gc` may have run + # https://git-scm.com/book/en/v2/Git-Internals-Maintenance-and-Data-Recovery + packed_file = os.path.join(path, ".git", "packed-refs") + if os.path.exists(packed_file): + with open(packed_file) as fh: + for line in fh: + line = line.rstrip() + if line and line[:1] not in ("#", "^"): + try: + revision, ref = line.split(" ", 1) + except ValueError: + continue + if ref == head: + return text_type(revision) + + raise InvalidGitRepository( + 'Unable to find ref to head "%s" in repository' % (head,) + ) + + with open(revision_file) as fh: + return text_type(fh.read()).strip() diff --git a/sentry/processor.py b/sentry/processor.py new file mode 100644 index 000000000..8afb7d754 --- /dev/null +++ b/sentry/processor.py @@ -0,0 +1,138 @@ +""" Custom class of raven.core.processors taken of https://git.io/JITko + This is a custom class of processor to filter and sanitize + passwords and keys from request data, it does not exist in + sentry-sdk. +""" + +from __future__ import absolute_import + +import re + +from sentry_sdk._compat import text_type + +from .generalutils import string_types, varmap + + +class SanitizeKeysProcessor(object): + """Class from raven for sanitize keys, cookies, etc + Asterisk out things that correspond to a configurable set of keys.""" + + MASK = "*" * 8 + + def process(self, data, **kwargs): + if "exception" in data: + if "values" in data["exception"]: + for value in data["exception"].get("values", []): + if "stacktrace" in value: + self.filter_stacktrace(value["stacktrace"]) + + if "request" in data: + self.filter_http(data["request"]) + + if "extra" in data: + data["extra"] = self.filter_extra(data["extra"]) + + if "level" in data: + data["level"] = self.filter_level(data["level"]) + + return data + + @property + def sanitize_keys(self): + pass + + def sanitize(self, item, value): + if value is None: + return + + if not item: # key can be a NoneType + return value + + # Just in case we have bytes here, we want to make them into text + # properly without failing so we can perform our check. + if isinstance(item, bytes): + item = item.decode("utf-8", "replace") + else: + item = text_type(item) + + item = item.lower() + for key in self.sanitize_keys: + if key in item: + # store mask as a fixed length for security + return self.MASK + return value + + def filter_stacktrace(self, data): + for frame in data.get("frames", []): + if "vars" not in frame: + continue + frame["vars"] = varmap(self.sanitize, frame["vars"]) + + def filter_http(self, data): + for n in ("data", "cookies", "headers", "env", "query_string"): + if n not in data: + continue + + # data could be provided as bytes and if it's python3 + if isinstance(data[n], bytes): + data[n] = data[n].decode("utf-8", "replace") + + if isinstance(data[n], string_types()) and "=" in data[n]: + # at this point we've assumed it's a standard HTTP query + # or cookie + if n == "cookies": + delimiter = ";" + else: + delimiter = "&" + + data[n] = self._sanitize_keyvals(data[n], delimiter) + else: + data[n] = varmap(self.sanitize, data[n]) + if n == "headers" and "Cookie" in data[n]: + data[n]["Cookie"] = self._sanitize_keyvals(data[n]["Cookie"], ";") + + def filter_extra(self, data): + return varmap(self.sanitize, data) + + def filter_level(self, data): + return re.sub(r"\x1b(\[.*?[@-~]|\].*?(\x07|\x1b\\))", "", data) + + def _sanitize_keyvals(self, keyvals, delimiter): + sanitized_keyvals = [] + for keyval in keyvals.split(delimiter): + keyval = keyval.split("=") + if len(keyval) == 2: + sanitized_keyvals.append((keyval[0], self.sanitize(*keyval))) + else: + sanitized_keyvals.append(keyval) + + return delimiter.join("=".join(keyval) for keyval in sanitized_keyvals) + + +class SanitizePasswordsProcessor(SanitizeKeysProcessor): + """Asterisk out things that look like passwords, credit card numbers, + and API keys in frames, http, and basic extra data.""" + + KEYS = frozenset( + [ + "password", + "secret", + "passwd", + "authorization", + "api_key", + "apikey", + "sentry_dsn", + "access_token", + ] + ) + VALUES_RE = re.compile(r"^(?:\d[ -]*?){13,16}$") + + @property + def sanitize_keys(self): + return self.KEYS + + def sanitize(self, item, value): + value = super(SanitizePasswordsProcessor, self).sanitize(item, value) + if isinstance(value, string_types()) and self.VALUES_RE.match(value): + return self.MASK + return value diff --git a/sentry/readme/CONFIGURE.rst b/sentry/readme/CONFIGURE.rst new file mode 100644 index 000000000..25de942df --- /dev/null +++ b/sentry/readme/CONFIGURE.rst @@ -0,0 +1,76 @@ +The following additional configuration options can be added to your Odoo +configuration file: + +============================= ==================================================================== ========================================================== + Option Description Default +============================= ==================================================================== ========================================================== +``sentry_dsn`` Sentry *Data Source Name*. You can find this value in your Sentry ``''`` + project configuration. Typically it looks something like this: + *https://:@sentry.example.com/* + This is the only required option in order to use the module. + +``sentry_enabled`` Whether or not Sentry logging is enabled. ``False`` + +``sentry_logging_level`` The minimal logging level for which to send reports to Sentry. ``warn`` + Possible values: *notset*, *debug*, *info*, *warn*, *error*, + *critical*. It is recommended to have this set to at least *warn*, + to avoid spamming yourself with Sentry events. + +``sentry_exclude_loggers`` A string of comma-separated logger names which should be excluded ``werkzeug`` + from Sentry. + +``sentry_ignored_exceptions`` A string of comma-separated exceptions which should be ignored. ``odoo.exceptions.AccessDenied, + You can use a star symbol (*) at the end, to ignore all exceptions odoo.exceptions.AccessError, + from a module, eg.: *odoo.exceptions.**. odoo.exceptions.DeferredException, + odoo.exceptions.MissingError, + odoo.exceptions.RedirectWarning, + odoo.exceptions.UserError, + odoo.exceptions.ValidationError, + odoo.exceptions.Warning, + odoo.exceptions.except_orm`` + +``sentry_include_context`` If enabled, additional context data will be extracted from current ``True`` + HTTP request and user session (if available). This has no effect + for Cron jobs, as no request/session is available inside a Cron job. + +``sentry_release`` Explicitly define a version to be sent as the release version to + Sentry. Useful in conjuntion with Sentry's "Resolve in the next + release"-functionality. Also useful if your production deployment + does not include any Git context from which a commit might be read. + Overrides *sentry_odoo_dir*. + +``sentry_odoo_dir`` Absolute path to your Odoo installation directory. This is optional + and will only be used to extract the Odoo Git commit, which will be + sent to Sentry, to allow to distinguish between Odoo updates. + Overridden by *sentry_release* +============================= ==================================================================== ========================================================== + +Other `client arguments +`_ can be +configured by prepending the argument name with *sentry_* in your Odoo config +file. Currently supported additional client arguments are: ``with_locals, +max_breadcrumbs, release, environment, server_name, shutdown_timeout, +in_app_include, in_app_exclude, default_integrations, dist, sample_rate, +send_default_pii, http_proxy, https_proxy, request_bodies, debug, +attach_stacktrace, ca_certs, propagate_traces, traces_sample_rate, +auto_enabling_integrations``. + +Example Odoo configuration +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Below is an example of Odoo configuration file with *Odoo Sentry* options:: + + [options] + sentry_dsn = https://:@sentry.example.com/ + sentry_enabled = true + sentry_logging_level = warn + sentry_exclude_loggers = werkzeug + sentry_ignore_exceptions = odoo.exceptions.AccessDenied, + odoo.exceptions.AccessError,odoo.exceptions.MissingError, + odoo.exceptions.RedirectWarning,odoo.exceptions.UserError, + odoo.exceptions.ValidationError,odoo.exceptions.Warning, + odoo.exceptions.except_orm + sentry_include_context = true + sentry_environment = production + sentry_release = 1.3.2 + sentry_odoo_dir = /home/odoo/odoo/ diff --git a/sentry/readme/CONTRIBUTORS.rst b/sentry/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000..7281929d2 --- /dev/null +++ b/sentry/readme/CONTRIBUTORS.rst @@ -0,0 +1,5 @@ +* Mohammed Barsi +* Andrius Preimantas +* Naglis Jonaitis +* Atte Isopuro +* Florian Mounier diff --git a/sentry/readme/CREDITS.rst b/sentry/readme/CREDITS.rst new file mode 100644 index 000000000..7f8b9f7ab --- /dev/null +++ b/sentry/readme/CREDITS.rst @@ -0,0 +1 @@ +* Vauxoo diff --git a/sentry/readme/DESCRIPTION.rst b/sentry/readme/DESCRIPTION.rst new file mode 100644 index 000000000..531a05079 --- /dev/null +++ b/sentry/readme/DESCRIPTION.rst @@ -0,0 +1,2 @@ +This module allows painless `Sentry `__ integration with +Odoo. diff --git a/sentry/readme/INSTALL.rst b/sentry/readme/INSTALL.rst new file mode 100644 index 000000000..6ccb9e341 --- /dev/null +++ b/sentry/readme/INSTALL.rst @@ -0,0 +1,10 @@ +The module can be installed just like any other Odoo module, by adding the +module's directory to Odoo *addons_path*. In order for the module to correctly +wrap the Odoo WSGI application, it also needs to be loaded as a server-wide +module. This can be done with the ``server_wide_modules`` parameter in your +Odoo config file or with the ``--load`` command-line parameter. + +This module additionally requires the sentry-sdk Python package to be available on +the system. It can be installed using pip:: + + pip install sentry-sdk diff --git a/sentry/readme/ROADMAP.rst b/sentry/readme/ROADMAP.rst new file mode 100644 index 000000000..92e100a5b --- /dev/null +++ b/sentry/readme/ROADMAP.rst @@ -0,0 +1,11 @@ +* **No database separation** -- This module functions by intercepting all Odoo + logging records in a running Odoo process. This means that once installed in + one database, it will intercept and report errors for all Odoo databases, + which are used on that Odoo server. + +* **Frontend integration** -- In the future, it would be nice to add + Odoo client-side error reporting to this module as well, by integrating + `raven-js `_. Additionally, `Sentry user + feedback form `_ could be + integrated into the Odoo client error dialog window to allow users shortly + describe what they were doing when things went wrong. diff --git a/sentry/readme/USAGE.rst b/sentry/readme/USAGE.rst new file mode 100644 index 000000000..e50ec6d61 --- /dev/null +++ b/sentry/readme/USAGE.rst @@ -0,0 +1,6 @@ +Once configured and installed, the module will report any logging event at and +above the configured Sentry logging level, no additional actions are necessary. + +.. image:: https://odoo-community.org/website/image/ir.attachment/5784_f2813bd/datas + :alt: Try me on Runbot + :target: https://runbot.odoo-community.org/runbot/149/14.0 diff --git a/sentry/static/description/icon.png b/sentry/static/description/icon.png new file mode 100644 index 000000000..134c89f93 Binary files /dev/null and b/sentry/static/description/icon.png differ diff --git a/sentry/static/description/index.html b/sentry/static/description/index.html new file mode 100644 index 000000000..3cff37347 --- /dev/null +++ b/sentry/static/description/index.html @@ -0,0 +1,583 @@ + + + + + + +Sentry + + + +
+

Sentry

+ + +

Beta License: AGPL-3 OCA/server-tools Translate me on Weblate Try me on Runbot

+

This module allows painless Sentry integration with +Odoo.

+

Table of contents

+ +
+

Installation

+

The module can be installed just like any other Odoo module, by adding the +module’s directory to Odoo addons_path. In order for the module to correctly +wrap the Odoo WSGI application, it also needs to be loaded as a server-wide +module. This can be done with the server_wide_modules parameter in your +Odoo config file or with the --load command-line parameter.

+

This module additionally requires the sentry-sdk Python package to be available on +the system. It can be installed using pip:

+
+pip install sentry-sdk
+
+
+
+

Configuration

+

The following additional configuration options can be added to your Odoo +configuration file:

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
OptionDescriptionDefault
sentry_dsnSentry Data Source Name. You can find this value in your Sentry +project configuration. Typically it looks something like this: +https://<public_key>:<secret_key>@sentry.example.com/<project id> +This is the only required option in order to use the module.''
sentry_enabledWhether or not Sentry logging is enabled.False
sentry_logging_levelThe minimal logging level for which to send reports to Sentry. +Possible values: notset, debug, info, warn, error, +critical. It is recommended to have this set to at least warn, +to avoid spamming yourself with Sentry events.warn
sentry_exclude_loggersA string of comma-separated logger names which should be excluded +from Sentry.werkzeug
sentry_ignored_exceptionsA string of comma-separated exceptions which should be ignored. +You can use a star symbol (*) at the end, to ignore all exceptions +from a module, eg.: odoo.exceptions.*.odoo.exceptions.AccessDenied, +odoo.exceptions.AccessError, +odoo.exceptions.DeferredException, +odoo.exceptions.MissingError, +odoo.exceptions.RedirectWarning, +odoo.exceptions.UserError, +odoo.exceptions.ValidationError, +odoo.exceptions.Warning, +odoo.exceptions.except_orm
sentry_include_contextIf enabled, additional context data will be extracted from current +HTTP request and user session (if available). This has no effect +for Cron jobs, as no request/session is available inside a Cron job.True
sentry_releaseExplicitly define a version to be sent as the release version to +Sentry. Useful in conjuntion with Sentry’s “Resolve in the next +release”-functionality. Also useful if your production deployment +does not include any Git context from which a commit might be read. +Overrides sentry_odoo_dir. 
sentry_odoo_dirAbsolute path to your Odoo installation directory. This is optional +and will only be used to extract the Odoo Git commit, which will be +sent to Sentry, to allow to distinguish between Odoo updates. +Overridden by sentry_release 
+

Other client arguments can be +configured by prepending the argument name with sentry_ in your Odoo config +file. Currently supported additional client arguments are: with_locals, +max_breadcrumbs, release, environment, server_name, shutdown_timeout, +in_app_include, in_app_exclude, default_integrations, dist, sample_rate, +send_default_pii, http_proxy, https_proxy, request_bodies, debug, +attach_stacktrace, ca_certs, propagate_traces, traces_sample_rate, +auto_enabling_integrations.

+
+

Example Odoo configuration

+

Below is an example of Odoo configuration file with Odoo Sentry options:

+
+[options]
+sentry_dsn = https://<public_key>:<secret_key>@sentry.example.com/<project id>
+sentry_enabled = true
+sentry_logging_level = warn
+sentry_exclude_loggers = werkzeug
+sentry_ignore_exceptions = odoo.exceptions.AccessDenied,
+    odoo.exceptions.AccessError,odoo.exceptions.MissingError,
+    odoo.exceptions.RedirectWarning,odoo.exceptions.UserError,
+    odoo.exceptions.ValidationError,odoo.exceptions.Warning,
+    odoo.exceptions.except_orm
+sentry_include_context = true
+sentry_environment = production
+sentry_release = 1.3.2
+sentry_odoo_dir = /home/odoo/odoo/
+
+
+
+
+

Usage

+

Once configured and installed, the module will report any logging event at and +above the configured Sentry logging level, no additional actions are necessary.

+Try me on Runbot +
+
+

Known issues / Roadmap

+
    +
  • No database separation – This module functions by intercepting all Odoo +logging records in a running Odoo process. This means that once installed in +one database, it will intercept and report errors for all Odoo databases, +which are used on that Odoo server.
  • +
  • Frontend integration – In the future, it would be nice to add +Odoo client-side error reporting to this module as well, by integrating +raven-js. Additionally, Sentry user +feedback form could be +integrated into the Odoo client error dialog window to allow users shortly +describe what they were doing when things went wrong.
  • +
+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us smashing it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Mohammed Barsi
  • +
  • Versada
  • +
  • Nicolas JEUDY
  • +
  • Vauxoo
  • +
+
+
+

Contributors

+ +
+
+

Other credits

+
    +
  • Vauxoo
  • +
+
+
+

Maintainers

+

This module is maintained by the OCA.

+Odoo Community Association +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

Current maintainers:

+

barsi naglis versada moylop260 fernandahf

+

This module is part of the OCA/server-tools project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/sentry/tests/__init__.py b/sentry/tests/__init__.py new file mode 100644 index 000000000..21926387a --- /dev/null +++ b/sentry/tests/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2016-2017 Versada +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import test_client, test_logutils diff --git a/sentry/tests/test_client.py b/sentry/tests/test_client.py new file mode 100644 index 000000000..db2b9d3e7 --- /dev/null +++ b/sentry/tests/test_client.py @@ -0,0 +1,154 @@ +# Copyright 2016-2017 Versada +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import logging +import sys +from unittest.mock import patch + +from sentry_sdk.integrations.logging import _IGNORED_LOGGERS +from sentry_sdk.transport import HttpTransport + +from odoo import exceptions +from odoo.tests import TransactionCase +from odoo.tools import config + +from ..hooks import initialize_sentry + +GIT_SHA = "d670460b4b4aece5915caf5c68d12f560a9fe3e4" +RELEASE = "test@1.2.3" + + +def remove_handler_ignore(handler_name): + """Removes handlers of handlers ignored list.""" + _IGNORED_LOGGERS.discard(handler_name) + + +class TestException(exceptions.UserError): + pass + + +class InMemoryTransport(HttpTransport): + """A :class:`sentry_sdk.Hub.transport` subclass which simply stores events in a list. + + Extended based on the one found in raven-python to avoid additional testing + dependencies: https://git.io/vyGO3 + """ + + def __init__(self, *args, **kwargs): + self.events = [] + self.envelopes = [] + + def capture_event(self, event, *args, **kwargs): + self.events.append(event) + + def capture_envelope(self, envelope, *args, **kwargs): + self.envelopes.append(envelope) + + def has_event(self, event_level, event_msg): + for event in self.events: + if ( + event.get("level") == event_level + and event.get("logentry", {}).get("message") == event_msg + ): + return True + return False + + def flush(self, *args, **kwargs): + pass + + def kill(self, *args, **kwargs): + pass + + +class TestClientSetup(TransactionCase): + def setUp(self): + super(TestClientSetup, self).setUp() + self.dsn = "http://public:secret@example.com/1" + config.options["sentry_enabled"] = True + config.options["sentry_dsn"] = self.dsn + self.client = initialize_sentry(config)._client + self.client.transport = InMemoryTransport({"dsn": self.dsn}) + self.handler = self.client.integrations["logging"]._handler + + def log(self, level, msg, exc_info=None): + record = logging.LogRecord(__name__, level, __file__, 42, msg, (), exc_info) + self.handler.emit(record) + + def assertEventCaptured(self, client, event_level, event_msg): + self.assertTrue( + client.transport.has_event(event_level, event_msg), + msg='Event: "%s" was not captured' % event_msg, + ) + + def assertEventNotCaptured(self, client, event_level, event_msg): + self.assertFalse( + client.transport.has_event(event_level, event_msg), + msg='Event: "%s" was captured' % event_msg, + ) + + def test_initialize_raven_sets_dsn(self): + self.assertEqual(self.client.dsn, self.dsn) + + def test_capture_event(self): + level, msg = logging.WARNING, "Test event, can be ignored" + self.log(level, msg) + level = "warning" + self.assertEventCaptured(self.client, level, msg) + + def test_capture_event_exc(self): + level, msg = logging.WARNING, "Test event, can be ignored exception" + try: + raise TestException(msg) + except TestException: + exc_info = sys.exc_info() + self.log(level, msg, exc_info) + level = "warning" + self.assertEventCaptured(self.client, level, msg) + + def test_ignore_exceptions(self): + config.options["sentry_ignore_exceptions"] = "odoo.exceptions.UserError" + client = initialize_sentry(config)._client + client.transport = InMemoryTransport({"dsn": self.dsn}) + level, msg = logging.WARNING, "Test exception" + try: + raise exceptions.UserError(msg) + except exceptions.UserError: + exc_info = sys.exc_info() + self.log(level, msg, exc_info) + level = "warning" + self.assertEventNotCaptured(client, level, msg) + + def test_exclude_logger(self): + config.options["sentry_enabled"] = True + config.options["sentry_exclude_loggers"] = __name__ + client = initialize_sentry(config)._client + client.transport = InMemoryTransport({"dsn": self.dsn}) + level, msg = logging.WARNING, "Test exclude logger %s" % __name__ + self.log(level, msg) + level = "warning" + # Revert ignored logger so it doesn't affect other tests + remove_handler_ignore(__name__) + self.assertEventNotCaptured(client, level, msg) + + @patch("odoo.addons.sentry.hooks.get_odoo_commit", return_value=GIT_SHA) + def test_config_odoo_dir(self, get_odoo_commit): + config.options["sentry_odoo_dir"] = "/opt/odoo/core" + client = initialize_sentry(config)._client + + self.assertEqual( + client.options["release"], + GIT_SHA, + "Failed to use 'sentry_odoo_dir' parameter appropriately", + ) + + @patch("odoo.addons.sentry.hooks.get_odoo_commit", return_value=GIT_SHA) + def test_config_release(self, get_odoo_commit): + config.options["sentry_odoo_dir"] = "/opt/odoo/core" + config.options["sentry_release"] = RELEASE + client = initialize_sentry(config)._client + + self.assertEqual( + client.options["release"], + RELEASE, + "Failed to use 'sentry_release' parameter appropriately", + ) diff --git a/sentry/tests/test_logutils.py b/sentry/tests/test_logutils.py new file mode 100644 index 000000000..e822a81a0 --- /dev/null +++ b/sentry/tests/test_logutils.py @@ -0,0 +1,69 @@ +# Copyright 2016-2017 Versada +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.tests import TransactionCase + +from ..logutils import SanitizeOdooCookiesProcessor + + +class TestOdooCookieSanitizer(TransactionCase): + def test_cookie_as_string(self): + data = { + "request": { + "cookies": "website_lang=en_us;" + "session_id=hello;" + "Session_ID=hello;" + "foo=bar" + } + } + + proc = SanitizeOdooCookiesProcessor() + result = proc.process(data) + + self.assertTrue("request" in result) + http = result["request"] + self.assertEqual( + http["cookies"], + "website_lang=en_us;" + "session_id={m};" + "Session_ID={m};" + "foo=bar".format(m=proc.MASK), + ) + + def test_cookie_as_string_with_partials(self): + data = {"request": {"cookies": "website_lang=en_us;session_id;foo=bar"}} + + proc = SanitizeOdooCookiesProcessor() + result = proc.process(data) + + self.assertTrue("request" in result) + http = result["request"] + self.assertEqual( + http["cookies"], + "website_lang=en_us;session_id;foo=bar", + ) + + def test_cookie_header(self): + data = { + "request": { + "headers": { + "Cookie": "foo=bar;" + "session_id=hello;" + "Session_ID=hello;" + "a_session_id_here=hello" + } + } + } + + proc = SanitizeOdooCookiesProcessor() + result = proc.process(data) + + self.assertTrue("request" in result) + http = result["request"] + self.assertEqual( + http["headers"]["Cookie"], + "foo=bar;" + "session_id={m};" + "Session_ID={m};" + "a_session_id_here={m}".format(m=proc.MASK), + ) diff --git a/setup/sentry/odoo/addons/sentry b/setup/sentry/odoo/addons/sentry new file mode 120000 index 000000000..40ffa7e91 --- /dev/null +++ b/setup/sentry/odoo/addons/sentry @@ -0,0 +1 @@ +../../../../sentry \ No newline at end of file diff --git a/setup/sentry/setup.py b/setup/sentry/setup.py new file mode 100644 index 000000000..28c57bb64 --- /dev/null +++ b/setup/sentry/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)