summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorArmin Ronacher <armin.ronacher@active-4.com>2018-03-29 21:03:23 +0200
committerGitHub <noreply@github.com>2018-03-29 21:03:23 +0200
commit1328d65561801f6b500e8ba3bf712cdfe1691a3a (patch)
treed48aa12f0646e74f4befa63b1fdab55b4e5aed99
parentfcffd7ec731b4278e76c0febf2110729a320a86d (diff)
downloadraven-1328d65561801f6b500e8ba3bf712cdfe1691a3a.tar.gz
feat: Add a client for Sanic
-rw-r--r--.travis.yml11
-rw-r--r--conftest.py5
-rw-r--r--raven/contrib/sanic.py224
-rwxr-xr-xsetup.py11
-rw-r--r--tests/contrib/sanic/__init__.py0
-rw-r--r--tests/contrib/sanic/tests.py240
-rw-r--r--tox.ini6
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)
diff --git a/setup.py b/setup.py
index 9533ff1..16150c2 100755
--- a/setup.py
+++ b/setup.py
@@ -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'])
diff --git a/tox.ini b/tox.ini
index 9066915..1310257 100644
--- a/tox.ini
+++ b/tox.ini
@@ -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