Repository: elastic/apm-agent-python

Issue:
[META 545] Drop unsampled transactions when connected to APM Server 8.0+
[![issue details](https://img.shields.io/endpoint?label=meta-issue:&url=https%3A%2F%2Fgiss.app.elstc.co%2Fapi%2Fstatus%2Felastic%2Fapm%2F545)](https://github.com/elastic/apm/issues/545)

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

Drop unsampled transactions when the agent is connected to an APM Server of version 8.0 or higher.


Discussion:

Resolving patch:
diff --git a/elasticapm/base.py b/elasticapm/base.py
--- a/elasticapm/base.py
+++ b/elasticapm/base.py
@@ -635,7 +635,9 @@ def check_python_version(self):
         elif v < (3, 5):
             warnings.warn("The Elastic APM agent only supports Python 3.5+", DeprecationWarning)
 
-    def check_server_version(self, gte: Optional[Tuple[int]] = None, lte: Optional[Tuple[int]] = None) -> bool:
+    def check_server_version(
+        self, gte: Optional[Tuple[int, ...]] = None, lte: Optional[Tuple[int, ...]] = None
+    ) -> bool:
         """
         Check APM Server version against greater-or-equal and/or lower-or-equal limits, provided as tuples of integers.
         If server_version is not set, always returns True.
diff --git a/elasticapm/traces.py b/elasticapm/traces.py
--- a/elasticapm/traces.py
+++ b/elasticapm/traces.py
@@ -36,7 +36,7 @@
 import timeit
 from collections import defaultdict
 from types import TracebackType
-from typing import Any, Callable, Dict, Optional, Tuple, Type, Union
+from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Tuple, Type, Union
 
 from elasticapm.conf import constants
 from elasticapm.conf.constants import LABEL_RE, SPAN, TRANSACTION
@@ -60,6 +60,10 @@
 SpanType = Union["Span", "DroppedSpan"]
 
 
+if TYPE_CHECKING:
+    import elasticapm.Client
+
+
 class ChildDuration(object):
     __slots__ = ("obj", "_nesting_level", "_start", "_duration", "_lock")
 
@@ -755,7 +759,7 @@ def action(self):
 
 
 class Tracer(object):
-    def __init__(self, frames_collector_func, frames_processing_func, queue_func, config, agent):
+    def __init__(self, frames_collector_func, frames_processing_func, queue_func, config, agent: "elasticapm.Client"):
         self.config = config
         self.queue_func = queue_func
         self.frames_processing_func = frames_processing_func
@@ -820,6 +824,8 @@ def end_transaction(self, result=None, transaction_name=None, duration=None):
             transaction.end(duration=duration)
             if self._should_ignore(transaction.name):
                 return
+            if not transaction.is_sampled and self._agent.check_server_version(gte=(8, 0)):
+                return
             if transaction.result is None:
                 transaction.result = result
             self.queue_func(TRANSACTION, transaction.to_dict())


Test patch:
diff --git a/tests/client/client_tests.py b/tests/client/client_tests.py
--- a/tests/client/client_tests.py
+++ b/tests/client/client_tests.py
@@ -347,31 +347,6 @@ def test_server_cert_pinning(sending_elasticapm_client):
     assert sending_elasticapm_client._transport._server_cert == DEFAULT_CERTIFICATE
 
 
-@pytest.mark.parametrize(
-    "elasticapm_client", [{"transactions_ignore_patterns": ["^OPTIONS", "views.api.v2"]}], indirect=True
-)
-def test_ignore_patterns(elasticapm_client):
-    elasticapm_client.begin_transaction("web")
-    elasticapm_client.end_transaction("OPTIONS views.healthcheck", 200)
-
-    elasticapm_client.begin_transaction("web")
-    elasticapm_client.end_transaction("GET views.users", 200)
-
-    transactions = elasticapm_client.events[TRANSACTION]
-
-    assert len(transactions) == 1
-    assert transactions[0]["name"] == "GET views.users"
-
-
-@pytest.mark.parametrize(
-    "elasticapm_client", [{"transactions_ignore_patterns": ["^OPTIONS", "views.api.v2"]}], indirect=True
-)
-def test_ignore_patterns_with_none_transaction_name(elasticapm_client):
-    elasticapm_client.begin_transaction("web")
-    t = elasticapm_client.end_transaction(None, 200)
-    assert t.name == ""
-
-
 @pytest.mark.parametrize(
     "setting,url,result",
     [
@@ -417,260 +392,6 @@ def test_empty_transport_disables_send():
     client.close()
 
 
-@pytest.mark.parametrize(
-    "elasticapm_client",
-    [
-        {"collect_local_variables": "errors"},
-        {"collect_local_variables": "transactions", "local_var_max_length": 20, "local_var_max_list_length": 10},
-        {"collect_local_variables": "all", "local_var_max_length": 20, "local_var_max_list_length": 10},
-        {"collect_local_variables": "something"},
-    ],
-    indirect=True,
-)
-def test_collect_local_variables_transactions(elasticapm_client):
-    mode = elasticapm_client.config.collect_local_variables
-    elasticapm_client.begin_transaction("test")
-    with elasticapm.capture_span("foo"):
-        a_local_var = 1
-        a_long_local_var = 100 * "a"
-        a_long_local_list = list(range(100))
-        pass
-    elasticapm_client.end_transaction("test", "ok")
-    frame = elasticapm_client.events[SPAN][0]["stacktrace"][0]
-    if mode in ("transactions", "all"):
-        assert "vars" in frame, mode
-        assert frame["vars"]["a_local_var"] == 1
-        assert len(frame["vars"]["a_long_local_var"]) == 20
-        assert len(frame["vars"]["a_long_local_list"]) == 12
-        assert frame["vars"]["a_long_local_list"][-1] == "(90 more elements)"
-    else:
-        assert "vars" not in frame, mode
-
-
-@pytest.mark.parametrize(
-    "elasticapm_client",
-    [
-        {"source_lines_span_library_frames": 0, "source_lines_span_app_frames": 0},
-        {"source_lines_span_library_frames": 1, "source_lines_span_app_frames": 1},
-        {"source_lines_span_library_frames": 7, "source_lines_span_app_frames": 5},
-    ],
-    indirect=True,
-)
-def test_collect_source_transactions(elasticapm_client):
-    library_frame_context = elasticapm_client.config.source_lines_span_library_frames
-    in_app_frame_context = elasticapm_client.config.source_lines_span_app_frames
-    elasticapm_client.begin_transaction("test")
-    with elasticapm.capture_span("foo"):
-        pass
-    elasticapm_client.end_transaction("test", "ok")
-    span = elasticapm_client.events[SPAN][0]
-    in_app_frame = span["stacktrace"][0]
-    library_frame = span["stacktrace"][1]
-    assert not in_app_frame["library_frame"]
-    assert library_frame["library_frame"]
-    if library_frame_context:
-        assert "context_line" in library_frame, library_frame_context
-        assert "pre_context" in library_frame, library_frame_context
-        assert "post_context" in library_frame, library_frame_context
-        lines = len([library_frame["context_line"]] + library_frame["pre_context"] + library_frame["post_context"])
-        assert lines == library_frame_context, library_frame_context
-    else:
-        assert "context_line" not in library_frame, library_frame_context
-        assert "pre_context" not in library_frame, library_frame_context
-        assert "post_context" not in library_frame, library_frame_context
-    if in_app_frame_context:
-        assert "context_line" in in_app_frame, in_app_frame_context
-        assert "pre_context" in in_app_frame, in_app_frame_context
-        assert "post_context" in in_app_frame, in_app_frame_context
-        lines = len([in_app_frame["context_line"]] + in_app_frame["pre_context"] + in_app_frame["post_context"])
-        assert lines == in_app_frame_context, (in_app_frame_context, in_app_frame["lineno"])
-    else:
-        assert "context_line" not in in_app_frame, in_app_frame_context
-        assert "pre_context" not in in_app_frame, in_app_frame_context
-        assert "post_context" not in in_app_frame, in_app_frame_context
-
-
-@pytest.mark.parametrize("elasticapm_client", [{"transaction_sample_rate": 0.4}], indirect=True)
-def test_transaction_sampling(elasticapm_client, not_so_random):
-    for i in range(10):
-        elasticapm_client.begin_transaction("test_type")
-        with elasticapm.capture_span("xyz"):
-            pass
-        elasticapm_client.end_transaction("test")
-
-    transactions = elasticapm_client.events[TRANSACTION]
-    spans_per_transaction = defaultdict(list)
-    for span in elasticapm_client.events[SPAN]:
-        spans_per_transaction[span["transaction_id"]].append(span)
-
-    # seed is fixed by not_so_random fixture
-    assert len([t for t in transactions if t["sampled"]]) == 3
-    for transaction in transactions:
-        assert transaction["sampled"] or not transaction["id"] in spans_per_transaction
-        assert transaction["sampled"] or not "context" in transaction
-        assert transaction["sample_rate"] == 0 if not transaction["sampled"] else transaction["sample_rate"] == 0.4
-
-
-def test_transaction_sample_rate_dynamic(elasticapm_client, not_so_random):
-    elasticapm_client.config.update(version="1", transaction_sample_rate=0.4)
-    for i in range(10):
-        elasticapm_client.begin_transaction("test_type")
-        with elasticapm.capture_span("xyz"):
-            pass
-        elasticapm_client.end_transaction("test")
-
-    transactions = elasticapm_client.events[TRANSACTION]
-    spans_per_transaction = defaultdict(list)
-    for span in elasticapm_client.events[SPAN]:
-        spans_per_transaction[span["transaction_id"]].append(span)
-
-    # seed is fixed by not_so_random fixture
-    assert len([t for t in transactions if t["sampled"]]) == 3
-    for transaction in transactions:
-        assert transaction["sampled"] or not transaction["id"] in spans_per_transaction
-        assert transaction["sampled"] or not "context" in transaction
-
-    elasticapm_client.config.update(version="1", transaction_sample_rate=1.0)
-    for i in range(5):
-        elasticapm_client.begin_transaction("test_type")
-        with elasticapm.capture_span("xyz"):
-            pass
-        elasticapm_client.end_transaction("test")
-
-    transactions = elasticapm_client.events[TRANSACTION]
-
-    # seed is fixed by not_so_random fixture
-    assert len([t for t in transactions if t["sampled"]]) == 8
-
-
-def test_transaction_max_spans_dynamic(elasticapm_client):
-    elasticapm_client.config.update(version=1, transaction_max_spans=1)
-    elasticapm_client.begin_transaction("test_type")
-    for i in range(5):
-        with elasticapm.capture_span("span"):
-            pass
-    elasticapm_client.end_transaction("test")
-    transaction = elasticapm_client.events[TRANSACTION][0]
-    spans = elasticapm_client.spans_for_transaction(transaction)
-    assert len(spans) == 1
-
-    elasticapm_client.config.update(version=2, transaction_max_spans=3)
-    elasticapm_client.begin_transaction("test_type")
-    for i in range(5):
-        with elasticapm.capture_span("span"):
-            pass
-
-    elasticapm_client.end_transaction("test")
-    transaction = elasticapm_client.events[TRANSACTION][1]
-    spans = elasticapm_client.spans_for_transaction(transaction)
-    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):
-    elasticapm_client.begin_transaction("test_type")
-    with elasticapm.capture_span("noframes", duration=0.001):
-        pass
-    with elasticapm.capture_span("frames", duration=0.04):
-        pass
-    elasticapm_client.end_transaction("test")
-
-    spans = elasticapm_client.events[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
-
-
-@pytest.mark.parametrize("elasticapm_client", [{"span_frames_min_durarion_ms": -1}], indirect=True)
-def test_transaction_span_frames_min_duration_no_limit(elasticapm_client):
-    elasticapm_client.begin_transaction("test_type")
-    with elasticapm.capture_span("frames"):
-        pass
-    with elasticapm.capture_span("frames", duration=0.04):
-        pass
-    elasticapm_client.end_transaction("test")
-
-    spans = elasticapm_client.events[SPAN]
-
-    assert len(spans) == 2
-    assert spans[0]["name"] == "frames"
-    assert spans[0]["stacktrace"] is not None
-
-    assert spans[1]["name"] == "frames"
-    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)
-    elasticapm_client.begin_transaction("test_type")
-    with elasticapm.capture_span("noframes", duration=0.001):
-        pass
-    with elasticapm.capture_span("frames", duration=0.04):
-        pass
-    elasticapm_client.end_transaction("test")
-
-    spans = elasticapm_client.events[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
-
-    elasticapm_client.config.update(version="1", span_frames_min_duration=-1)
-    elasticapm_client.begin_transaction("test_type")
-    with elasticapm.capture_span("frames"):
-        pass
-    with elasticapm.capture_span("frames", duration=0.04):
-        pass
-    elasticapm_client.end_transaction("test")
-
-    spans = elasticapm_client.events[SPAN]
-
-    assert len(spans) == 4
-    assert spans[2]["name"] == "frames"
-    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" * (KEYWORD_MAX_LENGTH + 1)
-    expected = encoding.keyword_field(too_long)
-    assert too_long != expected
-    assert len(expected) == KEYWORD_MAX_LENGTH
-    assert expected[-1] != "x"
-    elasticapm_client.begin_transaction(too_long)
-    elasticapm.label(val=too_long)
-    elasticapm.set_user_context(username=too_long, email=too_long, user_id=too_long)
-    with elasticapm.capture_span(name=too_long, span_type=too_long):
-        pass
-    elasticapm_client.end_transaction(too_long, too_long)
-    elasticapm_client.close()
-
-    span = elasticapm_client.events["span"][0]
-    transaction = elasticapm_client.events["transaction"][0]
-
-    assert transaction["name"] == expected
-    assert transaction["type"] == expected
-    assert transaction["result"] == expected
-
-    assert transaction["context"]["user"]["id"] == expected
-    assert transaction["context"]["user"]["username"] == expected
-    assert transaction["context"]["user"]["email"] == expected
-
-    assert transaction["context"]["tags"]["val"] == expected
-
-    assert span["type"] == expected
-    assert span["name"] == expected
-
-
 @pytest.mark.parametrize("sending_elasticapm_client", [{"service_name": "*"}], indirect=True)
 @mock.patch("elasticapm.transport.base.Transport.send")
 def test_config_error_stops_error_send(mock_send, sending_elasticapm_client):
@@ -689,62 +410,6 @@ def test_config_error_stops_transaction_send(mock_send, sending_elasticapm_clien
     assert mock_send.call_count == 0
 
 
-def test_trace_parent(elasticapm_client):
-    trace_parent = TraceParent.from_string("00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-03")
-    elasticapm_client.begin_transaction("test", trace_parent=trace_parent)
-    transaction = elasticapm_client.end_transaction("test", "OK")
-    data = transaction.to_dict()
-    assert data["trace_id"] == "0af7651916cd43dd8448eb211c80319c"
-    assert data["parent_id"] == "b7ad6b7169203331"
-
-
-def test_sample_rate_in_dict(elasticapm_client):
-    trace_parent = TraceParent.from_string(
-        "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-03", tracestate_string="es=s:0.43"
-    )
-    elasticapm_client.begin_transaction("test", trace_parent=trace_parent)
-    with elasticapm.capture_span("x"):
-        pass
-    transaction = elasticapm_client.end_transaction("test", "OK")
-    data = transaction.to_dict()
-    assert data["sample_rate"] == 0.43
-    assert elasticapm_client.events[SPAN][0]["sample_rate"] == 0.43
-
-
-def test_sample_rate_undefined_by_parent(elasticapm_client):
-    trace_parent = TraceParent.from_string("00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-03")
-    elasticapm_client.begin_transaction("test", trace_parent=trace_parent)
-    with elasticapm.capture_span("x"):
-        pass
-    transaction = elasticapm_client.end_transaction("test", "OK")
-    data = transaction.to_dict()
-    assert "sample_rate" not in data
-    assert "sample_rate" not in elasticapm_client.events[SPAN][0]
-
-
-def test_trace_parent_not_set(elasticapm_client):
-    elasticapm_client.begin_transaction("test", trace_parent=None)
-    transaction = elasticapm_client.end_transaction("test", "OK")
-    data = transaction.to_dict()
-    assert data["trace_id"] is not None
-    assert "parent_id" not in data
-
-
-def test_ensure_parent_sets_new_id(elasticapm_client):
-    transaction = elasticapm_client.begin_transaction("test", trace_parent=None)
-    assert transaction.id == transaction.trace_parent.span_id
-    span_id = transaction.ensure_parent_id()
-    assert span_id == transaction.trace_parent.span_id
-
-
-def test_ensure_parent_doesnt_change_existing_id(elasticapm_client):
-    transaction = elasticapm_client.begin_transaction("test", trace_parent=None)
-    assert transaction.id == transaction.trace_parent.span_id
-    span_id = transaction.ensure_parent_id()
-    span_id_2 = transaction.ensure_parent_id()
-    assert span_id == span_id_2
-
-
 @pytest.mark.parametrize(
     "elasticapm_client,expected",
     [
@@ -862,13 +527,6 @@ def test_check_server_version(elasticapm_client):
     assert not elasticapm_client.check_server_version(gte=(7, 16), lte=(7, 18))
 
 
-def test_backdating_transaction(elasticapm_client):
-    elasticapm_client.begin_transaction("test", start=time.time() - 1)
-    elasticapm_client.end_transaction()
-    transaction = elasticapm_client.events[TRANSACTION][0]
-    assert 1000 < transaction["duration"] < 2000
-
-
 @pytest.mark.parametrize(
     "elasticapm_client,expected",
     [
diff --git a/tests/client/transaction_tests.py b/tests/client/transaction_tests.py
new file mode 100644
--- /dev/null
+++ b/tests/client/transaction_tests.py
@@ -0,0 +1,391 @@
+#  BSD 3-Clause License
+#
+#  Copyright (c) 2022, 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.
+import time
+from collections import defaultdict
+
+import pytest
+
+import elasticapm
+from elasticapm.conf import constants
+from elasticapm.utils import encoding
+from elasticapm.utils.disttracing import TraceParent
+
+
+@pytest.mark.parametrize(
+    "elasticapm_client", [{"transactions_ignore_patterns": ["^OPTIONS", "views.api.v2"]}], indirect=True
+)
+def test_ignore_patterns(elasticapm_client):
+    elasticapm_client.begin_transaction("web")
+    elasticapm_client.end_transaction("OPTIONS views.healthcheck", 200)
+
+    elasticapm_client.begin_transaction("web")
+    elasticapm_client.end_transaction("GET views.users", 200)
+
+    transactions = elasticapm_client.events[constants.TRANSACTION]
+
+    assert len(transactions) == 1
+    assert transactions[0]["name"] == "GET views.users"
+
+
+@pytest.mark.parametrize(
+    "elasticapm_client", [{"transactions_ignore_patterns": ["^OPTIONS", "views.api.v2"]}], indirect=True
+)
+def test_ignore_patterns_with_none_transaction_name(elasticapm_client):
+    elasticapm_client.begin_transaction("web")
+    t = elasticapm_client.end_transaction(None, 200)
+    assert t.name == ""
+
+
+@pytest.mark.parametrize(
+    "elasticapm_client",
+    [
+        {"collect_local_variables": "errors"},
+        {"collect_local_variables": "transactions", "local_var_max_length": 20, "local_var_max_list_length": 10},
+        {"collect_local_variables": "all", "local_var_max_length": 20, "local_var_max_list_length": 10},
+        {"collect_local_variables": "something"},
+    ],
+    indirect=True,
+)
+def test_collect_local_variables_transactions(elasticapm_client):
+    mode = elasticapm_client.config.collect_local_variables
+    elasticapm_client.begin_transaction("test")
+    with elasticapm.capture_span("foo"):
+        a_local_var = 1
+        a_long_local_var = 100 * "a"
+        a_long_local_list = list(range(100))
+        pass
+    elasticapm_client.end_transaction("test", "ok")
+    frame = elasticapm_client.events[constants.SPAN][0]["stacktrace"][0]
+    if mode in ("transactions", "all"):
+        assert "vars" in frame, mode
+        assert frame["vars"]["a_local_var"] == 1
+        assert len(frame["vars"]["a_long_local_var"]) == 20
+        assert len(frame["vars"]["a_long_local_list"]) == 12
+        assert frame["vars"]["a_long_local_list"][-1] == "(90 more elements)"
+    else:
+        assert "vars" not in frame, mode
+
+
+@pytest.mark.parametrize(
+    "elasticapm_client",
+    [
+        {"source_lines_span_library_frames": 0, "source_lines_span_app_frames": 0},
+        {"source_lines_span_library_frames": 1, "source_lines_span_app_frames": 1},
+        {"source_lines_span_library_frames": 7, "source_lines_span_app_frames": 5},
+    ],
+    indirect=True,
+)
+def test_collect_source_transactions(elasticapm_client):
+    library_frame_context = elasticapm_client.config.source_lines_span_library_frames
+    in_app_frame_context = elasticapm_client.config.source_lines_span_app_frames
+    elasticapm_client.begin_transaction("test")
+    with elasticapm.capture_span("foo"):
+        pass
+    elasticapm_client.end_transaction("test", "ok")
+    span = elasticapm_client.events[constants.SPAN][0]
+    in_app_frame = span["stacktrace"][0]
+    library_frame = span["stacktrace"][1]
+    assert not in_app_frame["library_frame"]
+    assert library_frame["library_frame"]
+    if library_frame_context:
+        assert "context_line" in library_frame, library_frame_context
+        assert "pre_context" in library_frame, library_frame_context
+        assert "post_context" in library_frame, library_frame_context
+        lines = len([library_frame["context_line"]] + library_frame["pre_context"] + library_frame["post_context"])
+        assert lines == library_frame_context, library_frame_context
+    else:
+        assert "context_line" not in library_frame, library_frame_context
+        assert "pre_context" not in library_frame, library_frame_context
+        assert "post_context" not in library_frame, library_frame_context
+    if in_app_frame_context:
+        assert "context_line" in in_app_frame, in_app_frame_context
+        assert "pre_context" in in_app_frame, in_app_frame_context
+        assert "post_context" in in_app_frame, in_app_frame_context
+        lines = len([in_app_frame["context_line"]] + in_app_frame["pre_context"] + in_app_frame["post_context"])
+        assert lines == in_app_frame_context, (in_app_frame_context, in_app_frame["lineno"])
+    else:
+        assert "context_line" not in in_app_frame, in_app_frame_context
+        assert "pre_context" not in in_app_frame, in_app_frame_context
+        assert "post_context" not in in_app_frame, in_app_frame_context
+
+
+@pytest.mark.parametrize(
+    "elasticapm_client",
+    [
+        {"transaction_sample_rate": 0.4, "server_version": (7, 14)},
+        {"transaction_sample_rate": 0.4, "server_version": (8, 0)},
+    ],
+    indirect=True,
+)
+def test_transaction_sampling(elasticapm_client, not_so_random):
+    for i in range(10):
+        elasticapm_client.begin_transaction("test_type")
+        with elasticapm.capture_span("xyz"):
+            pass
+        elasticapm_client.end_transaction("test")
+
+    transactions = elasticapm_client.events[constants.TRANSACTION]
+    spans_per_transaction = defaultdict(list)
+    for span in elasticapm_client.events[constants.SPAN]:
+        spans_per_transaction[span["transaction_id"]].append(span)
+
+    # seed is fixed by not_so_random fixture
+    assert len([t for t in transactions if t["sampled"]]) == 3
+    if elasticapm_client.server_version < (8, 0):
+        assert len(transactions) == 10
+    else:
+        assert len(transactions) == 3
+    for transaction in transactions:
+        assert transaction["sampled"] or not transaction["id"] in spans_per_transaction
+        assert transaction["sampled"] or not "context" in transaction
+        assert transaction["sample_rate"] == 0 if not transaction["sampled"] else transaction["sample_rate"] == 0.4
+
+
+def test_transaction_sample_rate_dynamic(elasticapm_client, not_so_random):
+    elasticapm_client.config.update(version="1", transaction_sample_rate=0.4)
+    for i in range(10):
+        elasticapm_client.begin_transaction("test_type")
+        with elasticapm.capture_span("xyz"):
+            pass
+        elasticapm_client.end_transaction("test")
+
+    transactions = elasticapm_client.events[constants.TRANSACTION]
+    spans_per_transaction = defaultdict(list)
+    for span in elasticapm_client.events[constants.SPAN]:
+        spans_per_transaction[span["transaction_id"]].append(span)
+
+    # seed is fixed by not_so_random fixture
+    assert len([t for t in transactions if t["sampled"]]) == 3
+    for transaction in transactions:
+        assert transaction["sampled"] or not transaction["id"] in spans_per_transaction
+        assert transaction["sampled"] or not "context" in transaction
+
+    elasticapm_client.config.update(version="1", transaction_sample_rate=1.0)
+    for i in range(5):
+        elasticapm_client.begin_transaction("test_type")
+        with elasticapm.capture_span("xyz"):
+            pass
+        elasticapm_client.end_transaction("test")
+
+    transactions = elasticapm_client.events[constants.TRANSACTION]
+
+    # seed is fixed by not_so_random fixture
+    assert len([t for t in transactions if t["sampled"]]) == 8
+
+
+def test_transaction_max_spans_dynamic(elasticapm_client):
+    elasticapm_client.config.update(version=1, transaction_max_spans=1)
+    elasticapm_client.begin_transaction("test_type")
+    for i in range(5):
+        with elasticapm.capture_span("span"):
+            pass
+    elasticapm_client.end_transaction("test")
+    transaction = elasticapm_client.events[constants.TRANSACTION][0]
+    spans = elasticapm_client.spans_for_transaction(transaction)
+    assert len(spans) == 1
+
+    elasticapm_client.config.update(version=2, transaction_max_spans=3)
+    elasticapm_client.begin_transaction("test_type")
+    for i in range(5):
+        with elasticapm.capture_span("span"):
+            pass
+
+    elasticapm_client.end_transaction("test")
+    transaction = elasticapm_client.events[constants.TRANSACTION][1]
+    spans = elasticapm_client.spans_for_transaction(transaction)
+    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):
+    elasticapm_client.begin_transaction("test_type")
+    with elasticapm.capture_span("noframes", duration=0.001):
+        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
+
+
+@pytest.mark.parametrize("elasticapm_client", [{"span_frames_min_durarion_ms": -1}], indirect=True)
+def test_transaction_span_frames_min_duration_no_limit(elasticapm_client):
+    elasticapm_client.begin_transaction("test_type")
+    with elasticapm.capture_span("frames"):
+        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"] == "frames"
+    assert spans[0]["stacktrace"] is not None
+
+    assert spans[1]["name"] == "frames"
+    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)
+    elasticapm_client.begin_transaction("test_type")
+    with elasticapm.capture_span("noframes", duration=0.001):
+        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
+
+    elasticapm_client.config.update(version="1", span_frames_min_duration=-1)
+    elasticapm_client.begin_transaction("test_type")
+    with elasticapm.capture_span("frames"):
+        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"] == "frames"
+    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)
+    assert too_long != expected
+    assert len(expected) == constants.KEYWORD_MAX_LENGTH
+    assert expected[-1] != "x"
+    elasticapm_client.begin_transaction(too_long)
+    elasticapm.label(val=too_long)
+    elasticapm.set_user_context(username=too_long, email=too_long, user_id=too_long)
+    with elasticapm.capture_span(name=too_long, span_type=too_long):
+        pass
+    elasticapm_client.end_transaction(too_long, too_long)
+    elasticapm_client.close()
+
+    span = elasticapm_client.events["span"][0]
+    transaction = elasticapm_client.events["transaction"][0]
+
+    assert transaction["name"] == expected
+    assert transaction["type"] == expected
+    assert transaction["result"] == expected
+
+    assert transaction["context"]["user"]["id"] == expected
+    assert transaction["context"]["user"]["username"] == expected
+    assert transaction["context"]["user"]["email"] == expected
+
+    assert transaction["context"]["tags"]["val"] == expected
+
+    assert span["type"] == expected
+    assert span["name"] == expected
+
+
+def test_trace_parent(elasticapm_client):
+    trace_parent = TraceParent.from_string("00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-03")
+    elasticapm_client.begin_transaction("test", trace_parent=trace_parent)
+    transaction = elasticapm_client.end_transaction("test", "OK")
+    data = transaction.to_dict()
+    assert data["trace_id"] == "0af7651916cd43dd8448eb211c80319c"
+    assert data["parent_id"] == "b7ad6b7169203331"
+
+
+def test_sample_rate_in_dict(elasticapm_client):
+    trace_parent = TraceParent.from_string(
+        "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-03", tracestate_string="es=s:0.43"
+    )
+    elasticapm_client.begin_transaction("test", trace_parent=trace_parent)
+    with elasticapm.capture_span("x"):
+        pass
+    transaction = elasticapm_client.end_transaction("test", "OK")
+    data = transaction.to_dict()
+    assert data["sample_rate"] == 0.43
+    assert elasticapm_client.events[constants.SPAN][0]["sample_rate"] == 0.43
+
+
+def test_sample_rate_undefined_by_parent(elasticapm_client):
+    trace_parent = TraceParent.from_string("00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-03")
+    elasticapm_client.begin_transaction("test", trace_parent=trace_parent)
+    with elasticapm.capture_span("x"):
+        pass
+    transaction = elasticapm_client.end_transaction("test", "OK")
+    data = transaction.to_dict()
+    assert "sample_rate" not in data
+    assert "sample_rate" not in elasticapm_client.events[constants.SPAN][0]
+
+
+def test_trace_parent_not_set(elasticapm_client):
+    elasticapm_client.begin_transaction("test", trace_parent=None)
+    transaction = elasticapm_client.end_transaction("test", "OK")
+    data = transaction.to_dict()
+    assert data["trace_id"] is not None
+    assert "parent_id" not in data
+
+
+def test_ensure_parent_sets_new_id(elasticapm_client):
+    transaction = elasticapm_client.begin_transaction("test", trace_parent=None)
+    assert transaction.id == transaction.trace_parent.span_id
+    span_id = transaction.ensure_parent_id()
+    assert span_id == transaction.trace_parent.span_id
+
+
+def test_ensure_parent_doesnt_change_existing_id(elasticapm_client):
+    transaction = elasticapm_client.begin_transaction("test", trace_parent=None)
+    assert transaction.id == transaction.trace_parent.span_id
+    span_id = transaction.ensure_parent_id()
+    span_id_2 = transaction.ensure_parent_id()
+    assert span_id == span_id_2
+
+
+def test_backdating_transaction(elasticapm_client):
+    elasticapm_client.begin_transaction("test", start=time.time() - 1)
+    elasticapm_client.end_transaction()
+    transaction = elasticapm_client.events[constants.TRANSACTION][0]
+    assert 1000 < transaction["duration"] < 2000
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
@@ -1611,6 +1611,9 @@ def test_transaction_name_from_route_doesnt_have_effect_in_older_django(client,
     assert transaction["name"] == "GET tests.contrib.django.testapp.views.no_error"
 
 
+@pytest.mark.parametrize(
+    "django_elasticapm_client", [{"server_version": (7, 14)}], indirect=True
+)  # unsampled transactions are dropped with server 8.0+
 def test_outcome_is_set_for_unsampled_transactions(django_elasticapm_client, client):
     with override_settings(
         TEMPLATES=[
diff --git a/tests/instrumentation/asyncio_tests/aiohttp_client_tests.py b/tests/instrumentation/asyncio_tests/aiohttp_client_tests.py
--- a/tests/instrumentation/asyncio_tests/aiohttp_client_tests.py
+++ b/tests/instrumentation/asyncio_tests/aiohttp_client_tests.py
@@ -156,20 +156,19 @@ async def test_trace_parent_propagation_sampled_headers_none(
             status = resp.status
             text = await resp.text()
     elasticapm_client.end_transaction("MyView")
-    transactions = elasticapm_client.events[constants.TRANSACTION]
-    spans = elasticapm_client.spans_for_transaction(transactions[0])
+    spans = elasticapm_client.events[constants.SPAN]
 
     headers = waiting_httpserver.requests[0].headers
     assert constants.TRACEPARENT_HEADER_NAME in headers
     trace_parent = TraceParent.from_string(
         headers[constants.TRACEPARENT_HEADER_NAME], tracestate_string=headers[constants.TRACESTATE_HEADER_NAME]
     )
-    assert trace_parent.trace_id == transactions[0]["trace_id"]
+    assert trace_parent.trace_id == transaction.trace_parent.trace_id
     if sampled:
         assert trace_parent.span_id == spans[0]["id"]
     else:
         assert trace_parent.tracestate_dict[constants.TRACESTATE.SAMPLE_RATE] == "0"
-        assert trace_parent.span_id == transactions[0]["id"]
+        assert trace_parent.span_id == transaction.id
 
 
 @pytest.mark.parametrize(
@@ -190,8 +189,7 @@ async def test_trace_parent_propagation_unsampled(instrument, event_loop, elasti
             status = resp.status
             text = await resp.text()
     elasticapm_client.end_transaction("MyView")
-    transactions = elasticapm_client.events[constants.TRANSACTION]
-    spans = elasticapm_client.spans_for_transaction(transactions[0])
+    spans = elasticapm_client.events[constants.SPAN]
 
     assert not spans
 
@@ -200,7 +198,7 @@ async def test_trace_parent_propagation_unsampled(instrument, event_loop, elasti
     trace_parent = TraceParent.from_string(
         headers[constants.TRACEPARENT_HEADER_NAME], tracestate_string=headers[constants.TRACESTATE_HEADER_NAME]
     )
-    assert trace_parent.trace_id == transactions[0]["trace_id"]
+    assert trace_parent.trace_id == transaction_object.trace_parent.trace_id
     assert trace_parent.span_id == transaction_object.id
     assert not trace_parent.trace_options.recorded
     # Check that sample_rate was correctly placed in the tracestate
diff --git a/tests/instrumentation/transactions_store_tests.py b/tests/instrumentation/transactions_store_tests.py
--- a/tests/instrumentation/transactions_store_tests.py
+++ b/tests/instrumentation/transactions_store_tests.py
@@ -335,6 +335,9 @@ def test_labels_dedot(elasticapm_client):
     assert transactions[0]["context"]["tags"] == {"d_o_t": "dot", "s_t_a_r": "star", "q_u_o_t_e": "quote"}
 
 
+@pytest.mark.parametrize(
+    "elasticapm_client", [{"server_version": (7, 14)}], indirect=True
+)  # unsampled transactions are dropped with server 8.0+
 def test_dedot_is_not_run_when_unsampled(elasticapm_client):
     for sampled in (True, False):
         t = elasticapm_client.begin_transaction("test")
@@ -414,6 +417,9 @@ def test_set_user_context_merge(elasticapm_client):
     assert transactions[0]["context"]["user"] == {"username": "foo", "email": "foo@example.com", "id": 42}
 
 
+@pytest.mark.parametrize(
+    "elasticapm_client", [{"server_version": (7, 14)}], indirect=True
+)  # unsampled transactions are dropped with server 8.0+
 def test_callable_context_ignored_when_not_sampled(elasticapm_client):
     callable_data = mock.Mock()
     callable_data.return_value = {"a": "b"}
diff --git a/tests/instrumentation/urllib3_tests.py b/tests/instrumentation/urllib3_tests.py
--- a/tests/instrumentation/urllib3_tests.py
+++ b/tests/instrumentation/urllib3_tests.py
@@ -32,7 +32,7 @@
 import urllib3
 
 from elasticapm.conf import constants
-from elasticapm.conf.constants import TRANSACTION
+from elasticapm.conf.constants import SPAN, TRANSACTION
 from elasticapm.traces import capture_span
 from elasticapm.utils.compat import urlparse
 from elasticapm.utils.disttracing import TraceParent
@@ -163,8 +163,7 @@ def test_trace_parent_propagation_unsampled(instrument, elasticapm_client, waiti
     pool = urllib3.PoolManager(timeout=0.1)
     r = pool.request("GET", url)
     elasticapm_client.end_transaction("MyView")
-    transactions = elasticapm_client.events[TRANSACTION]
-    spans = elasticapm_client.spans_for_transaction(transactions[0])
+    spans = elasticapm_client.events[SPAN]
 
     assert not spans
 
@@ -173,7 +172,7 @@ def test_trace_parent_propagation_unsampled(instrument, elasticapm_client, waiti
     trace_parent = TraceParent.from_string(
         headers[constants.TRACEPARENT_HEADER_NAME], tracestate_string=headers[constants.TRACESTATE_HEADER_NAME]
     )
-    assert trace_parent.trace_id == transactions[0]["trace_id"]
+    assert trace_parent.trace_id == transaction_object.trace_parent.trace_id
     assert trace_parent.span_id == transaction_object.id
     assert not trace_parent.trace_options.recorded
     # Check that sample_rate was correctly placed in the tracestate
diff --git a/tests/instrumentation/urllib_tests.py b/tests/instrumentation/urllib_tests.py
--- a/tests/instrumentation/urllib_tests.py
+++ b/tests/instrumentation/urllib_tests.py
@@ -32,7 +32,7 @@
 import pytest
 
 from elasticapm.conf import constants
-from elasticapm.conf.constants import TRANSACTION
+from elasticapm.conf.constants import SPAN, TRANSACTION
 from elasticapm.traces import capture_span
 from elasticapm.utils.compat import urlparse
 from elasticapm.utils.disttracing import TraceParent
@@ -192,8 +192,7 @@ def test_trace_parent_propagation_unsampled(instrument, elasticapm_client, waiti
     transaction_object.is_sampled = False
     urlopen(url)
     elasticapm_client.end_transaction("MyView")
-    transactions = elasticapm_client.events[TRANSACTION]
-    spans = elasticapm_client.spans_for_transaction(transactions[0])
+    spans = elasticapm_client.events[SPAN]
 
     assert not spans
 
@@ -202,7 +201,7 @@ def test_trace_parent_propagation_unsampled(instrument, elasticapm_client, waiti
     trace_parent = TraceParent.from_string(
         headers[constants.TRACEPARENT_HEADER_NAME], tracestate_string=headers[constants.TRACESTATE_HEADER_NAME]
     )
-    assert trace_parent.trace_id == transactions[0]["trace_id"]
+    assert trace_parent.trace_id == transaction_object.trace_parent.trace_id
     assert trace_parent.span_id == transaction_object.id
     assert not trace_parent.trace_options.recorded
     # Check that sample_rate was correctly placed in the tracestate