Repository: elastic/apm-agent-python

Issue:
Root Django URL doesn't appear in the UI when using routes
**Describe the bug**: `transaction.name` field is missing from the `transcction` document

**To Reproduce**

1. set `DJANGO_TRANSACTION_NAME_FROM_ROUTE` to `True`
2. have url of `path("", TemplateView.as_view(template_name="pages/home.html"), name="home")`
3. ...
4. not profit

**Environment (please complete the following information)**
- OS: Linux
- Python version: 3.8
- Framework and version [e.g. Django 2.1]: Django 3.1.6
- APM Server version: 7.12
- Agent version:6.1.1


Discussion:

Resolving patch:
diff --git a/elasticapm/contrib/django/middleware/__init__.py b/elasticapm/contrib/django/middleware/__init__.py
--- a/elasticapm/contrib/django/middleware/__init__.py
+++ b/elasticapm/contrib/django/middleware/__init__.py
@@ -169,7 +169,9 @@ def process_response(self, request, response):
             if hasattr(response, "status_code"):
                 transaction_name = None
                 if self.client.config.django_transaction_name_from_route and hasattr(request.resolver_match, "route"):
-                    transaction_name = request.resolver_match.route
+                    r = request.resolver_match
+                    # if no route is defined (e.g. for the root URL), fall back on url_name and then function name
+                    transaction_name = r.route or r.url_name or get_name_from_func(r.func)
                 elif getattr(request, "_elasticapm_view_func", False):
                     transaction_name = get_name_from_func(request._elasticapm_view_func)
                 if transaction_name:


Test patch:
diff --git a/tests/contrib/django/django_tests.py b/tests/contrib/django/django_tests.py
--- a/tests/contrib/django/django_tests.py
+++ b/tests/contrib/django/django_tests.py
@@ -1531,6 +1531,29 @@ def test_transaction_name_from_route(client, django_elasticapm_client):
     assert transaction["name"] == "GET route/<int:id>/"
 
 
+@pytest.mark.skipif(django.VERSION < (2, 2), reason="ResolverMatch.route attribute is new in Django 2.2")
+@pytest.mark.parametrize("django_elasticapm_client", [{"django_transaction_name_from_route": "true"}], indirect=True)
+def test_transaction_name_from_route_empty_route(client, django_elasticapm_client):
+    with override_settings(
+        **middleware_setting(django.VERSION, ["elasticapm.contrib.django.middleware.TracingMiddleware"])
+    ):
+        client.get("/")
+    transaction = django_elasticapm_client.events[TRANSACTION][0]
+    assert transaction["name"] == "GET home-view"
+
+
+@pytest.mark.skipif(django.VERSION < (2, 2), reason="ResolverMatch.route attribute is new in Django 2.2")
+@pytest.mark.urls("tests.contrib.django.testapp.nameless_route_urls")
+@pytest.mark.parametrize("django_elasticapm_client", [{"django_transaction_name_from_route": "true"}], indirect=True)
+def test_transaction_name_from_route_empty_route_no_name(client, django_elasticapm_client):
+    with override_settings(
+        **middleware_setting(django.VERSION, ["elasticapm.contrib.django.middleware.TracingMiddleware"])
+    ):
+        client.get("/")
+    transaction = django_elasticapm_client.events[TRANSACTION][0]
+    assert transaction["name"] == "GET tests.contrib.django.testapp.views.no_error"
+
+
 @pytest.mark.skipif(django.VERSION >= (2, 2), reason="ResolverMatch.route attribute is new in Django 2.2")
 @pytest.mark.parametrize("django_elasticapm_client", [{"django_transaction_name_from_route": "true"}], indirect=True)
 def test_transaction_name_from_route_doesnt_have_effect_in_older_django(client, django_elasticapm_client):
diff --git a/tests/contrib/django/testapp/nameless_route_urls.py b/tests/contrib/django/testapp/nameless_route_urls.py
new file mode 100644
--- /dev/null
+++ b/tests/contrib/django/testapp/nameless_route_urls.py
@@ -0,0 +1,40 @@
+#  BSD 3-Clause License
+#
+#  Copyright (c) 2021, Elasticsearch BV
+#  All rights reserved.
+#
+#  Redistribution and use in source and binary forms, with or without
+#  modification, are permitted provided that the following conditions are met:
+#
+#  * Redistributions of source code must retain the above copyright notice, this
+#    list of conditions and the following disclaimer.
+#
+#  * Redistributions in binary form must reproduce the above copyright notice,
+#    this list of conditions and the following disclaimer in the documentation
+#    and/or other materials provided with the distribution.
+#
+#  * Neither the name of the copyright holder nor the names of its
+#    contributors may be used to endorse or promote products derived from
+#    this software without specific prior written permission.
+#
+#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+#  DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+#  FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+#  DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+#  SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+#  CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+#  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.
+
+from __future__ import absolute_import
+
+import django
+
+from tests.contrib.django.testapp import views
+
+if django.VERSION >= (2, 2):
+    from django.urls import path
+
+    urlpatterns = (path("", views.no_error),)
diff --git a/tests/contrib/django/testapp/urls.py b/tests/contrib/django/testapp/urls.py
--- a/tests/contrib/django/testapp/urls.py
+++ b/tests/contrib/django/testapp/urls.py
@@ -75,4 +75,7 @@ def handler500(request):
 if django.VERSION >= (2, 2):
     from django.urls import path
 
-    urlpatterns += (path("route/<int:id>/", views.no_error, name="route-view"),)
+    urlpatterns += (
+        path("route/<int:id>/", views.no_error, name="route-view"),
+        path("", views.no_error, name="home-view"),
+    )