Repository: scoutapp/scout_apm_python
Pull request #453 (2020-01-21 17:58:22)

Improve Falcon integration
Refs #336. Hug uses Falcon for its HTTP API interface. This PR contains some improvements to the Falcon integration that will be helpful for Hug:

* `set_api()` is no longer required, thanks to some stack inspection.
* Tracing is automatically disabled if installation fails, like other intgerations.
* Callable class-based responders are supported. Hug uses one of these.

adamchainz: Okay so after some debugging - `set_api()` removal doesn't work on CI because there we're using the Cythonized version, for which stack frames don't exist. It seems there's no reliable way to automatically discover the current Falcon API given that stack frames don't exist. This is a [documented Cython limitation](http://docs.cython.org/en/latest/src/userguide/limitations.html). Reverting so that users will still need to call `set_api()`.
adamchainz: The only test failures are Jinja2 async that are also happening on master.

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3899beb..33f217c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,12 @@
 # Changelog
 
+## Pending
+
+### Added
+
+- Improved Falcon integration: tracing is automatically disabled if
+  installation fails, and callable class-based responders are supported.
+
 ## [2.10.0] 2020-01-02
 
 ### Added
diff --git a/src/scout_apm/falcon.py b/src/scout_apm/falcon.py
index fe8c97b..30ff58a 100644
--- a/src/scout_apm/falcon.py
+++ b/src/scout_apm/falcon.py
@@ -26,8 +26,9 @@ class ScoutMiddleware(object):
     """
 
     def __init__(self, config):
-        install(config=config)
         self.api = None
+        installed = install(config=config)
+        self._do_nothing = not installed
 
     def set_api(self, api):
         if not isinstance(api, falcon.API):
@@ -35,6 +36,8 @@ class ScoutMiddleware(object):
         self.api = api
 
     def process_request(self, req, resp):
+        if self._do_nothing:
+            return
         tracked_request = TrackedRequest.instance()
         tracked_request.is_real_request = True
         req.context.scout_tracked_request = tracked_request
@@ -69,6 +72,9 @@ class ScoutMiddleware(object):
             track_amazon_request_queue_time(amazon_queue_time, tracked_request)
 
     def process_resource(self, req, resp, resource, params):
+        if self._do_nothing:
+            return
+
         tracked_request = getattr(req.context, "scout_tracked_request", None)
         if tracked_request is None:
             # Somehow we didn't start a request - this might occur in
@@ -80,8 +86,8 @@ class ScoutMiddleware(object):
         if self.api is None:
             logger.warning(
                 (
-                    "{}.set_api() should be called before requests begin for "
-                    "more detail"
+                    "{}.set_api() should be called before requests begin for"
+                    + " more detail."
                 ).format(self.__class__.__name__)
             )
             operation = "Controller/{}.{}.{}".format(
@@ -92,8 +98,12 @@ 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__, responder.__name__
+                resource.__module__, resource.__class__.__name__, last_part
             )
 
         span = tracked_request.start_span(


Test patch:
diff --git a/tests/integration/test_falcon.py b/tests/integration/test_falcon.py
index 41c2bcd..e7e8012 100644
--- a/tests/integration/test_falcon.py
+++ b/tests/integration/test_falcon.py
@@ -19,13 +19,21 @@ from tests.integration.util import (
 )
 
 
+class FalconAPI(falcon.API):
+    """
+    Custom subclass that dosen't use __slots__, allowing us to attach
+    'scout_middleware' as an attribute for access in tests.
+    """
+
+    pass
+
+
 @contextmanager
 @kwargs_only
 def app_with_scout(config=None, middleware=None, set_api=True):
     """
     Context manager that yields a fresh Falcon app with Scout configured.
     """
-    # Enable Scout by default in tests.
     if config is None:
         config = {}
 
@@ -39,7 +47,7 @@ def app_with_scout(config=None, middleware=None, set_api=True):
     scout_middleware = ScoutMiddleware(config=config)
     middleware[scout_index] = scout_middleware
 
-    app = falcon.API(middleware=middleware)
+    app = FalconAPI(middleware=middleware)
     if set_api:
         scout_middleware.set_api(app)
 
@@ -81,6 +89,17 @@ def app_with_scout(config=None, middleware=None, set_api=True):
 
     app.add_route("/bad-status", BadStatusResource())
 
+    class ResponderClass(object):
+        def __call__(self, req, resp):
+            resp.body = "Responder class"
+
+    class ResponderClassResource(object):
+        on_get = ResponderClass()
+
+    app.add_route("/responder-class", ResponderClassResource())
+
+    app.scout_middleware = scout_middleware
+
     try:
         yield app
     finally:
@@ -132,8 +151,8 @@ def test_home_without_set_api(caplog, tracked_requests):
             "scout_apm.falcon",
             logging.WARNING,
             (
-                "ScoutMiddleware.set_api() should be called before requests "
-                "begin for more detail"
+                "ScoutMiddleware.set_api() should be called before requests"
+                + " begin for more detail."
             ),
         )
     ]
@@ -394,3 +413,29 @@ def test_bad_status(tracked_requests):
         span.operation
         == "Controller/tests.integration.test_falcon.BadStatusResource.on_get"
     )
+
+
+def test_responder_class(tracked_requests):
+    with app_with_scout() as app:
+        response = TestApp(app).get("/responder-class")
+
+    assert response.status_int == 200
+    assert response.text == "Responder class"
+    assert len(tracked_requests) == 1
+    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 (
+        span.operation
+        == "Controller/tests.integration.test_falcon.ResponderClassResource.GET"
+    )
+
+
+def test_no_monitor(tracked_requests):
+    with app_with_scout(config={"monitor": False}) as app:
+        response = TestApp(app).get("/")
+
+    assert response.status_int == 200
+    assert tracked_requests == []