diff --git a/instana/instrumentation/pyramid/__init__.py b/instana/instrumentation/pyramid/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/instana/instrumentation/pyramid/tweens.py b/instana/instrumentation/pyramid/tweens.py new file mode 100644 index 00000000..923f8de2 --- /dev/null +++ b/instana/instrumentation/pyramid/tweens.py @@ -0,0 +1,79 @@ +from __future__ import absolute_import + +from pyramid.httpexceptions import HTTPException + +import opentracing as ot +import opentracing.ext.tags as ext + +from ...log import logger +from ...singletons import tracer, agent +from ...util import strip_secrets + +class InstanaTweenFactory(object): + """A factory that provides Instana instrumentation tween for Pyramid apps""" + + def __init__(self, handler, registry): + self.handler = handler + + def __call__(self, request): + ctx = tracer.extract(ot.Format.HTTP_HEADERS, request.headers) + scope = tracer.start_active_span('http', child_of=ctx) + + scope.span.set_tag(ext.SPAN_KIND, ext.SPAN_KIND_RPC_SERVER) + scope.span.set_tag("http.host", request.host) + scope.span.set_tag(ext.HTTP_METHOD, request.method) + scope.span.set_tag(ext.HTTP_URL, request.path) + + if request.matched_route is not None: + scope.span.set_tag("http.path_tpl", request.matched_route.pattern) + + if hasattr(agent, 'extra_headers') and agent.extra_headers is not None: + for custom_header in agent.extra_headers: + # Headers are available in this format: HTTP_X_CAPTURE_THIS + h = ('HTTP_' + custom_header.upper()).replace('-', '_') + if h in request.headers: + scope.span.set_tag("http.%s" % custom_header, request.headers[h]) + + if len(request.query_string): + scrubbed_params = strip_secrets(request.query_string, agent.secrets_matcher, agent.secrets_list) + scope.span.set_tag("http.params", scrubbed_params) + + response = None + try: + response = self.handler(request) + + tracer.inject(scope.span.context, ot.Format.HTTP_HEADERS, response.headers) + response.headers['Server-Timing'] = "intid;desc=%s" % scope.span.context.trace_id + except HTTPException as e: + response = e + raise + except BaseException as e: + scope.span.set_tag("http.status", 500) + + # we need to explicitly populate the `message` tag with an error here + # so that it's picked up from an SDK span + scope.span.set_tag("message", str(e)) + scope.span.log_exception(e) + + logger.debug("Pyramid Instana tween", exc_info=True) + finally: + if response: + scope.span.set_tag("http.status", response.status_int) + + if 500 <= response.status_int <= 511: + if response.exception is not None: + message = str(response.exception) + scope.span.log_exception(response.exception) + else: + message = response.status + + scope.span.set_tag("message", message) + scope.span.assure_errored() + + scope.close() + + return response + +def includeme(config): + logger.debug("Instrumenting pyramid") + config.add_tween(__name__ + '.InstanaTweenFactory') diff --git a/instana/options.py b/instana/options.py index 97a4a35e..b9be8dba 100644 --- a/instana/options.py +++ b/instana/options.py @@ -2,6 +2,7 @@ import logging import os +from .util import determine_service_name class StandardOptions(object): """ Configurable option bits for this package """ @@ -20,7 +21,7 @@ def __init__(self, **kwds): self.log_level = logging.DEBUG self.debug = True - self.service_name = os.environ.get("INSTANA_SERVICE_NAME", None) + self.service_name = determine_service_name() self.agent_host = os.environ.get("INSTANA_AGENT_HOST", self.AGENT_DEFAULT_HOST) self.agent_port = os.environ.get("INSTANA_AGENT_PORT", self.AGENT_DEFAULT_PORT) diff --git a/setup.py b/setup.py index 1d9d8035..5c8ea69f 100644 --- a/setup.py +++ b/setup.py @@ -71,6 +71,7 @@ def check_setuptools(): 'gevent>=1.4.0' 'mock>=2.0.0', 'nose>=1.0', + 'pyramid>=1.2', 'urllib3[secure]>=1.15' ], 'test-cassandra': [ @@ -96,6 +97,7 @@ def check_setuptools(): 'pytest>=3.0.1', 'psycopg2>=2.7.1', 'pymongo>=3.7.0', + 'pyramid>=1.2', 'redis>3.0.0', 'requests>=2.17.1', 'sqlalchemy>=1.1.15', @@ -112,6 +114,7 @@ def check_setuptools(): 'Development Status :: 5 - Production/Stable', 'Framework :: Django', 'Framework :: Flask', + 'Framework :: Pyramid', 'Intended Audience :: Developers', 'Intended Audience :: Information Technology', 'Intended Audience :: Science/Research', diff --git a/tests/__init__.py b/tests/__init__.py index c900dd92..77b6baf1 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -13,16 +13,22 @@ if 'CASSANDRA_TEST' not in os.environ: from .apps.flaskalino import flask_server + from .apps.app_pyramid import pyramid_server - # Background Flask application - # - # Spawn our background Flask app that the tests will throw + # Background applications + servers = { + 'Flask': flask_server, + 'Pyramid': pyramid_server, + } + + # Spawn background apps that the tests will throw # requests at. - flask = threading.Thread(target=flask_server.serve_forever) - flask.daemon = True - flask.name = "Background Flask app" - print("Starting background Flask app...") - flask.start() + for (name, server) in servers.items(): + p = threading.Thread(target=server.serve_forever) + p.daemon = True + p.name = "Background %s app" % name + print("Starting background %s app..." % name) + p.start() if 'GEVENT_TEST' not in os.environ and 'CASSANDRA_TEST' not in os.environ: diff --git a/tests/apps/app_pyramid.py b/tests/apps/app_pyramid.py new file mode 100644 index 00000000..a0f2b9de --- /dev/null +++ b/tests/apps/app_pyramid.py @@ -0,0 +1,37 @@ +from wsgiref.simple_server import make_server +from pyramid.config import Configurator +import logging + +from pyramid.response import Response +import pyramid.httpexceptions as exc + +from ..helpers import testenv + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +testenv["pyramid_port"] = 10815 +testenv["pyramid_server"] = ("http://127.0.0.1:" + str(testenv["pyramid_port"])) + +def hello_world(request): + return Response('Ok') + +def please_fail(request): + raise exc.HTTPInternalServerError("internal error") + +def tableflip(request): + raise BaseException("fake exception") + +app = None +with Configurator() as config: + config.add_tween('instana.instrumentation.pyramid.tweens.InstanaTweenFactory') + config.add_route('hello', '/') + config.add_view(hello_world, route_name='hello') + config.add_route('fail', '/500') + config.add_view(please_fail, route_name='fail') + config.add_route('crash', '/exception') + config.add_view(tableflip, route_name='crash') + app = config.make_wsgi_app() + +pyramid_server = make_server('127.0.0.1', testenv["pyramid_port"], app) + diff --git a/tests/test_pyramid.py b/tests/test_pyramid.py new file mode 100644 index 00000000..38c324cb --- /dev/null +++ b/tests/test_pyramid.py @@ -0,0 +1,216 @@ +from __future__ import absolute_import + +import sys +import unittest +import urllib3 + +from instana.singletons import tracer +from .helpers import testenv + +class TestPyramid(unittest.TestCase): + def setUp(self): + """ Clear all spans before a test run """ + self.http = urllib3.PoolManager() + self.recorder = tracer.recorder + self.recorder.clear_spans() + + def tearDown(self): + """ Do nothing for now """ + return None + + def test_vanilla_requests(self): + r = self.http.request('GET', testenv["pyramid_server"] + '/') + self.assertEqual(r.status, 200) + + spans = self.recorder.queued_spans() + self.assertEqual(1, len(spans)) + + def test_get_request(self): + with tracer.start_active_span('test'): + response = self.http.request('GET', testenv["pyramid_server"] + '/') + + spans = self.recorder.queued_spans() + self.assertEqual(3, len(spans)) + + pyramid_span = spans[0] + urllib3_span = spans[1] + test_span = spans[2] + + assert response + self.assertEqual(200, response.status) + + assert('X-Instana-T' in response.headers) + assert(int(response.headers['X-Instana-T'], 16)) + self.assertEqual(response.headers['X-Instana-T'], pyramid_span.t) + + assert('X-Instana-S' in response.headers) + assert(int(response.headers['X-Instana-S'], 16)) + self.assertEqual(response.headers['X-Instana-S'], pyramid_span.s) + + assert('X-Instana-L' in response.headers) + self.assertEqual(response.headers['X-Instana-L'], '1') + + assert('Server-Timing' in response.headers) + server_timing_value = "intid;desc=%s" % pyramid_span.t + self.assertEqual(response.headers['Server-Timing'], server_timing_value) + + self.assertIsNone(tracer.active_span) + + # Same traceId + self.assertEqual(test_span.t, urllib3_span.t) + self.assertEqual(urllib3_span.t, pyramid_span.t) + + # Parent relationships + self.assertEqual(urllib3_span.p, test_span.s) + self.assertEqual(pyramid_span.p, urllib3_span.s) + + # Error logging + self.assertIsNone(test_span.ec) + self.assertIsNone(urllib3_span.ec) + self.assertIsNone(pyramid_span.ec) + + # HTTP SDK span + self.assertEqual("sdk", pyramid_span.n) + + assert(pyramid_span.data["sdk"]) + self.assertEqual('http', pyramid_span.data["sdk"]["name"]) + self.assertEqual('entry', pyramid_span.data["sdk"]["type"]) + + sdk_data = pyramid_span.data["sdk"]["custom"] + self.assertEqual('127.0.0.1:' + str(testenv['pyramid_port']), sdk_data["tags"]["http.host"]) + self.assertEqual('/', sdk_data["tags"]["http.url"]) + self.assertEqual('GET', sdk_data["tags"]["http.method"]) + self.assertEqual(200, sdk_data["tags"]["http.status"]) + self.assertNotIn("message", sdk_data["tags"]) + self.assertNotIn("http.path_tpl", sdk_data["tags"]) + + # urllib3 + self.assertEqual("test", test_span.data["sdk"]["name"]) + self.assertEqual("urllib3", urllib3_span.n) + self.assertEqual(200, urllib3_span.data["http"]["status"]) + self.assertEqual(testenv["pyramid_server"] + '/', urllib3_span.data["http"]["url"]) + self.assertEqual("GET", urllib3_span.data["http"]["method"]) + self.assertIsNotNone(urllib3_span.stack) + self.assertTrue(type(urllib3_span.stack) is list) + self.assertTrue(len(urllib3_span.stack) > 1) + + def test_500(self): + with tracer.start_active_span('test'): + response = self.http.request('GET', testenv["pyramid_server"] + '/500') + + spans = self.recorder.queued_spans() + + self.assertEqual(3, len(spans)) + + pyramid_span = spans[0] + urllib3_span = spans[1] + test_span = spans[2] + + assert response + self.assertEqual(500, response.status) + + assert('X-Instana-T' in response.headers) + assert(int(response.headers['X-Instana-T'], 16)) + self.assertEqual(response.headers['X-Instana-T'], pyramid_span.t) + + assert('X-Instana-S' in response.headers) + assert(int(response.headers['X-Instana-S'], 16)) + self.assertEqual(response.headers['X-Instana-S'], pyramid_span.s) + + assert('X-Instana-L' in response.headers) + self.assertEqual(response.headers['X-Instana-L'], '1') + + assert('Server-Timing' in response.headers) + server_timing_value = "intid;desc=%s" % pyramid_span.t + self.assertEqual(response.headers['Server-Timing'], server_timing_value) + + self.assertIsNone(tracer.active_span) + + # Same traceId + self.assertEqual(test_span.t, urllib3_span.t) + self.assertEqual(test_span.t, pyramid_span.t) + + # Parent relationships + self.assertEqual(urllib3_span.p, test_span.s) + self.assertEqual(pyramid_span.p, urllib3_span.s) + + # Error logging + self.assertIsNone(test_span.ec) + self.assertEqual(1, urllib3_span.ec) + self.assertEqual(1, pyramid_span.ec) + + # wsgi + self.assertEqual("sdk", pyramid_span.n) + self.assertEqual('http', pyramid_span.data["sdk"]["name"]) + self.assertEqual('entry', pyramid_span.data["sdk"]["type"]) + + sdk_data = pyramid_span.data["sdk"]["custom"] + self.assertEqual('127.0.0.1:' + str(testenv['pyramid_port']), sdk_data["tags"]["http.host"]) + self.assertEqual('/500', sdk_data["tags"]["http.url"]) + self.assertEqual('GET', sdk_data["tags"]["http.method"]) + self.assertEqual(500, sdk_data["tags"]["http.status"]) + self.assertEqual("internal error", sdk_data["tags"]["message"]) + self.assertNotIn("http.path_tpl", sdk_data["tags"]) + + # urllib3 + self.assertEqual("test", test_span.data["sdk"]["name"]) + self.assertEqual("urllib3", urllib3_span.n) + self.assertEqual(500, urllib3_span.data["http"]["status"]) + self.assertEqual(testenv["pyramid_server"] + '/500', urllib3_span.data["http"]["url"]) + self.assertEqual("GET", urllib3_span.data["http"]["method"]) + self.assertIsNotNone(urllib3_span.stack) + self.assertTrue(type(urllib3_span.stack) is list) + self.assertTrue(len(urllib3_span.stack) > 1) + + def test_exception(self): + with tracer.start_active_span('test'): + response = self.http.request('GET', testenv["pyramid_server"] + '/exception') + + spans = self.recorder.queued_spans() + + self.assertEqual(3, len(spans)) + + pyramid_span = spans[0] + urllib3_span = spans[1] + test_span = spans[2] + + assert response + self.assertEqual(500, response.status) + + self.assertIsNone(tracer.active_span) + + # Same traceId + self.assertEqual(test_span.t, urllib3_span.t) + self.assertEqual(test_span.t, pyramid_span.t) + + # Parent relationships + self.assertEqual(urllib3_span.p, test_span.s) + self.assertEqual(pyramid_span.p, urllib3_span.s) + + # Error logging + self.assertIsNone(test_span.ec) + self.assertEqual(1, urllib3_span.ec) + self.assertEqual(1, pyramid_span.ec) + + # HTTP SDK span + self.assertEqual("sdk", pyramid_span.n) + self.assertEqual('http', pyramid_span.data["sdk"]["name"]) + self.assertEqual('entry', pyramid_span.data["sdk"]["type"]) + + sdk_data = pyramid_span.data["sdk"]["custom"] + self.assertEqual('127.0.0.1:' + str(testenv['pyramid_port']), sdk_data["tags"]["http.host"]) + self.assertEqual('/exception', sdk_data["tags"]["http.url"]) + self.assertEqual('GET', sdk_data["tags"]["http.method"]) + self.assertEqual(500, sdk_data["tags"]["http.status"]) + self.assertEqual("fake exception", sdk_data["tags"]["message"]) + self.assertNotIn("http.path_tpl", sdk_data["tags"]) + + # urllib3 + self.assertEqual("test", test_span.data["sdk"]["name"]) + self.assertEqual("urllib3", urllib3_span.n) + self.assertEqual(500, urllib3_span.data["http"]["status"]) + self.assertEqual(testenv["pyramid_server"] + '/exception', urllib3_span.data["http"]["url"]) + self.assertEqual("GET", urllib3_span.data["http"]["method"]) + self.assertIsNotNone(urllib3_span.stack) + self.assertTrue(type(urllib3_span.stack) is list) + self.assertTrue(len(urllib3_span.stack) > 1)