Repository: elastic/apm-agent-python

Issue:
[META 531] Add transaction.name to errors
[![issue details](https://img.shields.io/endpoint?label=meta-issue:&url=https%3A%2F%2Fgiss.app.elstc.co%2Fapi%2Fstatus%2Felastic%2Fapm%2F531)](https://github.com/elastic/apm/issues/531)

[![issue details](https://img.shields.io/endpoint?label=spec-issue:&url=https%3A%2F%2Fgiss.app.elstc.co%2Fapi%2Fstatus%2Felastic%2Fapm%2F553)](https://github.com/elastic/apm/issues/553)

Implement https://github.com/elastic/apm/issues/531.


Discussion:

Resolving patch:
diff --git a/elasticapm/base.py b/elasticapm/base.py
--- a/elasticapm/base.py
+++ b/elasticapm/base.py
@@ -547,7 +547,11 @@ def _build_msg_for_logging(
             # parent id might already be set in the handler
             event_data.setdefault("parent_id", span.id if span else transaction.id)
             event_data["transaction_id"] = transaction.id
-            event_data["transaction"] = {"sampled": transaction.is_sampled, "type": transaction.transaction_type}
+            event_data["transaction"] = {
+                "sampled": transaction.is_sampled,
+                "type": transaction.transaction_type,
+                "name": transaction.name,
+            }
 
         return event_data
 
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
@@ -33,9 +33,12 @@
 
 import logging
 import threading
+from types import FunctionType
+from typing import Optional
 
 from django.apps import apps
 from django.conf import settings as django_settings
+from django.http import HttpRequest, HttpResponse
 
 import elasticapm
 from elasticapm.conf import constants
@@ -114,10 +117,10 @@ def process_response_wrapper(wrapped, instance, args, kwargs):
     response = wrapped(*args, **kwargs)
     try:
         request, original_response = args
-        # if there's no view_func on the request, and this middleware created
+        # if we haven't set the name in a view, and this middleware created
         # a new response object, it's logged as the responsible transaction
         # name
-        if not hasattr(request, "_elasticapm_view_func") and response is not original_response:
+        if not getattr(request, "_elasticapm_name_set", False) and response is not original_response:
             elasticapm.set_transaction_name(
                 build_name_with_http_method_prefix(get_name_from_middleware(wrapped, instance), request)
             )
@@ -159,25 +162,17 @@ def instrument_middlewares(self):
                 except ImportError:
                     client.logger.warning("Can't instrument middleware %s", middleware_path)
 
-    def process_view(self, request, view_func, view_args, view_kwargs):
-        request._elasticapm_view_func = view_func
+    def process_view(self, request: HttpRequest, view_func: FunctionType, view_args: list, view_kwargs: dict):
+        elasticapm.set_transaction_name(self.get_transaction_name(request, view_func), override=False)
+        request._elasticapm_name_set = True
 
-    def process_response(self, request, response):
+    def process_response(self, request: HttpRequest, response: HttpResponse):
         if django_settings.DEBUG and not self.client.config.debug:
             return response
         try:
             if hasattr(response, "status_code"):
-                transaction_name = None
-                if self.client.config.django_transaction_name_from_route and hasattr(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:
-                    transaction_name = build_name_with_http_method_prefix(transaction_name, request)
-                    elasticapm.set_transaction_name(transaction_name, override=False)
-
+                if not getattr(request, "_elasticapm_name_set", False):
+                    elasticapm.set_transaction_name(self.get_transaction_name(request), override=False)
                 elasticapm.set_context(
                     lambda: self.client.get_data_from_request(request, constants.TRANSACTION), "request"
                 )
@@ -191,6 +186,18 @@ def process_response(self, request, response):
             self.client.error_logger.error("Exception during timing of request", exc_info=True)
         return response
 
+    def get_transaction_name(self, request: HttpRequest, view_func: Optional[FunctionType] = None) -> str:
+        transaction_name = ""
+        if self.client.config.django_transaction_name_from_route and hasattr(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 view_func:
+            transaction_name = get_name_from_func(view_func)
+        if transaction_name:
+            transaction_name = build_name_with_http_method_prefix(transaction_name, request)
+        return transaction_name
+
 
 class ErrorIdMiddleware(MiddlewareMixin):
     """


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
@@ -770,6 +770,52 @@ def test_transaction_metrics_error(django_elasticapm_client, client):
         assert transaction["outcome"] == "failure"
 
 
+def test_transaction_metrics_exception(django_elasticapm_client, client):
+    with override_settings(
+        **middleware_setting(django.VERSION, ["elasticapm.contrib.django.middleware.TracingMiddleware"])
+    ):
+        assert len(django_elasticapm_client.events[TRANSACTION]) == 0
+        try:
+            client.get(reverse("elasticapm-raise-exc"))
+        except Exception:
+            pass
+        assert len(django_elasticapm_client.events[TRANSACTION]) == 1
+
+        transactions = django_elasticapm_client.events[TRANSACTION]
+        errors = django_elasticapm_client.events[ERROR]
+
+        assert len(transactions) == 1
+        assert len(errors) == 1
+        transaction = transactions[0]
+        error = errors[0]
+        assert transaction["duration"] > 0
+        assert transaction["result"] == "HTTP 5xx"
+        assert transaction["name"] == "GET tests.contrib.django.testapp.views.raise_exc"
+        assert transaction["outcome"] == "failure"
+        assert transaction["name"] == error["transaction"]["name"]
+
+
+def test_transaction_metrics_404(django_elasticapm_client, client):
+    with override_settings(
+        **middleware_setting(django.VERSION, ["elasticapm.contrib.django.middleware.TracingMiddleware"])
+    ):
+        assert len(django_elasticapm_client.events[TRANSACTION]) == 0
+        try:
+            r = client.get("/non-existant-url")
+        except Exception as e:
+            pass
+        assert len(django_elasticapm_client.events[TRANSACTION]) == 1
+
+        transactions = django_elasticapm_client.events[TRANSACTION]
+
+        assert len(transactions) == 1
+        transaction = transactions[0]
+        assert transaction["duration"] > 0
+        assert transaction["result"] == "HTTP 4xx"
+        assert transaction["name"] == ""
+        assert transaction["outcome"] == "success"
+
+
 def test_transaction_metrics_debug(django_elasticapm_client, client):
     with override_settings(
         DEBUG=True, **middleware_setting(django.VERSION, ["elasticapm.contrib.django.middleware.TracingMiddleware"])
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
@@ -32,7 +32,7 @@
 
 import django
 from django.conf import settings
-from django.http import HttpResponse
+from django.http import HttpResponseServerError
 
 from tests.contrib.django.testapp import views
 
@@ -46,7 +46,7 @@
 def handler500(request):
     if getattr(settings, "BREAK_THAT_500", False):
         raise ValueError("handler500")
-    return HttpResponse("")
+    return HttpResponseServerError("")
 
 
 urlpatterns = (