Repository: scoutapp/scout_apm_python (python)

Issue:
Instrument hug
Hug was reported used by 1% of developers in the [Python developers survey 2018](https://www.jetbrains.com/research/python-developers-survey-2018/). We should support it.

Pull request description:
Add Hug integration
Fixes #336.

Hug uses Falcon under the hood, so this builds on top of it to provide a Hug integration with a separate interface.

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c6ff089..066c00e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,8 +4,12 @@
 
 ### Added
 
-- Improved Falcon integration: tracing is automatically disabled if
-  installation fails, and callable class-based responders are supported.
+- Improve Falcon integration: tracing is automatically disabled if
+  installation fails, track middleware, and support class-based responders
+  ([PR #453](https://github.com/scoutapp/scout_apm_python/pull/453),
+  [PR #460](https://github.com/scoutapp/scout_apm_python/pull/460)).
+- Add Hug integration
+  ([PR #460](https://github.com/scoutapp/scout_apm_python/pull/460)).
 
 ### Fixed
 
diff --git a/README.md b/README.md
index b115ec1..9b3af42 100644
--- a/README.md
+++ b/README.md
@@ -23,6 +23,7 @@ Scout APM has integrations for the following frameworks:
 * Falcon 2.0+
 * Flask 0.10+
 * Huey 2.0+
+* Hug 2.5.1+
 * Nameko 2.0+
 * Pyramid 1.8+
 * RQ 1.0+
diff --git a/src/scout_apm/falcon.py b/src/scout_apm/falcon.py
index 30ff58a..a1b09a6 100644
--- a/src/scout_apm/falcon.py
+++ b/src/scout_apm/falcon.py
@@ -14,6 +14,11 @@ from scout_apm.core.web_requests import (
     track_request_queue_time,
 )
 
+try:
+    from hug.interface import HTTP as HugHTTP
+except ImportError:
+    HugHTTP = None
+
 logger = logging.getLogger(__name__)
 
 # Falcon Middleware docs:
@@ -25,8 +30,9 @@ class ScoutMiddleware(object):
     Falcon Middleware for integration with Scout APM.
     """
 
-    def __init__(self, config):
+    def __init__(self, config, hug_http_interface=None):
         self.api = None
+        self.hug_http_interface = hug_http_interface
         installed = install(config=config)
         self._do_nothing = not installed
 
@@ -38,9 +44,14 @@ class ScoutMiddleware(object):
     def process_request(self, req, resp):
         if self._do_nothing:
             return
+        if self.api is None and self.hug_http_interface is not None:
+            self.api = self.hug_http_interface.falcon
         tracked_request = TrackedRequest.instance()
         tracked_request.is_real_request = True
         req.context.scout_tracked_request = tracked_request
+        tracked_request.start_span(
+            operation="Middleware", should_capture_backtrace=False
+        )
 
         path = req.path
         # Falcon URL parameter values are *either* single items or lists
@@ -98,19 +109,22 @@ class ScoutMiddleware(object):
             # current resource but unfortunately not the method being called, hence
             # we have to go through routing again.
             responder, _params, _resource, _uri_template = self.api._get_responder(req)
-            try:
-                last_part = responder.__name__
-            except AttributeError:
-                last_part = req.method
-            operation = "Controller/{}.{}.{}".format(
-                resource.__module__, resource.__class__.__name__, last_part
-            )
+            operation = self._name_operation(req, responder, resource)
 
         span = tracked_request.start_span(
             operation=operation, should_capture_backtrace=False
         )
         req.context.scout_resource_span = span
 
+    def _name_operation(self, req, responder, resource):
+        try:
+            last_part = responder.__name__
+        except AttributeError:
+            last_part = req.method
+        return "Controller/{}.{}.{}".format(
+            resource.__module__, resource.__class__.__name__, last_part
+        )
+
     def process_response(self, req, resp, resource, req_succeeded):
         tracked_request = getattr(req.context, "scout_tracked_request", None)
         if tracked_request is None:
@@ -131,5 +145,5 @@ class ScoutMiddleware(object):
         span = getattr(req.context, "scout_resource_span", None)
         if span is not None:
             tracked_request.stop_span()
-        else:
-            tracked_request.finish()
+        # Stop Middleware span
+        tracked_request.stop_span()
diff --git a/src/scout_apm/hug.py b/src/scout_apm/hug.py
new file mode 100644
index 0000000..d9f6721
--- /dev/null
+++ b/src/scout_apm/hug.py
@@ -0,0 +1,40 @@
+# coding=utf-8
+from __future__ import absolute_import, division, print_function, unicode_literals
+
+import hug
+from hug.interface import HTTP
+
+from scout_apm.falcon import ScoutMiddleware as FalconMiddleware
+
+
+class ScoutMiddleware(FalconMiddleware):
+    """
+    Hug's HTTP interface is based on Falcon. Therefore we use a subclass of our
+    Falcon integration with Hug specific extras.
+    """
+
+    def __init__(self, config, hug_http_interface):
+        super(ScoutMiddleware, self).__init__(config)
+        self.hug_http_interface = hug_http_interface
+
+    def process_request(self, req, resp):
+        if not self._do_nothing and self.api is None:
+            self.api = self.hug_http_interface.falcon
+        return super(ScoutMiddleware, self).process_request(req, resp)
+
+    def _name_operation(self, req, responder, resource):
+        if isinstance(responder, HTTP):
+            # Hug doesn't use functions but its custom callable classes
+            return "Controller/{}.{}".format(
+                responder.interface._function.__module__,
+                responder.interface._function.__name__,
+            )
+        return super(ScoutMiddleware, self)._name_operation(req, responder, resource)
+
+
+def integrate_scout(hug_module_name, config):
+    http_interface = hug.API(hug_module_name).http
+    scout_middleware = ScoutMiddleware(
+        config=config, hug_http_interface=http_interface,
+    )
+    http_interface.add_middleware(scout_middleware)
diff --git a/tox.ini b/tox.ini
index 2937571..3774d5c 100644
--- a/tox.ini
+++ b/tox.ini
@@ -51,6 +51,7 @@ deps =
     flask
     flask-sqlalchemy
     huey
+    hug>=2.5.1 ; python_version >= "3.5"
     httpretty
     jinja2
     nameko<3


Test patch:
diff --git a/tests/integration/test_falcon.py b/tests/integration/test_falcon.py
index e7e8012..ed96105 100644
--- a/tests/integration/test_falcon.py
+++ b/tests/integration/test_falcon.py
@@ -125,11 +125,12 @@ def test_home(tracked_requests):
     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 len(tracked_request.complete_spans) == 2
     assert (
-        span.operation == "Controller/tests.integration.test_falcon.HomeResource.on_get"
+        tracked_request.complete_spans[0].operation
+        == "Controller/tests.integration.test_falcon.HomeResource.on_get"
     )
+    assert tracked_request.complete_spans[1].operation == "Middleware"
 
 
 def test_home_without_set_api(caplog, tracked_requests):
@@ -142,9 +143,12 @@ def test_home_without_set_api(caplog, tracked_requests):
     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/tests.integration.test_falcon.HomeResource.GET"
+    assert len(tracked_request.complete_spans) == 2
+    assert (
+        tracked_request.complete_spans[0].operation
+        == "Controller/tests.integration.test_falcon.HomeResource.GET"
+    )
+    assert tracked_request.complete_spans[1].operation == "Middleware"
     falcon_log_tuples = [x for x in caplog.record_tuples if x[0] == "scout_apm.falcon"]
     assert falcon_log_tuples == [
         (
@@ -171,8 +175,7 @@ def test_user_ip(headers, client_address, expected, tracked_requests):
             ),
         )
 
-    tracked_request = tracked_requests[0]
-    assert tracked_request.tags["user_ip"] == expected
+    assert tracked_requests[0].tags["user_ip"] == expected
 
 
 def test_home_suffixed(tracked_requests):
@@ -185,10 +188,9 @@ def test_home_suffixed(tracked_requests):
     tracked_request = tracked_requests[0]
     assert tracked_request.tags["path"] == "/suffixed"
     assert tracked_request.active_spans == []
-    assert len(tracked_request.complete_spans) == 1
-    span = tracked_request.complete_spans[0]
+    assert len(tracked_request.complete_spans) == 2
     assert (
-        span.operation
+        tracked_request.complete_spans[0].operation
         == "Controller/tests.integration.test_falcon.HomeResource.on_get_suffixed"
     )
 
@@ -298,7 +300,8 @@ def test_middleware_deleting_scout_tracked_request(tracked_requests):
     tracked_request = tracked_requests[0]
     assert tracked_request.tags["path"] == "/"
     assert tracked_request.active_spans == []
-    assert tracked_request.complete_spans == []
+    assert len(tracked_request.complete_spans) == 1
+    assert tracked_request.complete_spans[0].operation == "Middleware"
 
 
 def test_middleware_returning_early_from_process_resource(tracked_requests):
@@ -323,7 +326,8 @@ def test_middleware_returning_early_from_process_resource(tracked_requests):
     tracked_request = tracked_requests[0]
     assert tracked_request.tags["path"] == "/"
     assert tracked_request.active_spans == []
-    assert tracked_request.complete_spans == []
+    assert len(tracked_request.complete_spans) == 1
+    assert tracked_request.complete_spans[0].operation == "Middleware"
 
 
 def test_not_found(tracked_requests):
@@ -336,7 +340,8 @@ def test_not_found(tracked_requests):
     assert tracked_request.tags["path"] == "/not-found"
     assert tracked_request.tags["error"] == "true"
     assert tracked_request.active_spans == []
-    assert tracked_request.complete_spans == []
+    assert len(tracked_request.complete_spans) == 1
+    assert tracked_request.complete_spans[0].operation == "Middleware"
 
 
 def test_crash(tracked_requests):
@@ -351,10 +356,9 @@ def test_crash(tracked_requests):
     assert tracked_request.tags["path"] == "/crash"
     assert tracked_request.tags["error"] == "true"
     assert tracked_request.active_spans == []
-    assert len(tracked_request.complete_spans) == 1
-    span = tracked_request.complete_spans[0]
+    assert len(tracked_request.complete_spans) == 2
     assert (
-        span.operation
+        tracked_request.complete_spans[0].operation
         == "Controller/tests.integration.test_falcon.CrashResource.on_get"
     )
 
@@ -370,10 +374,9 @@ def test_error(tracked_requests):
     assert tracked_request.tags["path"] == "/error"
     assert tracked_request.tags["error"] == "true"
     assert tracked_request.active_spans == []
-    assert len(tracked_request.complete_spans) == 1
-    span = tracked_request.complete_spans[0]
+    assert len(tracked_request.complete_spans) == 2
     assert (
-        span.operation
+        tracked_request.complete_spans[0].operation
         == "Controller/tests.integration.test_falcon.ErrorResource.on_get"
     )
 
@@ -389,10 +392,9 @@ def test_return_error(tracked_requests):
     assert tracked_request.tags["path"] == "/return-error"
     assert tracked_request.tags["error"] == "true"
     assert tracked_request.active_spans == []
-    assert len(tracked_request.complete_spans) == 1
-    span = tracked_request.complete_spans[0]
+    assert len(tracked_request.complete_spans) == 2
     assert (
-        span.operation
+        tracked_request.complete_spans[0].operation
         == "Controller/tests.integration.test_falcon.ReturnErrorResource.on_get"
     )
 
@@ -407,10 +409,9 @@ def test_bad_status(tracked_requests):
     assert tracked_request.tags["path"] == "/bad-status"
     assert tracked_request.tags["error"] == "true"
     assert tracked_request.active_spans == []
-    assert len(tracked_request.complete_spans) == 1
-    span = tracked_request.complete_spans[0]
+    assert len(tracked_request.complete_spans) == 2
     assert (
-        span.operation
+        tracked_request.complete_spans[0].operation
         == "Controller/tests.integration.test_falcon.BadStatusResource.on_get"
     )
 
@@ -425,10 +426,9 @@ def test_responder_class(tracked_requests):
     tracked_request = tracked_requests[0]
     assert tracked_request.tags["path"] == "/responder-class"
     assert tracked_request.active_spans == []
-    assert len(tracked_request.complete_spans) == 1
-    span = tracked_request.complete_spans[0]
+    assert len(tracked_request.complete_spans) == 2
     assert (
-        span.operation
+        tracked_request.complete_spans[0].operation
         == "Controller/tests.integration.test_falcon.ResponderClassResource.GET"
     )
 
diff --git a/tests/integration/test_hug.py b/tests/integration/test_hug.py
new file mode 100644
index 0000000..da31aad
--- /dev/null
+++ b/tests/integration/test_hug.py
@@ -0,0 +1,81 @@
+# coding=utf-8
+"""
+Because the Hug integration is based on the Falcon one, we test only the
+extras here, specifically the transaction naming.
+"""
+from __future__ import absolute_import, division, print_function, unicode_literals
+
+from contextlib import contextmanager
+
+import pytest
+from webtest import TestApp
+
+from scout_apm.api import Config
+from scout_apm.compat import kwargs_only
+
+try:
+    import hug
+except ImportError:
+    hug = None
+else:
+    from scout_apm.hug import integrate_scout
+
+
+if hug is not None:
+    # Hug doesn't (seem to) support individual apps, since they're automatically
+    # module scoped, so use a single module level app:
+
+    @hug.get("/")
+    def home():
+        return "Welcome home."
+
+
+skip_if_hug_unavailable = pytest.mark.skipif(hug is None, reason="Hug isn't available")
+pytestmark = [skip_if_hug_unavailable]
+
+scout_integrated = False
+
+
+@contextmanager
+@kwargs_only
+def app_with_scout(scout_config=None):
+    """
+    Context manager that yields the global Hug app with Scout configured.
+    """
+    global scout_integrated
+
+    if scout_config is None:
+        scout_config = {}
+
+    scout_config["core_agent_launch"] = False
+    scout_config.setdefault("monitor", True)
+    Config.set(**scout_config)
+
+    if not scout_integrated:
+        integrate_scout(__name__, config={})
+        scout_integrated = True
+
+    try:
+        # Hug attaches magic names to the current module when you use the @hug
+        # decorators, we're interested in the WSGI app:
+        yield __hug_wsgi__  # noqa: F821
+    finally:
+        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) == 2
+    assert (
+        tracked_request.complete_spans[0].operation
+        == "Controller/tests.integration.test_hug.home"
+    )
+    assert tracked_request.complete_spans[1].operation == "Middleware"