Repository: scoutapp/scout_apm_python

Issue:
Add Falcon Instrumentation middleware
We have a Falcon example in the docs ( https://docs.scoutapm.com/#falcon-example ) but it would be good to have an official middleware ( https://falcon.readthedocs.io/en/stable/api/middleware.html ) for auto-instrumentation.


Discussion:

Resolving patch:
diff --git a/src/scout_apm/falcon.py b/src/scout_apm/falcon.py
new file mode 100644
--- /dev/null
+++ b/src/scout_apm/falcon.py
@@ -0,0 +1,95 @@
+# coding=utf-8
+from __future__ import absolute_import, division, print_function, unicode_literals
+
+import logging
+
+import falcon
+
+from scout_apm.api import install
+from scout_apm.core.ignore import ignore_path
+from scout_apm.core.tracked_request import TrackedRequest
+
+logger = logging.getLogger(__name__)
+
+# Falcon Middleware docs:
+# https://falcon.readthedocs.io/en/stable/api/middleware.html
+
+
+class ScoutMiddleware(object):
+    """
+    Falcon Middleware for integration with Scout APM.
+    """
+
+    def __init__(self, config):
+        install(config=config)
+        self.api = None
+
+    def set_api(self, api):
+        if not isinstance(api, falcon.API):
+            raise ValueError("api should be an instance of falcon.API")
+        self.api = api
+
+    def process_request(self, req, resp):
+        tracked_request = TrackedRequest.instance()
+        tracked_request.mark_real_request()
+        req.context.scout_tracked_request = tracked_request
+
+        tracked_request.tag("path", req.path)
+        if ignore_path(req.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 = (
+            req.get_header("x-forwarded-for", default="").split(",")[0]
+            or req.get_header("client-ip", default="").split(",")[0]
+            or req.remote_addr
+        )
+        tracked_request.tag("user_ip", user_ip)
+
+    def process_resource(self, req, resp, resource, params):
+        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
+            # combination with a pretty adversarial application, so guard
+            # against it, although if a request was started and the context was
+            # lost, other problems might occur.
+            return
+
+        if self.api is None:
+            logger.warning(
+                (
+                    "{}.set_api() should be called before requests begin for "
+                    "more detail"
+                ).format(self.__class__.__name__)
+            )
+            operation = "Controller/{}.{}.{}".format(
+                resource.__module__, resource.__class__.__name__, req.method
+            )
+        else:
+            # Find the current responder's name. Falcon passes middleware the
+            # 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)
+            operation = "Controller/{}.{}.{}".format(
+                resource.__module__, resource.__class__.__name__, responder.__name__
+            )
+
+        span = tracked_request.start_span(operation=operation)
+        req.context.scout_resource_span = span
+
+    def process_response(self, req, resp, resource, req_succeeded):
+        tracked_request = getattr(req.context, "scout_tracked_request", None)
+        if tracked_request is None:
+            # Somehow we didn't start a request
+            return
+
+        if not req_succeeded:
+            tracked_request.tag("error", "true")
+
+        span = getattr(req.context, "scout_resource_span", None)
+        if span is not None:
+            tracked_request.stop_span()
+        else:
+            tracked_request.finish()


Test patch:
diff --git a/tests/integration/test_falcon.py b/tests/integration/test_falcon.py
new file mode 100644
--- /dev/null
+++ b/tests/integration/test_falcon.py
@@ -0,0 +1,288 @@
+# coding=utf-8
+from __future__ import absolute_import, division, print_function, unicode_literals
+
+import logging
+import sys
+from contextlib import contextmanager
+
+import falcon
+import pytest
+from webtest import TestApp
+
+from scout_apm.api import Config
+from scout_apm.falcon import ScoutMiddleware
+
+
+@contextmanager
+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 = {"monitor": True}
+
+    # Disable running the agent.
+    config["core_agent_launch"] = False
+
+    # Basic Falcon app
+    if middleware is None:
+        middleware = ["scout"]
+    scout_index = middleware.index("scout")
+    assert scout_index != -1
+    scout_middleware = ScoutMiddleware(config=config)
+    middleware[scout_index] = scout_middleware
+
+    app = falcon.API(middleware=middleware)
+    if set_api:
+        scout_middleware.set_api(app)
+
+    class HomeResource(object):
+        def on_get(self, req, resp):
+            resp.status = falcon.HTTP_200
+            resp.content_type = falcon.MEDIA_TEXT
+            resp.body = "Welcome home."
+
+        def on_get_suffixed(self, req, resp):
+            self.on_get(req, resp)
+            resp.body = "Welcome home, suffixed."
+
+    app.add_route("/", HomeResource())
+    app.add_route("/suffixed", HomeResource(), suffix="suffixed")
+
+    class CrashResource(object):
+        def on_get(self, req, resp):
+            raise falcon.HTTPStatus("748 Confounded by ponies")
+
+    app.add_route("/crash", CrashResource())
+
+    try:
+        yield app
+    finally:
+        Config.reset_all()
+
+
+def test_set_api_wrong_type():
+    scout_middleware = ScoutMiddleware(config={})
+
+    with pytest.raises(ValueError) as excinfo:
+        scout_middleware.set_api(None)
+
+    assert str(excinfo.value) == "api should be an instance of falcon.API"
+
+
+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/tests.integration.test_falcon.HomeResource.on_get"
+    )
+
+
+def test_home_without_set_api(caplog, tracked_requests):
+    with app_with_scout(set_api=False) 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/tests.integration.test_falcon.HomeResource.GET"
+    falcon_log_tuples = [x for x in caplog.record_tuples if x[0] == "scout_apm.falcon"]
+    assert falcon_log_tuples == [
+        (
+            "scout_apm.falcon",
+            logging.WARNING,
+            (
+                "ScoutMiddleware.set_api() should be called before requests "
+                "begin for more detail"
+            ),
+        )
+    ]
+
+
+@pytest.mark.parametrize(
+    "headers, extra_environ, expected",
+    [
+        ({}, {}, None),
+        ({}, {"REMOTE_ADDR": "1.1.1.1"}, "1.1.1.1"),
+        ({"x-forwarded-for": "1.1.1.1"}, {}, "1.1.1.1"),
+        ({"x-forwarded-for": "1.1.1.1,2.2.2.2"}, {}, "1.1.1.1"),
+        ({"x-forwarded-for": "1.1.1.1"}, {"REMOTE_ADDR": "2.2.2.2"}, "1.1.1.1"),
+        (
+            {"x-forwarded-for": "1.1.1.1", "client-ip": "2.2.2.2"},
+            {"REMOTE_ADDR": "3.3.3.3"},
+            "1.1.1.1",
+        ),
+        ({"client-ip": "1.1.1.1"}, {}, "1.1.1.1"),
+        ({"client-ip": "1.1.1.1,2.2.2.2"}, {}, "1.1.1.1"),
+        ({"client-ip": "1.1.1.1"}, {"REMOTE_ADDR": "2.2.2.2"}, "1.1.1.1"),
+        ({"client-ip": "1.1.1.1"}, {"REMOTE_ADDR": "2.2.2.2"}, "1.1.1.1"),
+    ],
+)
+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
+
+
+def test_home_suffixed(tracked_requests):
+    with app_with_scout() as app:
+        response = TestApp(app).get("/suffixed")
+
+    assert response.status_int == 200
+    assert response.text == "Welcome home, suffixed."
+    assert len(tracked_requests) == 1
+    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 (
+        span.operation
+        == "Controller/tests.integration.test_falcon.HomeResource.on_get_suffixed"
+    )
+
+
+def test_home_ignored(tracked_requests):
+    with app_with_scout({"monitor": True, "ignore": ["/"]}) as app:
+        response = TestApp(app).get("/")
+
+    assert response.status_int == 200
+    assert response.text == "Welcome home."
+    assert tracked_requests == []
+
+
+def test_middleware_returning_early_from_process_request(tracked_requests):
+    class ShortcutMiddleware(object):
+        def process_request(self, req, resp):
+            resp.status = falcon.HTTP_200
+            resp.body = "Shortcut!"
+            resp.complete = True
+
+        def process_resource(self, req, resp, resource, params):
+            pass
+
+        def process_response(self, req, resp, resource, req_succeeded):
+            pass
+
+    with app_with_scout(middleware=[ShortcutMiddleware(), "scout"]) as app:
+        response = TestApp(app).get("/")
+
+    assert response.status_int == 200
+    assert response.text == "Shortcut!"
+    assert tracked_requests == []
+
+
+def test_middleware_deleting_scout_tracked_request(tracked_requests):
+    class AdversarialMiddleware(object):
+        def process_request(self, req, resp):
+            pass
+
+        def process_resource(self, req, resp, resource, params):
+            req.context.saved_scout_tracked_request = req.context.scout_tracked_request
+            del req.context.scout_tracked_request
+
+        def process_response(self, req, resp, resource, req_succeeded):
+            pass
+
+    class AdversarialUndoMiddleware(object):
+        def process_request(self, req, resp):
+            pass
+
+        def process_resource(self, req, resp, resource, params):
+            req.context.scout_tracked_request = req.context.saved_scout_tracked_request
+            del req.context.saved_scout_tracked_request
+
+        def process_response(self, req, resp, resource, req_succeeded):
+            pass
+
+    with app_with_scout(
+        middleware=[AdversarialMiddleware(), "scout", AdversarialUndoMiddleware()]
+    ) 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 tracked_request.complete_spans == []
+
+
+def test_middleware_returning_early_from_process_resource(tracked_requests):
+    class ShortcutMiddleware(object):
+        def process_request(self, req, resp):
+            pass
+
+        def process_resource(self, req, resp, resource, params):
+            resp.status = falcon.HTTP_200
+            resp.body = "Shortcut!"
+            resp.complete = True
+
+        def process_response(self, req, resp, resource, req_succeeded):
+            pass
+
+    with app_with_scout(middleware=[ShortcutMiddleware(), "scout"]) as app:
+        response = TestApp(app).get("/")
+
+    assert response.status_int == 200
+    assert response.text == "Shortcut!"
+    assert len(tracked_requests) == 1
+    tracked_request = tracked_requests[0]
+    assert tracked_request.tags["path"] == "/"
+    assert tracked_request.active_spans == []
+    assert tracked_request.complete_spans == []
+
+
+def test_not_found(tracked_requests):
+    with app_with_scout() as app:
+        response = TestApp(app).get("/not-found", expect_errors=True)
+
+    assert response.status_int == 404
+    assert len(tracked_requests) == 1
+    tracked_request = tracked_requests[0]
+    assert tracked_request.tags["path"] == "/not-found"
+    assert tracked_request.tags["error"] == "true"
+    assert tracked_request.active_spans == []
+    assert tracked_request.complete_spans == []
+
+
+def test_crash(tracked_requests):
+    with app_with_scout() as app:
+        response = TestApp(app).get("/crash", expect_errors=True)
+
+    assert response.status_int == 748
+    assert response.text == ""
+    assert len(tracked_requests) == 1
+    tracked_request = tracked_requests[0]
+    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 (
+        span.operation
+        == "Controller/tests.integration.test_falcon.CrashResource.on_get"
+    )