Skip to content

Commit

Permalink
Initial implementation and tests
Browse files Browse the repository at this point in the history
  • Loading branch information
mattbennett committed Jul 29, 2015
1 parent da9f726 commit f8e92c8
Show file tree
Hide file tree
Showing 9 changed files with 380 additions and 0 deletions.
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.coverage
*.pyc
*.egg-info
.tox
20 changes: 20 additions & 0 deletions .travis.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
sudo: false
language: python
python:
- '2.7'
install:
- pip install tox
env:
- TOX_ENV=py27-test
- TOX_ENV=py33-test
- TOX_ENV=py34-test
script:
- tox -e $TOX_ENV
deploy:
provider: pypi
user: mattbennett
password:
secure: thV7e6kG3jA8x0/2DINX7o8U8IhOfhi6lmXenpTZXJWcbqa0bZZQvbwsPRIrlZkZlrGMEirfwsKmmK8UxBiFG9lgbOT8RMH6gyF1fW4cfINm3LQ3/U2Jjww3ZE7Km/ogVi18TQZxVSGUIQP3UUV9sEjif97N8VMON9HjsdbfquHc2XlFsP/4POK95uwOVU8W0cDWq5A4+Lh1L0IFDzUWk3AefH9HssGP386tlM9Yxuozh9bdgr+zpOYH3SDtGmA2mh3/32m4ksbI5KfBGk1g0Quf92Ql2Lykt/9BMLPEE18mEABODj3xursQvBEWTqVImdO6ykUoXEPm6MUkcBsy+v/u04OVgN9NECqvzjfbr/d2KclzNUiJJZu6l2T424zfeICQ+l0Q4hcd3QIJaqcnEEn3BkAQyfpwkMn9JOBgR1jYHXcUuHYOWfHUpG1b4j84VKvhhiLhw3HtUjZCVs5bIsVOQQAsv3la9uZyaSRQy2JnMYmcOipK+EriXOAdqNMSjGSozJ3T0/McIzn67YuqRJfkLeloHz98Toi1kB129LIRDJQQnjLC9hUZqzcvEiO2ZuP3R0VoHXJoKwjF95uTkoMGHTlbdanGeB4ZlaZSg3xctYZH2T9ajko1rkvYteK0d3tO1zI0ZOsTG8XldHa+zublI5CCjRRlwmPlxDAxk6I=
on:
tags: true
repo: mattbennett/nameko-sentry
13 changes: 13 additions & 0 deletions LICENCE
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
Copyright 2015 Matt Bennett

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
11 changes: 11 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
test: flake8 pylint pytest

flake8:
flake8 nameko_sentry.py test_nameko_sentry.py

pylint:
pylint nameko_sentry -E

pytest:
coverage run --concurrency=eventlet --source nameko_sentry.py --branch -m pytest test_nameko_sentry.py
coverage report --show-missing --fail-under=100
83 changes: 83 additions & 0 deletions conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
from __future__ import absolute_import

# all imports are inline to make sure they happen after eventlet.monkey_patch
# which is called in pytest_load_initial_conftests (calling monkey_patch at
# import time breaks the pytest capturemanager)

import pytest


def pytest_addoption(parser):
parser.addoption(
'--blocking-detection',
action='store_true',
dest='blocking_detection',
default=False,
help='turn on eventlet hub blocking detection')

parser.addoption(
"--log-level", action="store",
default='DEBUG',
help=("The logging-level for the test run."))

parser.addoption(
"--amqp-uri", action="store", dest='AMQP_URI',
default='amqp://guest:guest@localhost:5672/nameko_test',
help=("The AMQP-URI to connect to rabbit with."))

parser.addoption(
"--rabbit-ctl-uri", action="store", dest='RABBIT_CTL_URI',
default='http://guest:guest@localhost:15672',
help=("The URI for rabbit's management API."))


def pytest_load_initial_conftests():
# make sure we monkey_patch before local conftests
import eventlet
eventlet.monkey_patch()


def pytest_configure(config):
import logging
import sys

if config.option.blocking_detection: # pragma: no cover
from eventlet import debug
debug.hub_blocking_detection(True)

log_level = config.getoption('log_level')
if log_level is not None:
log_level = getattr(logging, log_level)
logging.basicConfig(level=log_level, stream=sys.stderr)


@pytest.fixture
def ensure_cleanup_order(request):
""" Ensure ``rabbit_config`` is invoked early if it's used by any fixture
in ``request``.
"""
if "rabbit_config" in request.funcargnames:
request.getfuncargvalue("rabbit_config")


@pytest.yield_fixture
def container_factory(ensure_cleanup_order):
from nameko.containers import ServiceContainer

all_containers = []

def make_container(service_cls, config, worker_ctx_cls=None):
container = ServiceContainer(service_cls, config, worker_ctx_cls)
all_containers.append(container)
return container

yield make_container

for c in all_containers:
try:
c.stop()
except: # pragma: no cover
pass



55 changes: 55 additions & 0 deletions nameko_sentry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import logging

from nameko.extensions import DependencyProvider
from raven import Client
from raven.transport.eventlet import EventletHTTPTransport


class SentryReporter(DependencyProvider):
""" Send exceptions generated by entrypoints to a sentry server.
"""
def setup(self):
sentry_config = self.container.config.get('SENTRY')

dsn = sentry_config['DSN']
kwargs = sentry_config.get('CLIENT_CONFIG', {})

self.client = Client(dsn, transport=EventletHTTPTransport, **kwargs)

def worker_result(self, worker_ctx, result, exc_info):
if exc_info is None:
return

exc = exc_info[1]
call_id = worker_ctx.call_id
parent_call_id = worker_ctx.immediate_parent_call_id

expected_exceptions = getattr(
worker_ctx.entrypoint, 'expected_exceptions', tuple())

level = logging.ERROR
if expected_exceptions and isinstance(exc, expected_exceptions):
level = logging.WARNING

message = (
'Unhandled exception in call {}: '
'{} {!r}'.format(call_id, exc_info[0].__name__, str(exc))
)

logger = '{}.{}'.format(
worker_ctx.service_name, worker_ctx.entrypoint.method_name)

data = {
'logger': logger,
'level': level,
'message': message,
'tags': {
'call_id': call_id,
'parent_call_id': parent_call_id,
},
}

extra = {'exc': exc}

self.client.captureException(
exc_info, message=message, extra=extra, data=data)
43 changes: 43 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
#!/usr/bin/env python
from setuptools import setup

setup(
name='nameko-sentry',
version='0.0.1',
description='Nameko extension sends entrypoint exceptions to sentry',
author='Matt Bennett',
author_email='[email protected]',
url='http://github.com/mattbennett/nameko-sentry',
py_modules=['nameko_sentry'],
install_requires=[
"nameko>=2.0.0",
"raven>=3.0.0"
],
extras_require={
'dev': [
"coverage==4.0a1",
"flake8==2.1.0",
"mccabe==0.3",
"pep8==1.6.1",
"pyflakes==0.8.1",
"pylint==1.0.0",
"pytest==2.4.2",
]
},
dependency_links=[],
zip_safe=True,
license='Apache License, Version 2.0',
classifiers=[
"Programming Language :: Python",
"Operating System :: MacOS :: MacOS X",
"Operating System :: POSIX",
"Programming Language :: Python :: 2",
"Programming Language :: Python :: 2.7",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.3",
"Programming Language :: Python :: 3.4",
"Topic :: Internet",
"Topic :: Software Development :: Libraries :: Python Modules",
"Intended Audience :: Developers",
]
)
141 changes: 141 additions & 0 deletions test_nameko_sentry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import logging

from mock import Mock, patch, call
import pytest

from nameko.containers import WorkerContext
from nameko.extensions import Entrypoint
from nameko.testing.services import dummy, entrypoint_hook, entrypoint_waiter
from nameko.testing.utils import get_extension
from raven.transport.eventlet import EventletHTTPTransport

from nameko_sentry import SentryReporter


class CustomException(Exception):
pass


@pytest.fixture
def config():
return {
'SENTRY': {
'DSN': 'http://user:pass@localhost:9000/1',
'CLIENT_CONFIG': {
'site': 'site name'
}
}
}


@pytest.fixture
def container(config):
return Mock(config=config)


@pytest.fixture(params=[tuple(), CustomException]) # expected exceptions
def worker_ctx(request, container):

service = Mock()
entrypoint = Mock(spec=Entrypoint, expected_exceptions=request.param)
args = ("a", "b", "c")
kwargs = {"d": "d", "e": "e"}
data = {
'call_id': 'service.entrypoint.1',
'call_id_stack': [
'standalone_rpc_proxy.call.0'
]
}

return WorkerContext(
container, service, entrypoint, args=args, kwargs=kwargs, data=data
)


@pytest.fixture
def reporter(container):
return SentryReporter().bind(container, "sentry")


def test_setup(reporter):
reporter.setup()

# client config and DSN applied correctly
assert reporter.client.site == "site name"
assert reporter.client.get_public_dsn() == "//user@localhost:9000/1"

# transport set correctly
transport = reporter.client.remote.get_transport()
assert isinstance(transport, EventletHTTPTransport)


def test_worker_result(reporter, worker_ctx):
result = "OK!"

reporter.setup()
with patch.object(reporter, 'client') as client:
reporter.worker_result(worker_ctx, result, None)

assert not client.captureException.called


def test_worker_exception(reporter, worker_ctx):

exc = CustomException("Error!")
exc_info = (CustomException, exc, None)

reporter.setup()
with patch.object(reporter, 'client') as client:
reporter.worker_result(worker_ctx, None, exc_info)

# generate expected call args
logger = "{}.{}".format(
worker_ctx.service_name, worker_ctx.entrypoint.method_name)
message = "Unhandled exception in call {}: {} {!r}".format(
worker_ctx.call_id, CustomException.__name__, str(exc)
)
extra = {'exc': exc}

if isinstance(exc, worker_ctx.entrypoint.expected_exceptions):
loglevel = logging.WARNING
else:
loglevel = logging.ERROR

data = {
'logger': logger,
'level': loglevel,
'message': message,
'tags': {
'call_id': worker_ctx.call_id,
'parent_call_id': worker_ctx.immediate_parent_call_id
}
}

# verify call
assert client.captureException.call_args_list == [
call(exc_info, message=message, extra=extra, data=data)
]


def test_end_to_end(container_factory, config):

class Service(object):
name = "service"

sentry = SentryReporter()

@dummy
def broken(self):
raise CustomException("Error!")

container = container_factory(Service, config)
container.start()

reporter = get_extension(container, SentryReporter)

with patch.object(reporter, 'client') as client:
with entrypoint_hook(container, 'broken') as broken:
with entrypoint_waiter(container, 'broken'):
with pytest.raises(CustomException):
broken()
assert client.captureException.call_count == 1
10 changes: 10 additions & 0 deletions tox.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
[tox]
envlist = {py27,py33,py34}-test
skipsdist = True

[testenv]
whitelist_externals = make

commands =
pip install --editable .[dev]
make test

0 comments on commit f8e92c8

Please sign in to comment.