diff options
author | Armin Ronacher <armin.ronacher@active-4.com> | 2018-03-29 21:03:23 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2018-03-29 21:03:23 +0200 |
commit | 1328d65561801f6b500e8ba3bf712cdfe1691a3a (patch) | |
tree | d48aa12f0646e74f4befa63b1fdab55b4e5aed99 | |
parent | fcffd7ec731b4278e76c0febf2110729a320a86d (diff) | |
download | raven-1328d65561801f6b500e8ba3bf712cdfe1691a3a.tar.gz |
feat: Add a client for Sanic
-rw-r--r-- | .travis.yml | 11 | ||||
-rw-r--r-- | conftest.py | 5 | ||||
-rw-r--r-- | raven/contrib/sanic.py | 224 | ||||
-rwxr-xr-x | setup.py | 11 | ||||
-rw-r--r-- | tests/contrib/sanic/__init__.py | 0 | ||||
-rw-r--r-- | tests/contrib/sanic/tests.py | 240 | ||||
-rw-r--r-- | tox.ini | 6 |
7 files changed, 495 insertions, 2 deletions
diff --git a/.travis.yml b/.travis.yml index 5bab4d9..91a4875 100644 --- a/.travis.yml +++ b/.travis.yml @@ -31,6 +31,9 @@ jobs: python: 3.5 env: TOXENV=py35 - stage: core + python: 3.6 + env: TOXENV=py36 + - stage: core python: pypy env: TOXENV=pypy - stage: core @@ -125,6 +128,14 @@ jobs: python: 3.6 env: TOXENV=py36-lambda + - stage: contrib + python: 3.5 + env: TOXENV=py35-sanic-07 + + - stage: contrib + python: 3.6 + env: TOXENV=py36-sanic-07 + - stage: deploy script: ./setup.py sdist --formats=gztar bdist_wheel if: branch = master diff --git a/conftest.py b/conftest.py index 70d582d..9d4ea05 100644 --- a/conftest.py +++ b/conftest.py @@ -31,6 +31,11 @@ except ImportError: collect_ignore.append('tests/contrib/django') try: + import Sanic # NOQA +except ImportError: + collect_ignore.append('tests/contrib/sanic') + +try: import tastypie # NOQA except ImportError: collect_ignore.append('tests/contrib/django/test_tastypie.py') diff --git a/raven/contrib/sanic.py b/raven/contrib/sanic.py new file mode 100644 index 0000000..cdc5920 --- /dev/null +++ b/raven/contrib/sanic.py @@ -0,0 +1,224 @@ +""" +raven.contrib.sanic +~~~~~~~~~~~~~~~~~~~ + +:copyright: (c) 2010-2018 by the Sentry Team, see AUTHORS for more details. +:license: BSD, see LICENSE for more details. +""" + +from __future__ import absolute_import + +import logging + +import blinker + +from raven.conf import setup_logging +from raven.base import Client +from raven.handlers.logging import SentryHandler +from raven.utils.compat import urlparse +from raven.utils.encoding import to_unicode +from raven.utils.conf import convert_options + + +raven_signals = blinker.Namespace() +logging_configured = raven_signals.signal('logging_configured') + + +def make_client(client_cls, app, dsn=None): + return client_cls( + **convert_options( + app.config, + defaults={ + 'dsn': dsn, + 'include_paths': ( + set(app.config.get('SENTRY_INCLUDE_PATHS', [])) + | set([app.name]) + ), + 'extra': { + 'app': app, + }, + }, + ) + ) + + +class Sentry(object): + """ + Sanic application for Sentry. + + Look up configuration from ``os.environ['SENTRY_DSN']``:: + + >>> sentry = Sentry(app) + + Pass an arbitrary DSN:: + + >>> sentry = Sentry(app, dsn='http://public:secret@example.com/1') + + Pass an explicit client:: + + >>> sentry = Sentry(app, client=client) + + Automatically configure logging:: + + >>> sentry = Sentry(app, logging=True, level=logging.ERROR) + + Capture an exception:: + + >>> try: + >>> 1 / 0 + >>> except ZeroDivisionError: + >>> sentry.captureException() + + Capture a message:: + + >>> sentry.captureMessage('hello, world!') + """ + + def __init__(self, app, client=None, client_cls=Client, dsn=None, + logging=False, logging_exclusions=None, level=logging.NOTSET): + if client and not isinstance(client, Client): + raise TypeError('client should be an instance of Client') + + self.client = client + self.client_cls = client_cls + self.dsn = dsn + self.logging = logging + self.logging_exclusions = logging_exclusions + self.level = level + self.init_app(app) + + def handle_exception(self, request, exception): + if not self.client: + return + try: + self.client.http_context(self.get_http_info(request)) + except Exception as e: + self.client.logger.exception(to_unicode(e)) + + # Since Sanic is restricted to Python 3, let's be explicit with what + # we pass for exception info, rather than relying on sys.exc_info(). + exception_info = (type(exception), exception, exception.__traceback__) + self.captureException(exc_info=exception_info) + + def get_form_data(self, request): + return request.form + + def get_http_info(self, request): + """ + Determine how to retrieve actual data by using request.mimetype. + """ + if self.is_json_type(request): + retriever = self.get_json_data + else: + retriever = self.get_form_data + return self.get_http_info_with_retriever(request, retriever) + + def get_json_data(self, request): + return request.json + + def get_http_info_with_retriever(self, request, retriever): + """ + Exact method for getting http_info but with form data work around. + """ + urlparts = urlparse.urlsplit(request.url) + + try: + data = retriever(request) + except Exception: + data = {} + + return { + 'url': '{0}://{1}{2}'.format( + urlparts.scheme, urlparts.netloc, urlparts.path), + 'query_string': urlparts.query, + 'method': request.method, + 'data': data, + 'cookies': request.cookies, + 'headers': request.headers, + 'env': { + 'REMOTE_ADDR': request.remote_addr, + } + } + + def is_json_type(self, request): + content_type = request.headers.get('content-type') + return content_type == 'application/json' + + def init_app(self, app, dsn=None, logging=None, level=None, + logging_exclusions=None): + if dsn is not None: + self.dsn = dsn + + if level is not None: + self.level = level + + if logging is not None: + self.logging = logging + + if logging_exclusions is None: + self.logging_exclusions = ( + 'root', 'sanic.access', 'sanic.error') + else: + self.logging_exclusions = logging_exclusions + + if not self.client: + self.client = make_client(self.client_cls, app, self.dsn) + + if self.logging: + kwargs = {} + if self.logging_exclusions is not None: + kwargs['exclude'] = self.logging_exclusions + handler = SentryHandler(self.client, level=self.level) + setup_logging(handler, **kwargs) + logging_configured.send( + self, sentry_handler=SentryHandler, **kwargs) + + if not hasattr(app, 'extensions'): + app.extensions = {} + app.extensions['sentry'] = self + + app.error_handler.add(Exception, self.handle_exception) + app.register_middleware(self.before_request, attach_to='request') + app.register_middleware(self.after_request, attach_to='response') + + def before_request(self, request): + self.last_event_id = None + try: + self.client.http_context(self.get_http_info(request)) + except Exception as e: + self.client.logger.exception(to_unicode(e)) + + def after_request(self, request, response): + if self.last_event_id: + response.headers['X-Sentry-ID'] = self.last_event_id + self.client.context.clear() + + def captureException(self, *args, **kwargs): + assert self.client, 'captureException called before application configured' + result = self.client.captureException(*args, **kwargs) + self.set_last_event_id_from_result(result) + return result + + def captureMessage(self, *args, **kwargs): + assert self.client, 'captureMessage called before application configured' + result = self.client.captureMessage(*args, **kwargs) + self.set_last_event_id_from_result(result) + return result + + def set_last_event_id_from_result(self, result): + if result: + self.last_event_id = self.client.get_ident(result) + else: + self.last_event_id = None + + def user_context(self, *args, **kwargs): + assert self.client, 'user_context called before application configured' + return self.client.user_context(*args, **kwargs) + + def tags_context(self, *args, **kwargs): + assert self.client, 'tags_context called before application configured' + return self.client.tags_context(*args, **kwargs) + + def extra_context(self, *args, **kwargs): + assert self.client, 'extra_context called before application configured' + return self.client.extra_context(*args, **kwargs) @@ -46,16 +46,24 @@ flask_tests_requires = [ 'Flask-Login>=0.2.0', ] +sanic_requires = [] +sanic_tests_requires = [] + webpy_tests_requires = [ 'paste', 'web.py', ] -# If it's python3, remove unittest2 & web.py +# If it's python3, remove unittest2 & web.py. if sys.version_info[0] == 3: unittest2_requires = [] webpy_tests_requires = [] +# If it's Python 3.5+, add Sanic packages. +if sys.version_info >= (3, 5): + sanic_requires = ['sanic>=0.7.0', ] + sanic_tests_requires = ['aiohttp', ] + tests_require = [ 'bottle', 'celery>=2.5', @@ -84,6 +92,7 @@ tests_require = [ 'ZConfig', ] + ( flask_requires + flask_tests_requires + + sanic_requires + sanic_tests_requires + unittest2_requires + webpy_tests_requires ) diff --git a/tests/contrib/sanic/__init__.py b/tests/contrib/sanic/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tests/contrib/sanic/__init__.py diff --git a/tests/contrib/sanic/tests.py b/tests/contrib/sanic/tests.py new file mode 100644 index 0000000..52ef598 --- /dev/null +++ b/tests/contrib/sanic/tests.py @@ -0,0 +1,240 @@ +import json +import logging +import pytest +import sys + +from exam import before, fixture +from mock import patch, Mock + +from raven.contrib.sanic import Sentry, logging_configured +from raven.handlers.logging import SentryHandler +from raven.utils.testutils import InMemoryClient, TestCase + + +if sys.version_info >= (3, 5): + from sanic import Sanic, response + +# When using test_client, Sanic will run the server at 127.0.0.1:42101. +# For ease of reading, let's establish that as a constant. +BASE_URL = '127.0.0.1:42101' + +def create_app(ignore_exceptions=None, debug=False, **config): + import os + app = Sanic(__name__) + for key, value in config.items(): + app.config[key] = value + + app.debug = debug + + if ignore_exceptions: + app.config['RAVEN_IGNORE_EXCEPTIONS'] = ignore_exceptions + + @app.route('/an-error/', methods=['GET', 'POST']) + def an_error(request): + raise ValueError('hello world') + + @app.route('/log-an-error/', methods=['GET']) + def log_an_error(request): + logger = logging.getLogger('random-logger') + logger.error('Log an error') + return response.text('Hello') + + @app.route('/capture/', methods=['GET', 'POST']) + def capture_exception(request): + try: + raise ValueError('Boom') + except Exception: + request.app.extensions['sentry'].captureException() + return response.text('Hello') + + @app.route('/message/', methods=['GET', 'POST']) + def capture_message(request): + request.app.extensions['sentry'].captureMessage('Interesting') + return response.text('World') + + return app + +@pytest.mark.skipif(sys.version_info < (3,5), reason="Requires Python 3.5+.") +class BaseTest(TestCase): + @fixture + def app(self): + return create_app() + + @fixture + def client(self): + return self.app.test_client + + @before + def bind_sentry(self): + self.raven = InMemoryClient() + self.middleware = Sentry(self.app, client=self.raven) + + def make_client_and_raven(self, logging=False, *args, **kwargs): + app = create_app(*args, **kwargs) + raven = InMemoryClient() + Sentry(app, logging=logging, client=raven) + return app.test_client, raven, app + +@pytest.mark.skipif(sys.version_info < (3,5), reason="Requires Python 3.5+.") +class SanicTest(BaseTest): + def test_does_add_to_extensions(self): + self.assertIn('sentry', self.app.extensions) + self.assertEquals(self.app.extensions['sentry'], self.middleware) + + def test_error_handler(self): + request, response = self.client.get('/an-error/') + self.assertEquals(response.status, 500) + self.assertEquals(len(self.raven.events), 1) + + event = self.raven.events.pop(0) + + assert 'exception' in event + exc = event['exception']['values'][-1] + self.assertEquals(exc['type'], 'ValueError') + self.assertEquals(exc['value'], 'hello world') + self.assertEquals(event['level'], logging.ERROR) + self.assertEquals(event['message'], 'ValueError: hello world') + + def test_capture_plus_logging(self): + client, raven, app = self.make_client_and_raven( + debug=False, logging=True) + client.get('/an-error/') + client.get('/log-an-error/') + assert len(raven.events) == 2 + + def test_get(self): + request, response = self.client.get('/an-error/?foo=bar') + self.assertEquals(response.status, 500) + self.assertEquals(len(self.raven.events), 1) + + event = self.raven.events.pop(0) + + assert 'request' in event + http = event['request'] + self.assertEquals(http['url'], 'http://{0}/an-error/'.format(BASE_URL)) + self.assertEquals(http['query_string'], 'foo=bar') + self.assertEquals(http['method'], 'GET') + self.assertEquals(http['data'], {}) + self.assertTrue('headers' in http) + headers = http['headers'] + self.assertTrue('host' in headers, headers.keys()) + self.assertEqual(headers['host'], BASE_URL) + self.assertTrue('user-agent' in headers, headers.keys()) + self.assertTrue('aiohttp' in headers['user-agent']) + + def test_post_form(self): + request, response = self.client.post('/an-error/?biz=baz', data={'foo': 'bar'}) + self.assertEquals(response.status, 500) + self.assertEquals(len(self.raven.events), 1) + event = self.raven.events.pop(0) + + assert 'request' in event + http = event['request'] + self.assertEquals(http['url'], 'http://{0}/an-error/'.format(BASE_URL)) + self.assertEquals(http['query_string'], 'biz=baz') + self.assertEquals(http['method'], 'POST') + self.assertEquals(http['data'], {'foo': ['bar']}) + self.assertTrue('headers' in http) + headers = http['headers'] + self.assertTrue('host' in headers, headers.keys()) + self.assertEqual(headers['host'], BASE_URL) + self.assertTrue('user-agent' in headers, headers.keys()) + self.assertTrue('aiohttp' in headers['user-agent']) + + def test_post_json(self): + request, response = self.client.post( + '/an-error/?biz=baz', data=json.dumps({'foo': 'bar'}), + headers={'content-type': 'application/json'}) + self.assertEquals(response.status, 500) + self.assertEquals(len(self.raven.events), 1) + event = self.raven.events.pop(0) + assert 'request' in event + http = event['request'] + self.assertEquals(http['url'], 'http://{0}/an-error/'.format(BASE_URL)) + self.assertEquals(http['query_string'], 'biz=baz') + self.assertEquals(http['method'], 'POST') + self.assertEquals(http['data'], {'foo': 'bar'}) + self.assertTrue('headers' in http) + headers = http['headers'] + self.assertTrue('host' in headers, headers.keys()) + self.assertEqual(headers['host'], BASE_URL) + self.assertTrue('user-agent' in headers, headers.keys()) + self.assertTrue('aiohttp' in headers['user-agent']) + + def test_captureException_captures_http(self): + request, response = self.client.get('/capture/?foo=bar') + self.assertEquals(response.status, 200) + self.assertEquals(len(self.raven.events), 1) + + event = self.raven.events.pop(0) + self.assertEquals(event['event_id'], response.headers['X-Sentry-ID']) + + assert event['message'] == 'ValueError: Boom' + print(event) + assert 'request' in event + assert 'exception' in event + + def test_captureMessage_captures_http(self): + request, response = self.client.get('/message/?foo=bar') + self.assertEquals(response.status, 200) + self.assertEquals(len(self.raven.events), 1) + + event = self.raven.events.pop(0) + self.assertEquals(event['event_id'], response.headers['X-Sentry-ID']) + + assert 'sentry.interfaces.Message' in event + assert 'request' in event + + def test_captureException_sets_last_event_id(self): + try: + raise ValueError + except Exception: + self.middleware.captureException() + else: + self.fail() + + event_id = self.raven.events.pop(0)['event_id'] + assert self.middleware.last_event_id == event_id + + def test_captureMessage_sets_last_event_id(self): + self.middleware.captureMessage('foo') + + event_id = self.raven.events.pop(0)['event_id'] + assert self.middleware.last_event_id == event_id + + def test_logging_setup_signal(self): + app = Sanic(__name__) + + mock_handler = Mock() + + def receiver(sender, *args, **kwargs): + self.assertIn("exclude", kwargs) + mock_handler(*args, **kwargs) + + logging_configured.connect(receiver) + raven = InMemoryClient() + + Sentry( + app, client=raven, logging=True, + logging_exclusions=("excluded_logger",)) + + mock_handler.assert_called() + + def test_check_client_type(self): + self.assertRaises(TypeError, lambda _: Sentry(self.app, "oops, I'm putting my DSN instead")) + + def test_uses_dsn(self): + app = Sanic(__name__) + sentry = Sentry(app, dsn='http://public:secret@example.com/1') + assert sentry.client.remote.base_url == 'http://example.com' + + def test_binds_default_include_paths(self): + app = Sanic(__name__) + sentry = Sentry(app, dsn='http://public:secret@example.com/1') + assert sentry.client.include_paths == set([app.name]) + + def test_overrides_default_include_paths(self): + app = Sanic(__name__) + app.config['SENTRY_CONFIG'] = {'include_paths': ['foo.bar']} + sentry = Sentry(app, dsn='http://public:secret@example.com/1') + assert sentry.client.include_paths == set(['foo.bar']) @@ -6,7 +6,7 @@ [tox] envlist = # core - py{27,33,34,35} + py{27,33,34,35,36} pypy flake8 # contrib @@ -22,6 +22,7 @@ envlist = py35-flask-dev py27-celery-{3,4} py{27,36}-lambda + py{35,36}-sanic-07 [testenv] @@ -46,12 +47,15 @@ deps = flask-dev: flask-login celery-3: Celery>=3.1,<3.2 celery-4: Celery>=4.0,<4.1 + sanic-07: sanic>=0.7,<0.8 + sanic-07: aiohttp fix: git+https://github.com/pytest-dev/pytest-django.git#egg=pytest_django setenv = PYTHONDONTWRITEBYTECODE=1 TESTPATH=tests django: TESTPATH=tests/contrib/django flask: TESTPATH=tests/contrib/flask + sanic: TESTPATH=tests/contrib/sanic celery: TESTPATH=tests/contrib/test_celery.py lambda: TESTPATH=tests/contrib/awslambda usedevelop = true |