diff --git a/requirements.txt b/requirements.txt index 08b8b0254..fdbf76491 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,3 +7,4 @@ IPy python-json-logger odoorpc + raven diff --git a/sentry/README.rst b/sentry/README.rst new file mode 100644 index 000000000..f7f61e14d --- /dev/null +++ b/sentry/README.rst @@ -0,0 +1,168 @@ +.. image:: https://img.shields.io/badge/licence-AGPL--3-blue.svg + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 + +====== +Sentry +====== + +This module allows painless `Sentry `__ integration with +Odoo. + +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 raven_ Python package to be available on +the system. It can be installed using pip:: + + pip install raven + +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_processors`` A string of comma-separated processor classes which will be applied ``raven.processors.SanitizePasswordsProcessor, + on an event before sending it to Sentry. odoo.addons.sentry.logutils.SanitizeOdooCookiesProcessor`` + +``sentry_transport`` Transport class which will be used to send events to Sentry. ``threaded`` + Possible values: *threaded*: spawns an async worker for processing + messages, *synchronous*: a synchronous blocking transport; + *requests_threaded*: an asynchronous transport using the *requests* + library; *requests_synchronous* - blocking transport using the + *requests* library. + +``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_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. +============================= ==================================================================== ========================================================== + +Other `client arguments +`_ can be +configured by prepending the argument name with *sentry_* in your Odoo config +file. Currently supported additional client arguments are: ``install_sys_hook, +include_paths, exclude_paths, machine, auto_log_stacks, capture_locals, +string_max_length, list_max_length, site, include_versions, environment``. + +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_processors = raven.processors.SanitizePasswordsProcessor,odoo.addons.sentry.logutils.SanitizeOdooCookiesProcessor + sentry_transport = threaded + sentry_include_context = true + sentry_environment = production + sentry_auto_log_stacks = false + 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/10.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 smash it by providing detailed and welcomed feedback. + +Credits +======= + +Images +------ + +* `Module Icon `_ + +Contributors +------------ + +* Mohammed Barsi +* Andrius Preimantas +* Naglis Jonaitis + +Maintainer +---------- + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +This module is maintained by the OCA. + +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. + +To contribute to this module, please visit https://odoo-community.org. + + +.. _raven: https://github.com/getsentry/raven-python diff --git a/sentry/__init__.py b/sentry/__init__.py new file mode 100644 index 000000000..fe841dc4a --- /dev/null +++ b/sentry/__init__.py @@ -0,0 +1,78 @@ +# -*- coding: utf-8 -*- +# Copyright 2016-2017 Versada +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import logging + +from odoo.service import wsgi_server +from odoo.tools import config as odoo_config + +from . import const +from .logutils import LoggerNameFilter, OdooSentryHandler + +_logger = logging.getLogger(__name__) +HAS_RAVEN = True +try: + import raven + from raven.middleware import Sentry +except ImportError: + HAS_RAVEN = False + _logger.debug('Cannot import "raven". Please make sure it is installed.') + + +def get_odoo_commit(odoo_dir): + '''Attempts to get Odoo git commit from :param:`odoo_dir`.''' + if not odoo_dir: + return + try: + return raven.fetch_git_sha(odoo_dir) + except raven.exceptions.InvalidGitRepository: + _logger.debug( + u'Odoo directory: "%s" not a valid git repository', odoo_dir) + + +def initialize_raven(config, client_cls=None): + ''' + Setup an instance of :class:`raven.Client`. + + :param config: Sentry configuration + :param client: class used to instantiate the raven client. + ''' + enabled = config.get('sentry_enabled', False) + if not (HAS_RAVEN and enabled): + return + options = { + 'release': get_odoo_commit(config.get('sentry_odoo_dir')), + } + for option in const.get_sentry_options(): + value = config.get('sentry_%s' % option.key, option.default) + if callable(option.converter): + value = option.converter(value) + options[option.key] = value + + level = config.get('sentry_logging_level', const.DEFAULT_LOG_LEVEL) + exclude_loggers = const.split_multiple( + config.get('sentry_exclude_loggers', const.DEFAULT_EXCLUDE_LOGGERS) + ) + if level not in const.LOG_LEVEL_MAP: + level = const.DEFAULT_LOG_LEVEL + + client_cls = client_cls or raven.Client + client = client_cls(**options) + handler = OdooSentryHandler( + config.get('sentry_include_context', True), + client=client, + level=const.LOG_LEVEL_MAP[level], + ) + if exclude_loggers: + handler.addFilter(LoggerNameFilter( + exclude_loggers, name='sentry.logger.filter')) + raven.conf.setup_logging(handler) + wsgi_server.application = Sentry( + wsgi_server.application, client=client) + + client.captureMessage('Starting Odoo Server') + return client + + +sentry_client = initialize_raven(odoo_config) diff --git a/sentry/__manifest__.py b/sentry/__manifest__.py new file mode 100644 index 000000000..1ef7acfdb --- /dev/null +++ b/sentry/__manifest__.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +# 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': '10.0.1.0.0', + 'category': 'Extra Tools', + 'website': 'https://odoo-community.org/', + 'author': 'Mohammed Barsi,' + 'Versada,' + 'Odoo Community Association (OCA)', + 'license': 'AGPL-3', + 'application': False, + 'installable': True, + 'external_dependencies': { + 'python': [ + 'raven', + ] + }, + 'depends': [ + 'base', + ], +} diff --git a/sentry/const.py b/sentry/const.py new file mode 100644 index 000000000..5ceb3e2f2 --- /dev/null +++ b/sentry/const.py @@ -0,0 +1,89 @@ +# -*- coding: utf-8 -*- +# Copyright 2016-2017 Versada +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import collections +import logging + +import odoo.loglevels + +_logger = logging.getLogger(__name__) +try: + import raven + from raven.conf import defaults +except ImportError: + _logger.debug('Cannot import "raven". Please make sure it is installed.') + + +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)] + + +SentryOption = collections.namedtuple( + 'SentryOption', ['key', 'default', 'converter']) + +# Mapping of Odoo logging level -> Python stdlib logging library log level. +LOG_LEVEL_MAP = dict([ + (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) + +PROCESSORS = ( + 'raven.processors.SanitizePasswordsProcessor', + 'odoo.addons.sentry.logutils.SanitizeOdooCookiesProcessor', +) +DEFAULT_PROCESSORS = ','.join(PROCESSORS) + +EXCLUDE_LOGGERS = ( + 'werkzeug', +) +DEFAULT_EXCLUDE_LOGGERS = ','.join(EXCLUDE_LOGGERS) + +DEFAULT_TRANSPORT = 'threaded' + + +def select_transport(name=DEFAULT_TRANSPORT): + return { + 'requests_synchronous': raven.transport.RequestsHTTPTransport, + 'requests_threaded': raven.transport.ThreadedRequestsHTTPTransport, + 'synchronous': raven.transport.HTTPTransport, + 'threaded': raven.transport.ThreadedHTTPTransport, + }.get(name, DEFAULT_TRANSPORT) + + +def get_sentry_options(): + return [ + SentryOption('dsn', '', str.strip), + SentryOption('install_sys_hook', False, None), + SentryOption('transport', DEFAULT_TRANSPORT, select_transport), + SentryOption('include_paths', '', split_multiple), + SentryOption('exclude_paths', '', split_multiple), + SentryOption('machine', defaults.NAME, None), + SentryOption('auto_log_stacks', defaults.AUTO_LOG_STACKS, None), + SentryOption('capture_locals', defaults.CAPTURE_LOCALS, None), + SentryOption('string_max_length', defaults.MAX_LENGTH_STRING, None), + SentryOption('list_max_length', defaults.MAX_LENGTH_LIST, None), + SentryOption('site', None, None), + SentryOption('include_versions', True, None), + SentryOption( + 'ignore_exceptions', DEFAULT_IGNORED_EXCEPTIONS, split_multiple), + SentryOption('processors', DEFAULT_PROCESSORS, split_multiple), + SentryOption('environment', None, None), + ] diff --git a/sentry/logutils.py b/sentry/logutils.py new file mode 100644 index 000000000..178a518db --- /dev/null +++ b/sentry/logutils.py @@ -0,0 +1,106 @@ +# -*- coding: utf-8 -*- +# Copyright 2016-2017 Versada +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import logging +import urlparse + +import odoo.http + +_logger = logging.getLogger(__name__) +try: + from raven.handlers.logging import SentryHandler + from raven.processors import SanitizePasswordsProcessor + from raven.utils.wsgi import get_environ, get_headers +except ImportError: + _logger.debug('Cannot import "raven". Please make sure it is installed.') + SentryHandler = object + SanitizePasswordsProcessor = object + + +def get_request_info(request): + ''' + Returns context data extracted from :param:`request`. + + Heavily based on flask integration for Sentry: https://git.io/vP4i9. + ''' + urlparts = urlparse.urlsplit(request.url) + return { + 'url': '%s://%s%s' % (urlparts.scheme, urlparts.netloc, urlparts.path), + 'query_string': urlparts.query, + 'method': request.method, + 'headers': dict(get_headers(request.environ)), + 'env': dict(get_environ(request.environ)), + } + + +def get_extra_context(): + ''' + Extracts additional context from the current request (if such is set). + ''' + request = odoo.http.request + try: + session = getattr(request, 'session', {}) + except RuntimeError: + ctx = {} + else: + ctx = { + 'tags': { + 'database': session.get('db', None), + }, + 'user': { + 'login': session.get('login', None), + 'uid': session.get('uid', None), + }, + 'extra': { + 'context': session.get('context', {}), + }, + } + if request.httprequest: + ctx.update({ + 'request': get_request_info(request.httprequest), + }) + return ctx + + +class LoggerNameFilter(logging.Filter): + ''' + Custom :class:`logging.Filter` which allows to filter loggers by name. + ''' + + def __init__(self, loggers, name=''): + super(LoggerNameFilter, self).__init__(name=name) + self._exclude_loggers = set(loggers) + + def filter(self, event): + return event.name not in self._exclude_loggers + + +class OdooSentryHandler(SentryHandler): + ''' + Customized :class:`raven.handlers.logging.SentryHandler`. + + Allows to add additional Odoo and HTTP request data to the event which is + sent to Sentry. + ''' + + def __init__(self, include_extra_context, *args, **kwargs): + super(OdooSentryHandler, self).__init__(*args, **kwargs) + self.include_extra_context = include_extra_context + + def emit(self, record): + if self.include_extra_context: + self.client.context.merge(get_extra_context()) + return super(OdooSentryHandler, self).emit(record) + + +class SanitizeOdooCookiesProcessor(SanitizePasswordsProcessor): + ''' + Custom :class:`raven.processors.Processor`. + + Allows to sanitize sensitive Odoo cookies, namely the "session_id" cookie. + ''' + + FIELDS = frozenset([ + 'session_id', + ]) 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/tests/__init__.py b/sentry/tests/__init__.py new file mode 100644 index 000000000..50cb79814 --- /dev/null +++ b/sentry/tests/__init__.py @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- +# 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..69bc1819f --- /dev/null +++ b/sentry/tests/test_client.py @@ -0,0 +1,125 @@ +# -*- coding: utf-8 -*- +# Copyright 2016-2017 Versada +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import logging +import sys +import unittest + +import raven + +from odoo import exceptions + +from .. import initialize_raven +from ..logutils import OdooSentryHandler + + +def log_handler_by_class(logger, handler_cls): + for handler in logger.handlers: + if isinstance(handler, handler_cls): + yield handler + + +def remove_logging_handler(logger_name, handler_cls): + '''Removes handlers of specified classes from a :class:`logging.Logger` + with a given name. + + :param string logger_name: name of the logger + + :param handler_cls: class of the handler to remove. You can pass a tuple of + classes to catch several classes + ''' + logger = logging.getLogger(logger_name) + for handler in log_handler_by_class(logger, handler_cls): + logger.removeHandler(handler) + + +class InMemoryClient(raven.Client): + '''A :class:`raven.Client` 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, **kwargs): + self.events = [] + super(InMemoryClient, self).__init__(**kwargs) + + def is_enabled(self): + return True + + def send(self, **kwargs): + self.events.append(kwargs) + + def has_event(self, event_level, event_msg): + for event in self.events: + if (event.get('level') == event_level and + event.get('message') == event_msg): + return True + return False + + +class TestClientSetup(unittest.TestCase): + + def setUp(self): + super(TestClientSetup, self).setUp() + self.logger = logging.getLogger(__name__) + + # Sentry is enabled by default, so the default handler will be added + # when the module is loaded. After that, subsequent calls to + # setup_logging will not re-add our handler. We explicitly remove + # OdooSentryHandler handler so we can test with our in-memory client. + remove_logging_handler('', OdooSentryHandler) + + def assertEventCaptured(self, client, event_level, event_msg): + self.assertTrue( + client.has_event(event_level, event_msg), + msg=u'Event: "%s" was not captured' % event_msg + ) + + def assertEventNotCaptured(self, client, event_level, event_msg): + self.assertFalse( + client.has_event(event_level, event_msg), + msg=u'Event: "%s" was captured' % event_msg + ) + + def test_initialize_raven_sets_dsn(self): + config = { + 'sentry_enabled': True, + 'sentry_dsn': 'http://public:secret@example.com/1', + } + client = initialize_raven(config, client_cls=InMemoryClient) + self.assertEqual(client.remote.base_url, 'http://example.com') + + def test_capture_event(self): + config = { + 'sentry_enabled': True, + 'sentry_dsn': 'http://public:secret@example.com/1', + } + level, msg = logging.WARNING, 'Test event, can be ignored' + client = initialize_raven(config, client_cls=InMemoryClient) + self.logger.log(level, msg) + self.assertEventCaptured(client, level, msg) + + def test_ignore_exceptions(self): + config = { + 'sentry_enabled': True, + 'sentry_dsn': 'http://public:secret@example.com/1', + 'sentry_ignore_exceptions': 'odoo.exceptions.UserError', + } + level, msg = logging.WARNING, 'Test UserError' + client = initialize_raven(config, client_cls=InMemoryClient) + + handlers = list( + log_handler_by_class(logging.getLogger(), OdooSentryHandler) + ) + self.assertTrue(handlers) + handler = handlers[0] + try: + raise exceptions.UserError(msg) + except exceptions.UserError: + exc_info = sys.exc_info() + record = logging.LogRecord( + __name__, level, __file__, 42, msg, (), exc_info) + handler.emit(record) + self.assertEventNotCaptured(client, level, msg) diff --git a/sentry/tests/test_logutils.py b/sentry/tests/test_logutils.py new file mode 100644 index 000000000..b81b69162 --- /dev/null +++ b/sentry/tests/test_logutils.py @@ -0,0 +1,78 @@ +# -*- coding: utf-8 -*- +# Copyright 2016-2017 Versada +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import unittest + +import mock + +from ..logutils import SanitizeOdooCookiesProcessor + + +class TestOdooCookieSanitizer(unittest.TestCase): + + def test_cookie_as_string(self): + data = { + 'request': { + 'cookies': 'website_lang=en_us;' + 'session_id=hello;' + 'Session_ID=hello;' + 'foo=bar', + }, + } + + proc = SanitizeOdooCookiesProcessor(mock.Mock()) + 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(mock.Mock()) + result = proc.process(data) + + self.assertTrue('request' in result) + http = result['request'] + self.assertEqual( + http['cookies'], + 'website_lang=en_us;session_id;foo=bar'.format(m=proc.MASK), + ) + + def test_cookie_header(self): + data = { + 'request': { + 'headers': { + 'Cookie': 'foo=bar;' + 'session_id=hello;' + 'Session_ID=hello;' + 'a_session_id_here=hello', + }, + }, + } + + proc = SanitizeOdooCookiesProcessor(mock.Mock()) + 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))