Repository: scoutapp/scout_apm_python
Pull request #616 (2021-02-09 14:57:40)

Test against Elasticsearch 7.10


Patch:
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 4e3aa31..cb36e84 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -25,7 +25,7 @@ jobs:
 
     services:
       elasticsearch:
-        image: elasticsearch:7.8.0
+        image: elasticsearch:7.10.1
         ports:
         - 9200:9200
         env:
@@ -37,7 +37,7 @@ jobs:
         - 27017:27017
 
       redis:
-        image: redis:5
+        image: redis:6
         ports:
         - 6379:6379
 
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 798b99b..edd43c0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,6 +16,8 @@
   ([PR #598](https://github.com/scoutapp/scout_apm_python/pull/598))
 - Fix a socket resource leak when downloading the core agent.
   ([PR #609](https://github.com/scoutapp/scout_apm_python/pull/609))
+- Fix Starlette with ASGI apps that are class instances
+  ([Issue #596](https://github.com/scoutapp/scout_apm_python/issues/596))
 
 ## [2.17.0] 2020-09-17
 
diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md
index e2c0518..37d44fb 100644
--- a/DEVELOPMENT.md
+++ b/DEVELOPMENT.md
@@ -59,13 +59,13 @@ by environment variables. These are:
 
 * `ELASTICSEARCH_URL` - point to a running Elasticsearch instance, e.g.
   "http://localhost:9200/" . You can start it with:
-  `docker run --detach --name elasticsearch --publish 9200:9200 -e "discovery.type=single-node" elasticsearch:7.5.2` .
+  `docker run --detach --name elasticsearch --publish 9200:9200 -e "discovery.type=single-node" elasticsearch:7.10.1` .
 * `MONGODB_URL` - point to a running MongoDB instance e.g.
   "mongodb://localhost:27017/" . You can start it with:
   `docker run --detach --name mongo --publish 27017:27017 mongo:4.0` .
 * `REDIS_URL` - point to a running Redis instance e.g.
   "redis://localhost:6379/0" . You can start it with:
-  `docker run --detach --name redis --publish 6379:6379 redis:5` .
+  `docker run --detach --name redis --publish 6379:6379 redis:6` .
 
 You can `export` any of these environment variables and run the respective
 tests with `tox`.
diff --git a/src/scout_apm/async_/starlette.py b/src/scout_apm/async_/starlette.py
index e4c4292..6a3cdf4 100644
--- a/src/scout_apm/async_/starlette.py
+++ b/src/scout_apm/async_/starlette.py
@@ -31,8 +31,11 @@ class ScoutMiddleware:
             if "endpoint" in scope:
                 # Rename top span
                 endpoint = scope["endpoint"]
+                if not hasattr(endpoint, "__qualname__"):
+                    endpoint = endpoint.__class__
                 controller_span.operation = "Controller/{}.{}".format(
-                    endpoint.__module__, endpoint.__qualname__
+                    endpoint.__module__,
+                    endpoint.__qualname__,
                 )
                 tracked_request.is_real_request = True
 


Test patch:
diff --git a/tests/integration/test_starlette_py36plus.py b/tests/integration/test_starlette_py36plus.py
index 38e7f72..d916bab 100644
--- a/tests/integration/test_starlette_py36plus.py
+++ b/tests/integration/test_starlette_py36plus.py
@@ -72,6 +72,13 @@ def app_with_scout(*, middleware=None, scout_config=None):
 
         return PlainTextResponse("Triggering background jobs", background=tasks)
 
+    class InstanceApp:
+        async def __call__(self, scope, receive, send):
+            resp = PlainTextResponse(
+                "Welcome home from an app that's a class instance."
+            )
+            await resp(scope, receive, send)
+
     routes = [
         Route("/", endpoint=home),
         Route("/sync-home/", endpoint=sync_home),
@@ -80,6 +87,7 @@ def app_with_scout(*, middleware=None, scout_config=None):
         Route("/crash/", endpoint=crash),
         Route("/return-error/", endpoint=return_error),
         Route("/background-jobs/", endpoint=background_jobs),
+        Route("/instance-app/", endpoint=InstanceApp()),
     ]
 
     async def raise_error_handler(request, exc):
@@ -464,3 +472,29 @@ async def test_username_bad_user(tracked_requests):
     assert len(tracked_requests) == 1
     tracked_request = tracked_requests[0]
     assert "username" not in tracked_request.tags
+
+
+@async_test
+async def test_instance_app(tracked_requests):
+    with app_with_scout() as app:
+        communicator = ApplicationCommunicator(
+            app, asgi_http_scope(path="/instance-app/")
+        )
+        await communicator.send_input({"type": "http.request"})
+        # Read the response.
+        response_start = await communicator.receive_output()
+        response_body = await communicator.receive_output()
+
+    assert response_start["type"] == "http.response.start"
+    assert response_start["status"] == 200
+    assert response_body["type"] == "http.response.body"
+    assert response_body["body"] == b"Welcome home from an app that's a class instance."
+    assert len(tracked_requests) == 1
+    tracked_request = tracked_requests[0]
+    assert len(tracked_request.complete_spans) == 1
+    assert tracked_request.tags["path"] == "/instance-app/"
+    span = tracked_request.complete_spans[0]
+    assert span.operation == (
+        "Controller/tests.integration.test_starlette_py36plus."
+        + "app_with_scout.<locals>.InstanceApp"
+    )