Repository: scoutapp/scout_apm_python

Issue:
Add Nameko instrumentation
Instrument [Nameko](https://github.com/nameko/nameko), a Python framework for building microservices.


Discussion:
I'd sign up immediately if you had this :)
Me too.
Me too. 
@itsderek23 I see the Python agent is now GA, with support for several apps. Any thoughts on Nameko now?
Hi @geoffjukes - we're catching up on a couple of things so we haven't started this yet. 

That said, the latest `scout-apm` Python package adds [custom transaction instrumentation](http://help.apm.scoutapp.com/#python-custom-instrumentation). There are examples for both [Falcon](http://help.apm.scoutapp.com/#falcon-example) and [Cron Jobs](http://help.apm.scoutapp.com/#cron-job-example). This might be a good starting point until official support is ready.

Resolving patch:
diff --git a/src/scout_apm/nameko.py b/src/scout_apm/nameko.py
new file mode 100644
--- /dev/null
+++ b/src/scout_apm/nameko.py
@@ -0,0 +1,68 @@
+# coding=utf-8
+from __future__ import absolute_import, division, print_function, unicode_literals
+
+from nameko.extensions import DependencyProvider
+from nameko.web.handlers import HttpRequestHandler
+from werkzeug.wrappers import Request
+
+import scout_apm.core
+from scout_apm.core.ignore import ignore_path
+from scout_apm.core.queue_time import track_request_queue_time
+from scout_apm.core.tracked_request import TrackedRequest
+
+
+class ScoutReporter(DependencyProvider):
+    def setup(self):
+        installed = scout_apm.core.install()
+        self._do_nothing = not installed
+
+    def worker_setup(self, worker_ctx):
+        if self._do_nothing:
+            return
+        tracked_request = TrackedRequest.instance()
+        tracked_request.mark_real_request()
+
+        # Get HTTP details for HTTP handlers
+        if isinstance(worker_ctx.entrypoint, HttpRequestHandler):
+            try:
+                request = worker_ctx.args[0]
+            except IndexError:
+                pass
+            else:
+                if isinstance(request, Request):
+                    path = request.path
+                    tracked_request.tag("path", path)
+
+                    if ignore_path(path):
+                        tracked_request.tag("ignore_transaction", True)
+
+                    # Determine a remote IP to associate with the request. The value is
+                    # spoofable by the requester so this is not suitable to use in any
+                    # security sensitive context.
+                    user_ip = (
+                        request.headers.get("x-forwarded-for", default="").split(",")[0]
+                        or request.headers.get("client-ip", default="").split(",")[0]
+                        or request.remote_addr
+                    )
+                    tracked_request.tag("user_ip", user_ip)
+
+                    queue_time = request.headers.get(
+                        "x-queue-start", default=""
+                    ) or request.headers.get("x-request-start", default="")
+                    track_request_queue_time(queue_time, tracked_request)
+
+        operation = (
+            "Controller/"
+            + worker_ctx.service.name
+            + "."
+            + worker_ctx.entrypoint.method_name
+        )
+        tracked_request.start_span(operation=operation)
+
+    def worker_result(self, worker_ctx, result=None, exc_info=None):
+        if self._do_nothing:
+            return
+        tracked_request = TrackedRequest.instance()
+        if exc_info:
+            tracked_request.tag("error", "true")
+        tracked_request.stop_span()


Test patch:
diff --git a/tests/integration/test_nameko.py b/tests/integration/test_nameko.py
new file mode 100644
--- /dev/null
+++ b/tests/integration/test_nameko.py
@@ -0,0 +1,141 @@
+# coding=utf-8
+from __future__ import absolute_import, division, print_function, unicode_literals
+
+import datetime as dt
+import sys
+from contextlib import contextmanager
+
+from nameko.containers import get_container_cls
+from nameko.web.handlers import http
+from webtest import TestApp
+
+from scout_apm.api import Config
+from scout_apm.compat import datetime_to_timestamp
+from scout_apm.nameko import ScoutReporter
+from tests.integration.util import (
+    parametrize_queue_time_header_name,
+    parametrize_user_ip_headers,
+)
+
+
+@contextmanager
+def app_with_scout(nameko_config=None, scout_config=None):
+    """
+    Context manager that yields a fresh Nameko WSGI app with Scout configured.
+    """
+    if scout_config is None:
+        scout_config = {"monitor": True}
+    scout_config["core_agent_launch"] = False
+    Config.set(**scout_config)
+
+    # Nameko setup
+    class Service(object):
+        name = "myservice"
+
+        scout = ScoutReporter()
+
+        @http("GET", "/")
+        def home(self, request):
+            return "Welcome home."
+
+        @http("GET", "/crash/")
+        def crash(self, request):
+            raise ValueError("BØØM!")  # non-ASCII
+
+    if nameko_config is None:
+        nameko_config = {}
+    # Container setup copied from Nameko's container_factory pytest fixture,
+    # which we don't use - see pytest.ini
+    container_cls = get_container_cls(nameko_config)
+    container = container_cls(Service, nameko_config)
+    try:
+        container.start()
+
+        # A bit of introspection to look inside the container and pull out the WSGI
+        # app
+        app = list(container.subextensions)[0].get_wsgi_app()
+
+        # N.B. We're sidestepping the Nameko testing conventions
+        # (https://docs.nameko.io/en/stable/testing.html) to make our tests more
+        # uniform between frameworks
+
+        yield app
+    finally:
+        container.kill()
+        Config.reset_all()
+
+
+def test_home(tracked_requests):
+    with app_with_scout() as app:
+        response = TestApp(app).get("/")
+
+    assert response.status_int == 200
+    assert response.text == "Welcome home."
+    assert len(tracked_requests) == 1
+    tracked_request = tracked_requests[0]
+    assert tracked_request.tags["path"] == "/"
+    assert tracked_request.active_spans == []
+    assert len(tracked_request.complete_spans) == 1
+    span = tracked_request.complete_spans[0]
+    assert span.operation == "Controller/myservice.home"
+
+
+def test_home_ignored(tracked_requests):
+    with app_with_scout(scout_config={"monitor": True, "ignore": ["/"]}) as app:
+        response = TestApp(app).get("/")
+
+    assert response.status_int == 200
+    assert response.text == "Welcome home."
+    assert tracked_requests == []
+
+
+@parametrize_user_ip_headers
+def test_user_ip(headers, extra_environ, expected, tracked_requests):
+    if sys.version_info[0] == 2:
+        # Required for WebTest lint
+        headers = {str(k): str(v) for k, v in headers.items()}
+        extra_environ = {str(k): str(v) for k, v in extra_environ.items()}
+
+    with app_with_scout() as app:
+        TestApp(app).get("/", headers=headers, extra_environ=extra_environ)
+
+    tracked_request = tracked_requests[0]
+    assert tracked_request.tags["user_ip"] == expected
+
+
+@parametrize_queue_time_header_name
+def test_queue_time(header_name, tracked_requests):
+    # Not testing floats due to Python 2/3 rounding differences
+    queue_start = int(datetime_to_timestamp(dt.datetime.utcnow()) - 2)
+    with app_with_scout() as app:
+        response = TestApp(app).get(
+            "/", headers={header_name: str("t=") + str(queue_start)}
+        )
+
+    assert response.status_int == 200
+    assert len(tracked_requests) == 1
+    queue_time_ns = tracked_requests[0].tags["scout.queue_time_ns"]
+    # Upper bound assumes we didn't take more than 2s to run this test...
+    assert queue_time_ns >= 2000000000 and queue_time_ns < 4000000000
+
+
+def test_server_error(tracked_requests):
+    with app_with_scout() as app:
+        response = TestApp(app).get("/crash/", expect_errors=True)
+
+    assert response.status_int == 500
+    assert len(tracked_requests) == 1
+    tracked_request = tracked_requests[0]
+    assert tracked_request.tags["error"] == "true"
+    assert tracked_request.tags["path"] == "/crash/"
+    assert len(tracked_request.complete_spans) == 1
+    span = tracked_request.complete_spans[0]
+    assert span.operation == "Controller/myservice.crash"
+
+
+def test_no_monitor(tracked_requests):
+    with app_with_scout(scout_config={"monitor": False}) as app:
+        response = TestApp(app).get("/")
+
+    assert response.status_int == 200
+    assert tracked_requests == []