Repository: elastic/apm-agent-python

Issue:
Crash on starlette middleware in mount endpoints (StaticFiles)
**Describe the bug**: Elastic middleware crash on access to static files

**Environment (please complete the following information)**
- OS: [e.g. Linux]
- Python version: 3.9.4
- Framework and version [e.g. Django 2.1]: FastAPI
- APM Server version: 7.12.1
- Agent version: 7.12.1

```python
from __future__ import annotations

import uvicorn
from elasticapm.contrib.starlette import ElasticAPM, make_apm_client
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles

app = FastAPI(routes=[])
apm_config = {
    "SERVICE_NAME": "testing",
    "ENABLED": True,
    "SERVER_URL": "http://localhost:8200",
    "CAPTURE_HEADERS": True,
    "CAPTURE_BODY": "all",
}
apm = make_apm_client(apm_config)
app.add_middleware(ElasticAPM, client=apm)
app.mount("/static",StaticFiles(directory="/"),name="static")


if __name__ == "__main__":
    uvicorn.run(app, host="0.0.0.0", debug=False)
```

The issue is happens when elastic running the function _get_route_name.
the middleware will try to loop over the routes where starlette will return None in some cases:
```python
  File "/home/syniex/.local/lib/python3.9/site-packages/elasticapm/contrib/starlette/__init__.py", line 233, in _get_route_name
    for route in routes:
                 └ None

TypeError: 'NoneType' object is not iterable

```
Starlette code that makes the issue:
```python
@property
    def routes(self) -> typing.List[BaseRoute]:
        return getattr(self.app, "routes", None)
```


Discussion:
[Starlette#1182](https://github.com/encode/starlette/issues/1182#issue-890379365)
Seems like this was introduced in this commit: https://github.com/elastic/apm-agent-python/commit/a5e66603254bb9b126172a233b50be5cb88d9949
https://github.com/elastic/apm-agent-python/blob/master/elasticapm/contrib/starlette/__init__.py#L238
`if isinstance(route, Mount):` -> `if isinstance(route, Mount) and route.routes:` and add tests.

Resolving patch:
diff --git a/elasticapm/contrib/starlette/__init__.py b/elasticapm/contrib/starlette/__init__.py
--- a/elasticapm/contrib/starlette/__init__.py
+++ b/elasticapm/contrib/starlette/__init__.py
@@ -237,7 +237,7 @@ def _get_route_name(self, scope, routes, route_name=None):
             if match == Match.FULL:
                 route_name = route.path
                 child_scope = {**scope, **child_scope}
-                if isinstance(route, Mount):
+                if isinstance(route, Mount) and route.routes:
                     child_route_name = self._get_route_name(child_scope, route.routes, route_name)
                     if child_route_name is None:
                         route_name = None


Test patch:
diff --git a/tests/contrib/asyncio/starlette_tests.py b/tests/contrib/asyncio/starlette_tests.py
--- a/tests/contrib/asyncio/starlette_tests.py
+++ b/tests/contrib/asyncio/starlette_tests.py
@@ -28,18 +28,18 @@
 #  OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
 #  OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 
+import os
 import pytest  # isort:skip
 
 from tests.fixtures import TempStoreClient
 
 starlette = pytest.importorskip("starlette")  # isort:skip
 
-import types
-
 import mock
 import urllib3
 from starlette.applications import Starlette
 from starlette.responses import PlainTextResponse
+from starlette.staticfiles import StaticFiles
 from starlette.testclient import TestClient
 
 import elasticapm
@@ -53,6 +53,8 @@
 
 starlette_version_tuple = tuple(map(int, starlette.__version__.split(".")[:3]))
 
+file_path, file_name = os.path.split(__file__)
+
 
 @pytest.fixture
 def app(elasticapm_client):
@@ -371,6 +373,77 @@ def test_capture_body_error(app, elasticapm_client):
         response = client.post("/raise-exception", data="[0, 1]")
 
 
+@pytest.fixture
+def app_static_files_only(elasticapm_client):
+    app = Starlette()
+    app.add_middleware(ElasticAPM, client=elasticapm_client)
+    app.mount("/tmp", StaticFiles(directory=file_path), name="static")
+
+    yield app
+
+    elasticapm.uninstrument()
+
+
+def test_static_files_only(app_static_files_only, elasticapm_client):
+    client = TestClient(app_static_files_only)
+
+    response = client.get(
+        "/tmp/" + file_name,
+        headers={
+            constants.TRACEPARENT_HEADER_NAME: "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-03",
+            constants.TRACESTATE_HEADER_NAME: "foo=bar,bar=baz",
+            "REMOTE_ADDR": "127.0.0.1",
+        },
+    )
+
+    assert response.status_code == 200
+
+    assert len(elasticapm_client.events[constants.TRANSACTION]) == 1
+    transaction = elasticapm_client.events[constants.TRANSACTION][0]
+    spans = elasticapm_client.spans_for_transaction(transaction)
+    assert len(spans) == 0
+
+    assert transaction["name"] == "GET /tmp"
+    assert transaction["result"] == "HTTP 2xx"
+    assert transaction["outcome"] == "success"
+    assert transaction["type"] == "request"
+    assert transaction["span_count"]["started"] == 0
+    assert transaction["context"]["request"]["url"]["pathname"] == "/tmp/" + file_name
+    request = transaction["context"]["request"]
+    assert request["method"] == "GET"
+    assert request["socket"] == {"remote_address": "127.0.0.1", "encrypted": False}
+
+
+def test_static_files_only_file_notfound(app_static_files_only, elasticapm_client):
+    client = TestClient(app_static_files_only)
+
+    response = client.get(
+        "/tmp/whatever",
+        headers={
+            constants.TRACEPARENT_HEADER_NAME: "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-03",
+            constants.TRACESTATE_HEADER_NAME: "foo=bar,bar=baz",
+            "REMOTE_ADDR": "127.0.0.1",
+        },
+    )
+
+    assert response.status_code == 404
+
+    assert len(elasticapm_client.events[constants.TRANSACTION]) == 1
+    transaction = elasticapm_client.events[constants.TRANSACTION][0]
+    spans = elasticapm_client.spans_for_transaction(transaction)
+    assert len(spans) == 0
+
+    assert transaction["name"] == "GET /tmp"
+    assert transaction["result"] == "HTTP 4xx"
+    assert transaction["outcome"] == "success"
+    assert transaction["type"] == "request"
+    assert transaction["span_count"]["started"] == 0
+    assert transaction["context"]["request"]["url"]["pathname"] == "/tmp/whatever"
+    request = transaction["context"]["request"]
+    assert request["method"] == "GET"
+    assert request["socket"] == {"remote_address": "127.0.0.1", "encrypted": False}
+
+
 def test_make_client_with_config():
     c = make_apm_client(config={"SERVICE_NAME": "foo"}, client_cls=TempStoreClient)
     c.close()
diff --git a/tests/requirements/reqs-starlette-0.13.txt b/tests/requirements/reqs-starlette-0.13.txt
--- a/tests/requirements/reqs-starlette-0.13.txt
+++ b/tests/requirements/reqs-starlette-0.13.txt
@@ -1,3 +1,4 @@
 starlette==0.13.0
+aiofiles==0.7.0
 requests==2.23.0
 -r reqs-base.txt
diff --git a/tests/requirements/reqs-starlette-newest.txt b/tests/requirements/reqs-starlette-newest.txt
--- a/tests/requirements/reqs-starlette-newest.txt
+++ b/tests/requirements/reqs-starlette-newest.txt
@@ -1,4 +1,5 @@
 starlette
+aiofiles
 requests
 flask
 -r reqs-base.txt