Repository: getsentry/pytest-sentry (language: python)

Issue:
pytest-sentry should link transactions via trace id
pytest-sentry currently does not link transactions of a pytest session via trace ID. it should to make it easier to find duplicated test setup/teardown


Discussion / hints:

Fix patch:
diff --git a/README.rst b/README.rst
index 75f79eb..eea2d2b 100644
--- a/README.rst
+++ b/README.rst
@@ -55,6 +55,19 @@ By default ``pytest-sentry`` will send `Performance
 * Calls to the test function itself are reported as separate transaction such
   that you can find large, slow tests as well.
 
+* Fixture setup related to a particular test item will be in the same trace,
+  i.e. will have same trace ID. There is no common parent transaction though.
+  It is purposefully dropped to spare quota as it does not contain interesting
+  information::
+
+      pytest.runtest.protocol  [one time, not sent]
+        pytest.fixture.setup [multiple times, sent]
+        pytest.runtest.call [one time, sent]
+
+  The trace is per-test-item. For correlating transactions across an entire
+  test run, use the automatically attached CI tags or attach some tag on your
+  own.
+
 To measure performance data, install ``pytest-sentry`` and set
 ``PYTEST_SENTRY_DSN``, like with errors.
 


Test patch:
diff --git a/pytest_sentry.py b/pytest_sentry.py
index bf117d0..f8bfa5d 100644
--- a/pytest_sentry.py
+++ b/pytest_sentry.py
@@ -8,6 +8,7 @@
 from sentry_sdk.integrations import Integration
 
 from sentry_sdk import Hub, capture_exception
+from sentry_sdk.tracing import Transaction
 from sentry_sdk.scope import add_global_event_processor
 
 _ENVVARS_AS_TAGS = frozenset(
@@ -67,6 +68,14 @@ def procesor(event, hint):
                     continue
                 event.setdefault("tags", {})["pytest_environ.{}".format(key)] = value
 
+            if "exception" in event:
+                for exception in event["exception"]["values"]:
+                    if "stacktrace" in exception:
+                        _process_stacktrace(exception["stacktrace"])
+
+            if "stacktrace" in event:
+                _process_stacktrace(event["stacktrace"])
+
             return event
 
 
@@ -128,12 +137,30 @@ def pytest_load_initial_conftests(early_config, parser, args):
     )
 
 
+def _start_transaction(**kwargs):
+    transaction = Transaction.continue_from_headers(
+        dict(Hub.current.iter_trace_propagation_headers()), **kwargs
+    )
+    transaction.same_process_as_parent = True
+    return sentry_sdk.start_transaction(transaction)
+
+
 @hookwrapper(itemgetter=lambda item: item)
 def pytest_runtest_protocol(item):
-    with sentry_sdk.push_scope() as scope:
-        scope.add_event_processor(_process_event)
+    op = "pytest.runtest.protocol"
+
+    name = item.nodeid
+
+    # We use the full name including parameters because then we can identify
+    # how often a single test has run as part of the same GITHUB_RUN_ID.
+
+    with _start_transaction(op=op, name=u"{} {}".format(op, name)) as tx:
         yield
 
+        # Purposefully drop transaction to spare quota. We only created it to
+        # have a trace_id to correlate by.
+        tx.sampled = False
+
 
 @hookwrapper(itemgetter=lambda item: item)
 def pytest_runtest_call(item):
@@ -144,16 +171,15 @@ def pytest_runtest_call(item):
     # We use the full name including parameters because then we can identify
     # how often a single test has run as part of the same GITHUB_RUN_ID.
 
-    with sentry_sdk.start_transaction(op=op, name=u"{} {}".format(op, name)):
+    with _start_transaction(op=op, name=u"{} {}".format(op, name)):
         yield
 
 
 @hookwrapper(itemgetter=lambda fixturedef, request: request._pyfuncitem)
 def pytest_fixture_setup(fixturedef, request):
     op = "pytest.fixture.setup"
-    with sentry_sdk.start_transaction(
-        op=op, name=u"{} {}".format(op, fixturedef.argname)
-    ):
+    with _start_transaction(op=op, name=u"{} {}".format(op, fixturedef.argname)) as transaction:
+        transaction.set_tag("pytest.fixture.scope", fixturedef.scope)
         yield
 
 
@@ -180,8 +206,20 @@ def pytest_runtest_makereport(item, call):
 
 DEFAULT_HUB = Hub(Client())
 
+_hub_cache = {}
+
 
 def _resolve_hub_marker_value(marker_value):
+    if id(marker_value) not in _hub_cache:
+        _hub_cache[id(marker_value)] = rv = _resolve_hub_marker_value_uncached(
+            marker_value
+        )
+        return rv
+
+    return _hub_cache[id(marker_value)]
+
+
+def _resolve_hub_marker_value_uncached(marker_value):
     if marker_value is None:
         marker_value = DEFAULT_HUB
     else:
@@ -223,18 +261,6 @@ def sentry_test_hub(request):
     return _resolve_hub_marker_value(item.get_closest_marker("sentry_client"))
 
 
-def _process_event(event, hint):
-    if "exception" in event:
-        for exception in event["exception"]["values"]:
-            if "stacktrace" in exception:
-                _process_stacktrace(exception["stacktrace"])
-
-    if "stacktrace" in event:
-        _process_stacktrace(event["stacktrace"])
-
-    return event
-
-
 def _process_stacktrace(stacktrace):
     for frame in stacktrace["frames"]:
         frame["in_app"] = not frame["module"].startswith(
diff --git a/tests/test_tracing.py b/tests/test_tracing.py
index 6454e12..3ad6afe 100644
--- a/tests/test_tracing.py
+++ b/tests/test_tracing.py
@@ -44,3 +44,12 @@ def assert_report():
         test_transaction["transaction"]
         == "pytest.runtest.call tests/test_tracing.py::test_basic"
     )
+
+    assert (
+        fixture_transaction["contexts"]["trace"]["trace_id"]
+        == test_transaction["contexts"]["trace"]["trace_id"]
+    )
+    assert (
+        self_transaction["contexts"]["trace"]["trace_id"]
+        == fixture_transaction["contexts"]["trace"]["trace_id"]
+    )