Repository: elastic/apm-agent-python

Issue:
[META 94] Implement span_stack_trace_min_duration config option
Implement https://github.com/elastic/apm/issues/94

Spec PR: https://github.com/elastic/apm/pull/454/files

As part of this issue we should update the tuning/troubleshooting docs to make this feature more prominent, as it's our biggest source of overhead: https://www.elastic.co/guide/en/apm/agent/python/current/tuning-and-overhead.html#tuning-max-spans


Discussion:

Resolving patch:
diff --git a/elasticapm/conf/__init__.py b/elasticapm/conf/__init__.py
--- a/elasticapm/conf/__init__.py
+++ b/elasticapm/conf/__init__.py
@@ -317,7 +317,7 @@ def __call__(self, value, field_name):
             value = float(value)
         except ValueError:
             raise ConfigurationError("{} is not a float".format(value), field_name)
-        multiplier = 10 ** self.precision
+        multiplier = 10**self.precision
         rounded = math.floor(value * multiplier + 0.5) / multiplier
         if rounded == 0 and self.minimum and value != 0:
             rounded = self.minimum
@@ -608,6 +608,9 @@ class Config(_ConfigBase):
     span_frames_min_duration = _DurationConfigValue(
         "SPAN_FRAMES_MIN_DURATION", default=timedelta(seconds=0.005), unitless_factor=0.001
     )
+    span_stack_trace_min_duration = _DurationConfigValue(
+        "SPAN_STACK_TRACE_MIN_DURATION", default=timedelta(seconds=0.005), unitless_factor=0.001
+    )
     span_compression_enabled = _BoolConfigValue("SPAN_COMPRESSION_ENABLED", default=False)
     span_compression_exact_match_max_duration = _DurationConfigValue(
         "SPAN_COMPRESSION_EXACT_MATCH_MAX_DURATION",
diff --git a/elasticapm/traces.py b/elasticapm/traces.py
--- a/elasticapm/traces.py
+++ b/elasticapm/traces.py
@@ -34,6 +34,7 @@
 import threading
 import time
 import timeit
+import warnings
 from collections import defaultdict
 from datetime import timedelta
 from types import TracebackType
@@ -639,7 +640,11 @@ def end(self, skip_frames: int = 0, duration: Optional[float] = None):
         self.autofill_resource_context()
         super().end(skip_frames, duration)
         tracer = self.transaction.tracer
-        if not tracer.span_frames_min_duration or self.duration >= tracer.span_frames_min_duration and self.frames:
+        if (
+            tracer.span_stack_trace_min_duration >= timedelta(seconds=0)
+            and self.duration >= tracer.span_stack_trace_min_duration
+            and self.frames
+        ):
             self.frames = tracer.frames_processing_func(self.frames)[skip_frames:]
         else:
             self.frames = None
@@ -821,11 +826,25 @@ def __init__(self, frames_collector_func, frames_processing_func, queue_func, co
         self._ignore_patterns = [re.compile(p) for p in config.transactions_ignore_patterns or []]
 
     @property
-    def span_frames_min_duration(self) -> Optional[timedelta]:
-        if self.config.span_frames_min_duration in (timedelta(seconds=-1), None):
-            return None
+    def span_stack_trace_min_duration(self) -> timedelta:
+        if self.config.span_stack_trace_min_duration != timedelta(
+            seconds=0.005
+        ) or self.config.span_frames_min_duration == timedelta(seconds=0.005):
+            # No need to check span_frames_min_duration
+            return self.config.span_stack_trace_min_duration
         else:
-            return self.config.span_frames_min_duration
+            # span_stack_trace_min_duration is default value and span_frames_min_duration is non-default.
+            # warn and use span_frames_min_duration
+            warnings.warn(
+                "`span_frames_min_duration` is deprecated. Please use `span_stack_trace_min_duration`.",
+                DeprecationWarning,
+            )
+            if self.config.span_frames_min_duration < timedelta(seconds=0):
+                return timedelta(seconds=0)
+            elif self.config.span_frames_min_duration == timedelta(seconds=0):
+                return timedelta(seconds=-1)
+            else:
+                return self.config.span_frames_min_duration
 
     def begin_transaction(self, transaction_type, trace_parent=None, start=None, auto_activate=True):
         """


Test patch:
diff --git a/tests/client/transaction_tests.py b/tests/client/transaction_tests.py
--- a/tests/client/transaction_tests.py
+++ b/tests/client/transaction_tests.py
@@ -223,8 +223,8 @@ def test_transaction_max_spans_dynamic(elasticapm_client):
     assert len(spans) == 3
 
 
-@pytest.mark.parametrize("elasticapm_client", [{"span_frames_min_duration": 20}], indirect=True)
-def test_transaction_span_frames_min_duration(elasticapm_client):
+@pytest.mark.parametrize("elasticapm_client", [{"span_stack_trace_min_duration": 20}], indirect=True)
+def test_transaction_span_stack_trace_min_duration(elasticapm_client):
     elasticapm_client.begin_transaction("test_type")
     with elasticapm.capture_span("noframes", duration=0.001):
         pass
@@ -242,8 +242,8 @@ def test_transaction_span_frames_min_duration(elasticapm_client):
     assert spans[1]["stacktrace"] is not None
 
 
-@pytest.mark.parametrize("elasticapm_client", [{"span_frames_min_durarion_ms": -1}], indirect=True)
-def test_transaction_span_frames_min_duration_no_limit(elasticapm_client):
+@pytest.mark.parametrize("elasticapm_client", [{"span_stack_trace_min_duration": 0}], indirect=True)
+def test_transaction_span_stack_trace_min_duration_no_limit(elasticapm_client):
     elasticapm_client.begin_transaction("test_type")
     with elasticapm.capture_span("frames"):
         pass
@@ -261,8 +261,8 @@ def test_transaction_span_frames_min_duration_no_limit(elasticapm_client):
     assert spans[1]["stacktrace"] is not None
 
 
-def test_transaction_span_frames_min_duration_dynamic(elasticapm_client):
-    elasticapm_client.config.update(version="1", span_frames_min_duration=20)
+def test_transaction_span_stack_trace_min_duration_dynamic(elasticapm_client):
+    elasticapm_client.config.update(version="1", span_stack_trace_min_duration=20)
     elasticapm_client.begin_transaction("test_type")
     with elasticapm.capture_span("noframes", duration=0.001):
         pass
@@ -279,7 +279,7 @@ def test_transaction_span_frames_min_duration_dynamic(elasticapm_client):
     assert spans[1]["name"] == "frames"
     assert spans[1]["stacktrace"] is not None
 
-    elasticapm_client.config.update(version="1", span_frames_min_duration=-1)
+    elasticapm_client.config.update(version="1", span_stack_trace_min_duration=0)
     elasticapm_client.begin_transaction("test_type")
     with elasticapm.capture_span("frames"):
         pass
@@ -297,6 +297,46 @@ def test_transaction_span_frames_min_duration_dynamic(elasticapm_client):
     assert spans[3]["stacktrace"] is not None
 
 
+def test_transaction_span_stack_trace_min_duration_overrides_old_config(elasticapm_client):
+    """
+    span_stack_trace_min_duration overrides span_frames_min_duration (which is deprecated)
+    """
+    elasticapm_client.config.update(version="1", span_stack_trace_min_duration=20, span_frames_min_duration=1)
+    elasticapm_client.begin_transaction("test_type")
+    with elasticapm.capture_span("noframes", duration=0.01):
+        pass
+    with elasticapm.capture_span("frames", duration=0.04):
+        pass
+    elasticapm_client.end_transaction("test")
+
+    spans = elasticapm_client.events[constants.SPAN]
+
+    assert len(spans) == 2
+    assert spans[0]["name"] == "noframes"
+    assert "stacktrace" not in spans[0]
+
+    assert spans[1]["name"] == "frames"
+    assert spans[1]["stacktrace"] is not None
+
+    # Set span_stack_trace_min_duration to default so it picks up the non-default span_frames_min_duration
+    elasticapm_client.config.update(version="1", span_stack_trace_min_duration=5, span_frames_min_duration=1)
+    elasticapm_client.begin_transaction("test_type")
+    with elasticapm.capture_span("yesframes", duration=0.01):
+        pass
+    with elasticapm.capture_span("frames", duration=0.04):
+        pass
+    elasticapm_client.end_transaction("test")
+
+    spans = elasticapm_client.events[constants.SPAN]
+
+    assert len(spans) == 4
+    assert spans[2]["name"] == "yesframes"
+    assert spans[2]["stacktrace"] is not None
+
+    assert spans[3]["name"] == "frames"
+    assert spans[3]["stacktrace"] is not None
+
+
 def test_transaction_keyword_truncation(elasticapm_client):
     too_long = "x" * (constants.KEYWORD_MAX_LENGTH + 1)
     expected = encoding.keyword_field(too_long)
diff --git a/tests/contrib/django/fixtures.py b/tests/contrib/django/fixtures.py
--- a/tests/contrib/django/fixtures.py
+++ b/tests/contrib/django/fixtures.py
@@ -56,7 +56,7 @@ def django_elasticapm_client(request):
     client_config = getattr(request, "param", {})
     client_config.setdefault("service_name", "app")
     client_config.setdefault("secret_token", "secret")
-    client_config.setdefault("span_frames_min_duration", -1)
+    client_config.setdefault("span_stack_trace_min_duration", 0)
     client_config.setdefault("span_compression_exact_match_max_duration", "0ms")
     client_config.setdefault("span_compression_same_kind_max_duration", "0ms")
     app = apps.get_app_config("elasticapm")
@@ -85,7 +85,7 @@ def django_sending_elasticapm_client(request, validating_httpserver):
     client_config.setdefault("service_name", "app")
     client_config.setdefault("secret_token", "secret")
     client_config.setdefault("transport_class", "elasticapm.transport.http.Transport")
-    client_config.setdefault("span_frames_min_duration", -1)
+    client_config.setdefault("span_stack_trace_min_duration", 0)
     client_config.setdefault("span_compression_exact_match_max_duration", "0ms")
     client_config.setdefault("span_compression_same_kind_max_duration", "0ms")
     client_config.setdefault("exit_span_min_duration", "0ms")
diff --git a/tests/fixtures.py b/tests/fixtures.py
--- a/tests/fixtures.py
+++ b/tests/fixtures.py
@@ -186,7 +186,7 @@ def elasticapm_client(request):
     client_config.setdefault("secret_token", "test_key")
     client_config.setdefault("central_config", "false")
     client_config.setdefault("include_paths", ("*/tests/*",))
-    client_config.setdefault("span_frames_min_duration", -1)
+    client_config.setdefault("span_stack_trace_min_duration", 0)
     client_config.setdefault("metrics_interval", "0ms")
     client_config.setdefault("cloud_provider", False)
     client_config.setdefault("span_compression_exact_match_max_duration", "0ms")
@@ -220,7 +220,7 @@ def elasticapm_client_log_file(request):
     client_config.setdefault("secret_token", "test_key")
     client_config.setdefault("central_config", "false")
     client_config.setdefault("include_paths", ("*/tests/*",))
-    client_config.setdefault("span_frames_min_duration", -1)
+    client_config.setdefault("span_stack_trace_min_duration", 0)
     client_config.setdefault("span_compression_exact_match_max_duration", "0ms")
     client_config.setdefault("span_compression_same_kind_max_duration", "0ms")
     client_config.setdefault("metrics_interval", "0ms")
@@ -303,7 +303,7 @@ def sending_elasticapm_client(request, validating_httpserver):
     client_config.setdefault("service_name", "myapp")
     client_config.setdefault("secret_token", "test_key")
     client_config.setdefault("transport_class", "elasticapm.transport.http.Transport")
-    client_config.setdefault("span_frames_min_duration", -1)
+    client_config.setdefault("span_stack_trace_min_duration", 0)
     client_config.setdefault("span_compression_exact_match_max_duration", "0ms")
     client_config.setdefault("span_compression_same_kind_max_duration", "0ms")
     client_config.setdefault("include_paths", ("*/tests/*",))
diff --git a/tests/utils/stacks/tests.py b/tests/utils/stacks/tests.py
--- a/tests/utils/stacks/tests.py
+++ b/tests/utils/stacks/tests.py
@@ -144,7 +144,7 @@ def test_iter_stack_frames_max_frames():
 
 
 @pytest.mark.parametrize(
-    "elasticapm_client", [{"stack_trace_limit": 10, "span_frames_min_duration": -1}], indirect=True
+    "elasticapm_client", [{"stack_trace_limit": 10, "span_stack_trace_min_duration": 0}], indirect=True
 )
 def test_iter_stack_frames_max_frames_is_dynamic(elasticapm_client):
     def func():