Repository: scoutapp/scout_apm_python (python)

Issue:
object has no attribute '__qualname__'
I am getting the following error when trying to enable the scout APM on a starlette application:
```
Traceback (most recent call last):
  File "/app/.heroku/python/lib/python3.8/site-packages/uvicorn/protocols/http/httptools_impl.py", line 391, in run_asgi
    result = await app(self.scope, self.receive, self.send)
  File "/app/.heroku/python/lib/python3.8/site-packages/uvicorn/middleware/proxy_headers.py", line 45, in __call__
    return await self.app(scope, receive, send)
  File "/app/.heroku/python/lib/python3.8/site-packages/starlette/applications.py", line 111, in __call__
    await self.middleware_stack(scope, receive, send)
  File "/app/.heroku/python/lib/python3.8/site-packages/starlette/middleware/errors.py", line 181, in __call__
    raise exc from None
  File "/app/.heroku/python/lib/python3.8/site-packages/starlette/middleware/errors.py", line 159, in __call__
    await self.app(scope, receive, _send)
  File "/app/.heroku/python/lib/python3.8/site-packages/scout_apm/async_/starlette.py", line 67, in __call__
    grab_extra_data()
  File "/app/.heroku/python/lib/python3.8/site-packages/scout_apm/async_/starlette.py", line 35, in grab_extra_data
    endpoint.__module__, endpoint.__qualname__
AttributeError: 'GraphQLWithBackground' object has no attribute '__qualname__'
```

`GraphQLWithBackground` is a subclass of `ariadne.asgi.GraphQL`.

Pull request description:
Handle ASGI apps in Starlette integration that are class instances
Fixes #596.

Patch:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index f42fecb..1754cff 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -15,35 +15,53 @@ jobs:
 
     steps:
     - uses: actions/checkout@v2
+
     - uses: actions/setup-python@v2
       with:
-        python-version: 3.8
+        python-version: 3.9
+
     - uses: actions/setup-python@v2
       with:
         python-version: 2.7
+
+    - name: Set up QEMU
+      # For cross-architecture builds
+      # https://cibuildwheel.readthedocs.io/en/stable/faq/#emulation
+      if: runner.os == 'Linux'
+      uses: docker/setup-qemu-action@v1
+      with:
+        platforms: all
+
     - name: Install packaging tools
       run: |
         python2.7 -m pip install --upgrade pip setuptools wheel
-        python3.8 -m pip install --upgrade cibuildwheel pip setuptools twine wheel
+        python3.9 -m pip install --upgrade cibuildwheel pip setuptools twine wheel
+
     - name: Build sdist
-      run: python3.8 setup.py sdist
-    - name: Build Python 2 wheel
+      run: python3.9 setup.py sdist
+
+    - name: Build Python 2 pure Python wheel
       run: python2.7 setup.py bdist_wheel
-    - name: Build Python 3 wheel
+
+    - name: Build Python 3 pure Python wheel
       env:
         SCOUT_DISABLE_EXTENSIONS: "1"
-      run: python3.8 setup.py bdist_wheel
+      run: python3.9 setup.py bdist_wheel
+
     - name: Build binary wheels
       env:
-        # Disable for platforms where pure Python wheels would be generated,
-        # and Python 3.9 since it's pre-release
+        # Disable for platforms where pure Python wheels would be generated
+        CIBW_ARCHS_LINUX: "auto aarch64"
         CIBW_BUILD_VERBOSITY: 1
         CIBW_SKIP: "cp27-* pp27-* pp36-* pp37-*"
       run: cibuildwheel
+
     - name: Check packages
       run: twine check dist/* wheelhouse/*
+
     - name: List files
       run: ls -al dist wheelhouse
+
     - name: Release
       if: startsWith(github.event.ref, 'refs/tags')
       env:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6e451c0..edd43c0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,7 +8,7 @@
   `open_point_in_time()`.
   ([PR #602](https://github.com/scoutapp/scout_apm_python/pull/602))
 
-- The release now includes a wheel for Python 3.9.
+- The release now includes binary wheels for Linux ARM and Python 3.9.
 
 ### Fixed
 
@@ -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/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"
+    )