Repository: elastic/apm-agent-python

Issue:
Inaccurate transaction names for FastAPI sub-applications
**Description**
For requests to endpoints defined in FastAPI sub-applications, the mount path is chosen as the transaction name. I expected the full route of the endpoint.

**To Reproduce**

1. Run the following simple FastAPI app:

```python
import uvicorn
from elasticapm.contrib.starlette import ElasticAPM, make_apm_client
from fastapi import FastAPI

app = FastAPI()
sub = FastAPI()
app.mount("/sub", sub)

apm = make_apm_client(
    {
        "SERVICE_NAME": "sub-app-test",
    }
)

app.add_middleware(ElasticAPM, client=apm)


@sub.get("/hi")
async def hi():
    return "hi"


@sub.get("/bye")
async def bye():
    return "bye"


if __name__ == "__main__":
    uvicorn.run(app, host="0.0.0.0", port=8888)
```

2. 
- **Observed behavior**
The transactions of `/sub/hi` and `/sub/bye` are both named `/sub` and grouped.
![transactions](https://user-images.githubusercontent.com/1078105/106763453-2383b280-6637-11eb-9017-0865f8575687.PNG)

- **Expected behavior**
The transactions of `/sub/hi` and `/sub/bye` are named according to the full route.


**Environment**
- OS: 
  - Client: Windows
  - Server: Ubuntu
- Python version: 3.7.3
- Framework and version: `fastapi==0.61.2`
- APM Server version: docker image `elasticsearch/elasticsearch:7.10.2` 
- Agent version: `elastic-apm==6.0.0`


Discussion:

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
@@ -35,7 +35,7 @@
 from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
 from starlette.requests import Request
 from starlette.responses import Response
-from starlette.routing import Match
+from starlette.routing import Match, Mount
 from starlette.types import ASGIApp
 
 import elasticapm
@@ -207,18 +207,11 @@ async def _request_finished(self, response: Response):
         elasticapm.set_transaction_result(result, override=False)
 
     def get_route_name(self, request: Request) -> str:
-        route_name = None
         app = request.app
         scope = request.scope
         routes = app.routes
+        route_name = self._get_route_name(scope, routes)
 
-        for route in routes:
-            match, _ = route.matches(scope)
-            if match == Match.FULL:
-                route_name = route.path
-                break
-            elif match == Match.PARTIAL and route_name is None:
-                route_name = route.path
         # Starlette magically redirects requests if the path matches a route name with a trailing slash
         # appended or removed. To not spam the transaction names list, we do the same here and put these
         # redirects all in the same "redirect trailing slashes" transaction name
@@ -230,9 +223,23 @@ def get_route_name(self, request: Request) -> str:
             else:
                 redirect_scope["path"] = scope["path"] + "/"
                 trim = False
-            for route in routes:
-                match, _ = route.matches(redirect_scope)
-                if match != Match.NONE:
-                    route_name = route.path + "/" if trim else route.path[:-1]
-                    break
+
+            route_name = self._get_route_name(redirect_scope, routes)
+            route_name = route_name + "/" if trim else route_name[:-1]
         return route_name
+
+    def _get_route_name(self, scope, routes, route_name=None):
+        for route in routes:
+            match, child_scope = route.matches(scope)
+            if match == Match.FULL:
+                route_name = route.path
+                child_scope = {**scope, **child_scope}
+                if isinstance(route, Mount):
+                    child_route_name = self._get_route_name(child_scope, route.routes, route_name)
+                    if child_route_name is None:
+                        route_name = None
+                    else:
+                        route_name += child_route_name
+                return route_name
+            elif match == Match.PARTIAL and route_name is None:
+                route_name = route.path


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
@@ -55,6 +55,10 @@
 @pytest.fixture
 def app(elasticapm_client):
     app = Starlette()
+    sub = Starlette()
+    subsub = Starlette()
+    app.mount("/sub", sub)
+    sub.mount("/subsub", subsub)
 
     @app.route("/", methods=["GET", "POST"])
     async def hi(request):
@@ -86,6 +90,14 @@ async def with_slash(request):
     async def without_slash(request):
         return PlainTextResponse("Hi {}".format(request.path_params["name"]))
 
+    @sub.route("/hi")
+    async def hi_from_sub(request):
+        return PlainTextResponse("sub")
+
+    @subsub.route("/hihi/{name}")
+    async def hi_from_sub(request):
+        return PlainTextResponse(request.path_params["name"])
+
     app.add_middleware(ElasticAPM, client=elasticapm_client)
 
     yield app
@@ -270,6 +282,7 @@ def test_transaction_name_is_route(app, elasticapm_client):
     (
         ("/hi/shay/with/slash", "GET /hi/{name}/with/slash"),
         ("/hi/shay/without/slash/", "GET /hi/{name}/without/slash/"),
+        ("/sub/subsub/hihi/shay/", "GET /sub/subsub/hihi/{name}/"),
     ),
 )
 def test_trailing_slash_redirect_detection(app, elasticapm_client, url, expected):
@@ -294,6 +307,29 @@ def test_enabled_instrumentation(app, elasticapm_client):
     assert not isinstance(urllib3.connectionpool.HTTPConnectionPool.urlopen, wrapt.BoundFunctionWrapper)
 
 
+def test_transaction_name_is_route_for_mounts(app, elasticapm_client):
+    """
+    Tests if recursive URL matching works when apps are mounted in other apps
+    """
+    client = TestClient(app)
+    response = client.get("/sub/hi")
+    assert response.status_code == 200
+
+    assert len(elasticapm_client.events[constants.TRANSACTION]) == 1
+    transaction = elasticapm_client.events[constants.TRANSACTION][0]
+    assert transaction["name"] == "GET /sub/hi"
+    assert transaction["context"]["request"]["url"]["pathname"] == "/sub/hi"
+
+    response = client.get("/sub/subsub/hihi/shay")
+    assert response.status_code == 200
+
+    assert len(elasticapm_client.events[constants.TRANSACTION]) == 2
+    transaction = elasticapm_client.events[constants.TRANSACTION][1]
+    assert transaction["name"] == "GET /sub/subsub/hihi/{name}"
+    assert transaction["context"]["request"]["url"]["pathname"] == "/sub/subsub/hihi/shay"
+
+
+
 @pytest.mark.parametrize(
     "elasticapm_client",
     [