Repository: elastic/apm-agent-python

Issue:
dbapi2 span does not include object name when calling a procedure
**Is your feature request related to a problem? Please describe.**

Aggregating metrics of stored procedures is not possible because the span name lacks the object name of the procedure being called.
Also, the span.action is only set to `exec` when a procedure is executed through `callproc`, not connection.execute etc.
Now it's set to `query` for EXECUTE statements, depending on the dbapi2 method used.

**Describe the solution you'd like**

* Set span.action to the EXEC_ACTION constant for all EXEC/EXECUTE statements
* Include the object name / procedure name in the span.name, such as `EXECUTE sp_who` instead of just `EXECUTE`

**Describe alternatives you've considered**

Custom spans can be used, but that duplicates the number of spans for each EXECUTE statement

**Additional context**

Example of current output:

![Screenshot 2023-11-24 at 18 39 31](https://github.com/elastic/apm-agent-python/assets/435885/2d96424e-3a98-42c0-bab6-f2c4a04f1a16)



Discussion:

Resolving patch:
diff --git a/elasticapm/instrumentation/packages/dbapi2.py b/elasticapm/instrumentation/packages/dbapi2.py
--- a/elasticapm/instrumentation/packages/dbapi2.py
+++ b/elasticapm/instrumentation/packages/dbapi2.py
@@ -170,30 +170,46 @@ def extract_signature(sql):
         keyword = "INTO" if sql_type == "INSERT" else "FROM"
         sql_type = sql_type + " " + keyword
 
-        table_name = look_for_table(sql, keyword)
+        object_name = look_for_table(sql, keyword)
     elif sql_type in ["CREATE", "DROP"]:
         # 2nd word is part of SQL type
         sql_type = sql_type + sql[first_space:second_space]
-        table_name = ""
+        object_name = ""
     elif sql_type == "UPDATE":
-        table_name = look_for_table(sql, "UPDATE")
+        object_name = look_for_table(sql, "UPDATE")
     elif sql_type == "SELECT":
         # Name is first table
         try:
             sql_type = "SELECT FROM"
-            table_name = look_for_table(sql, "FROM")
+            object_name = look_for_table(sql, "FROM")
         except Exception:
-            table_name = ""
+            object_name = ""
+    elif sql_type in ["EXEC", "EXECUTE"]:
+        sql_type = "EXECUTE"
+        end = second_space if second_space > first_space else len(sql)
+        object_name = sql[first_space + 1 : end]
+    elif sql_type == "CALL":
+        first_paren = sql.find("(", first_space)
+        end = first_paren if first_paren > first_space else len(sql)
+        procedure_name = sql[first_space + 1 : end].rstrip(";")
+        object_name = procedure_name + "()"
     else:
         # No name
-        table_name = ""
+        object_name = ""
 
-    signature = " ".join(filter(bool, [sql_type, table_name]))
+    signature = " ".join(filter(bool, [sql_type, object_name]))
     return signature
 
 
 QUERY_ACTION = "query"
 EXEC_ACTION = "exec"
+PROCEDURE_STATEMENTS = ["EXEC", "EXECUTE", "CALL"]
+
+
+def extract_action_from_signature(signature, default):
+    if signature.split(" ")[0] in PROCEDURE_STATEMENTS:
+        return EXEC_ACTION
+    return default
 
 
 class CursorProxy(wrapt.ObjectProxy):
@@ -226,6 +242,7 @@ def _trace_sql(self, method, sql, params, action=QUERY_ACTION):
             signature = sql_string + "()"
         else:
             signature = self.extract_signature(sql_string)
+            action = extract_action_from_signature(signature, action)
 
         # Truncate sql_string to 10000 characters to prevent large queries from
         # causing an error to APM server.


Test patch:
diff --git a/tests/instrumentation/dbapi2_tests.py b/tests/instrumentation/dbapi2_tests.py
--- a/tests/instrumentation/dbapi2_tests.py
+++ b/tests/instrumentation/dbapi2_tests.py
@@ -29,7 +29,13 @@
 #  OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 import pytest
 
-from elasticapm.instrumentation.packages.dbapi2 import Literal, extract_signature, scan, tokenize
+from elasticapm.instrumentation.packages.dbapi2 import (
+    Literal,
+    extract_action_from_signature,
+    extract_signature,
+    scan,
+    tokenize,
+)
 
 
 def test_scan_simple():
@@ -114,3 +120,37 @@ def test_extract_signature_bytes():
     actual = extract_signature(sql)
     expected = "HELLO"
     assert actual == expected
+
+
+@pytest.mark.parametrize(
+    ["sql", "expected"],
+    [
+        (
+            "EXEC AdventureWorks2022.dbo.uspGetEmployeeManagers 50;",
+            "EXECUTE AdventureWorks2022.dbo.uspGetEmployeeManagers",
+        ),
+        ("EXECUTE sp_who2", "EXECUTE sp_who2"),
+        ("EXEC sp_updatestats @@all_schemas = 'true'", "EXECUTE sp_updatestats"),
+        ("CALL get_car_stats_by_year(2017, @number, @min, @avg, @max);", "CALL get_car_stats_by_year()"),
+        ("CALL get_car_stats_by_year", "CALL get_car_stats_by_year()"),
+        ("CALL get_car_stats_by_year;", "CALL get_car_stats_by_year()"),
+        ("CALL get_car_stats_by_year();", "CALL get_car_stats_by_year()"),
+    ],
+)
+def test_extract_signature_for_procedure_call(sql, expected):
+    actual = extract_signature(sql)
+    assert actual == expected
+
+
+@pytest.mark.parametrize(
+    ["sql", "expected"],
+    [
+        ("SELECT FROM table", "query"),
+        ("EXEC sp_who", "exec"),
+        ("EXECUTE sp_updatestats", "exec"),
+        ("CALL me_maybe", "exec"),
+    ],
+)
+def test_extract_action_from_signature(sql, expected):
+    actual = extract_action_from_signature(sql, "query")
+    assert actual == expected