From 203d4d1f6ba6e6d00cb5c6111bbf2635e57ab1da Mon Sep 17 00:00:00 2001 From: Neel Shah Date: Thu, 30 Oct 2025 14:53:06 +0100 Subject: [PATCH] Add external_propagation_context support If we are on an external tracing system like otel, we allow registering a new source of `trace_id/span_id` that takes precedence over the scope's propagation_context. * Also reworked logs and metrics to use `get_trace_context` * Cleaned up handling of `get_trace_context` that is still messy but a bit more clearer now regarding which underlying `propagation_context` is used --- sentry_sdk/client.py | 39 ++++++++------- sentry_sdk/scope.py | 56 ++++++++++++++++------ tests/integrations/logging/test_logging.py | 4 ++ tests/integrations/loguru/test_loguru.py | 4 ++ tests/test_logs.py | 4 +- tests/test_metrics.py | 16 ++++--- tests/test_scope.py | 46 ++++++++++++++++++ 7 files changed, 126 insertions(+), 43 deletions(-) diff --git a/sentry_sdk/client.py b/sentry_sdk/client.py index c711097d4c..24824e0050 100644 --- a/sentry_sdk/client.py +++ b/sentry_sdk/client.py @@ -928,17 +928,18 @@ def _capture_log(self, log): if release is not None and "sentry.release" not in log["attributes"]: log["attributes"]["sentry.release"] = release - span = current_scope.span - if span is not None and "sentry.trace.parent_span_id" not in log["attributes"]: - log["attributes"]["sentry.trace.parent_span_id"] = span.span_id - - if log.get("trace_id") is None: - transaction = current_scope.transaction - propagation_context = isolation_scope.get_active_propagation_context() - if transaction is not None: - log["trace_id"] = transaction.trace_id - elif propagation_context is not None: - log["trace_id"] = propagation_context.trace_id + trace_context = current_scope.get_trace_context() + trace_id = trace_context.get("trace_id") + span_id = trace_context.get("span_id") + + if trace_id is not None and log.get("trace_id") is None: + log["trace_id"] = trace_id + + if ( + span_id is not None + and "sentry.trace.parent_span_id" not in log["attributes"] + ): + log["attributes"]["sentry.trace.parent_span_id"] = span_id # The user, if present, is always set on the isolation scope. if isolation_scope._user is not None: @@ -977,6 +978,7 @@ def _capture_metric(self, metric): if metric is None: return + current_scope = sentry_sdk.get_current_scope() isolation_scope = sentry_sdk.get_isolation_scope() metric["attributes"]["sentry.sdk.name"] = SDK_INFO["name"] @@ -990,16 +992,13 @@ def _capture_metric(self, metric): if release is not None and "sentry.release" not in metric["attributes"]: metric["attributes"]["sentry.release"] = release - span = sentry_sdk.get_current_span() - metric["trace_id"] = "00000000-0000-0000-0000-000000000000" + trace_context = current_scope.get_trace_context() + trace_id = trace_context.get("trace_id") + span_id = trace_context.get("span_id") - if span: - metric["trace_id"] = span.trace_id - metric["span_id"] = span.span_id - else: - propagation_context = isolation_scope.get_active_propagation_context() - if propagation_context and propagation_context.trace_id: - metric["trace_id"] = propagation_context.trace_id + metric["trace_id"] = trace_id or "00000000-0000-0000-0000-000000000000" + if span_id is not None: + metric["span_id"] = span_id if isolation_scope._user is not None: for metric_attribute, user_attribute in ( diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index ecb8f370c5..8e55add770 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -107,6 +107,10 @@ global_event_processors = [] # type: List[EventProcessor] +# A function returning a (trace_id, span_id) tuple +# from an external tracing source (such as otel) +_external_propagation_context_fn = None # type: Optional[Callable[[], Optional[Tuple[str, str]]]] + class ScopeType(Enum): CURRENT = "current" @@ -142,6 +146,25 @@ def add_global_event_processor(processor): global_event_processors.append(processor) +def register_external_propagation_context(fn): + # type: (Callable[[], Optional[Tuple[str, str]]]) -> None + global _external_propagation_context_fn + _external_propagation_context_fn = fn + + +def remove_external_propagation_context(): + # type: () -> None + global _external_propagation_context_fn + _external_propagation_context_fn = None + + +def get_external_propagation_context(): + # type: () -> Optional[Tuple[str, str]] + return ( + _external_propagation_context_fn() if _external_propagation_context_fn else None + ) + + def _attr_setter(fn): # type: (Any) -> Any return property(fset=fn, doc=fn.__doc__) @@ -562,21 +585,29 @@ def get_baggage(self, *args, **kwargs): return self.get_isolation_scope().get_baggage() def get_trace_context(self): - # type: () -> Any + # type: () -> Dict[str, Any] """ Returns the Sentry "trace" context from the Propagation Context. """ - if self._propagation_context is None: - return None + if has_tracing_enabled(self.get_client().options) and self._span is not None: + return self._span.get_trace_context() - trace_context = { - "trace_id": self._propagation_context.trace_id, - "span_id": self._propagation_context.span_id, - "parent_span_id": self._propagation_context.parent_span_id, - "dynamic_sampling_context": self.get_dynamic_sampling_context(), - } # type: Dict[str, Any] + # if we are tracing externally (otel), those values take precedence + external_propagation_context = get_external_propagation_context() + if external_propagation_context: + trace_id, span_id = external_propagation_context + return {"trace_id": trace_id, "span_id": span_id} - return trace_context + propagation_context = self.get_active_propagation_context() + if propagation_context is None: + return {} + + return { + "trace_id": propagation_context.trace_id, + "span_id": propagation_context.span_id, + "parent_span_id": propagation_context.parent_span_id, + "dynamic_sampling_context": self.get_dynamic_sampling_context(), + } def trace_propagation_meta(self, *args, **kwargs): # type: (*Any, **Any) -> str @@ -1438,10 +1469,7 @@ def _apply_contexts_to_event(self, event, hint, options): # Add "trace" context if contexts.get("trace") is None: - if has_tracing_enabled(options) and self._span is not None: - contexts["trace"] = self._span.get_trace_context() - else: - contexts["trace"] = self.get_trace_context() + contexts["trace"] = self.get_trace_context() def _apply_flags_to_event(self, event, hint, options): # type: (Event, Hint, Optional[Dict[str, Any]]) -> None diff --git a/tests/integrations/logging/test_logging.py b/tests/integrations/logging/test_logging.py index 7a00ceadd2..121025bbb6 100644 --- a/tests/integrations/logging/test_logging.py +++ b/tests/integrations/logging/test_logging.py @@ -478,6 +478,10 @@ def test_logger_with_all_attributes(sentry_init, capture_envelopes): assert attributes.pop("sentry.sdk.name").startswith("sentry.python") + assert "sentry.trace.parent_span_id" in attributes + assert isinstance(attributes["sentry.trace.parent_span_id"], str) + del attributes["sentry.trace.parent_span_id"] + # Assert on the remaining non-dynamic attributes. assert attributes == { "foo": "bar", diff --git a/tests/integrations/loguru/test_loguru.py b/tests/integrations/loguru/test_loguru.py index 3d04d7d1ea..2a307a50cb 100644 --- a/tests/integrations/loguru/test_loguru.py +++ b/tests/integrations/loguru/test_loguru.py @@ -458,6 +458,10 @@ def test_logger_with_all_attributes( assert attributes.pop("sentry.sdk.name").startswith("sentry.python") + assert "sentry.trace.parent_span_id" in attributes + assert isinstance(attributes["sentry.trace.parent_span_id"], str) + del attributes["sentry.trace.parent_span_id"] + # Assert on the remaining non-dynamic attributes. assert attributes == { "logger.name": "tests.integrations.loguru.test_loguru", diff --git a/tests/test_logs.py b/tests/test_logs.py index 7494aa01c8..da7d0d5f03 100644 --- a/tests/test_logs.py +++ b/tests/test_logs.py @@ -310,7 +310,7 @@ def test_logs_tied_to_transactions(sentry_init, capture_envelopes): """ Log messages are also tied to transactions. """ - sentry_init(enable_logs=True) + sentry_init(enable_logs=True, traces_sample_rate=1.0) envelopes = capture_envelopes() with sentry_sdk.start_transaction(name="test-transaction") as trx: @@ -326,7 +326,7 @@ def test_logs_tied_to_spans(sentry_init, capture_envelopes): """ Log messages are also tied to spans. """ - sentry_init(enable_logs=True) + sentry_init(enable_logs=True, traces_sample_rate=1.0) envelopes = capture_envelopes() with sentry_sdk.start_transaction(name="test-transaction"): diff --git a/tests/test_metrics.py b/tests/test_metrics.py index b0e92c9320..8026e17bb6 100644 --- a/tests/test_metrics.py +++ b/tests/test_metrics.py @@ -122,7 +122,7 @@ def test_metrics_with_span(sentry_init, capture_envelopes): sentry_init(traces_sample_rate=1.0) envelopes = capture_envelopes() - with sentry_sdk.start_transaction(op="test", name="test-span"): + with sentry_sdk.start_transaction(op="test", name="test-span") as transaction: sentry_sdk.metrics.count("test.span.counter", 1) get_client().flush() @@ -131,24 +131,26 @@ def test_metrics_with_span(sentry_init, capture_envelopes): assert len(metrics) == 1 assert metrics[0]["trace_id"] is not None - assert metrics[0]["trace_id"] != "00000000-0000-0000-0000-000000000000" - assert metrics[0]["span_id"] is not None + assert metrics[0]["trace_id"] == transaction.trace_id + assert metrics[0]["span_id"] == transaction.span_id def test_metrics_tracing_without_performance(sentry_init, capture_envelopes): sentry_init() envelopes = capture_envelopes() - sentry_sdk.metrics.count("test.span.counter", 1) + with sentry_sdk.isolation_scope() as isolation_scope: + sentry_sdk.metrics.count("test.span.counter", 1) get_client().flush() metrics = envelopes_to_metrics(envelopes) assert len(metrics) == 1 - assert metrics[0]["trace_id"] is not None - assert metrics[0]["trace_id"] != "00000000-0000-0000-0000-000000000000" - assert metrics[0]["span_id"] is None + propagation_context = isolation_scope._propagation_context + assert propagation_context is not None + assert metrics[0]["trace_id"] == propagation_context.trace_id + assert metrics[0]["span_id"] == propagation_context.span_id def test_metrics_before_send(sentry_init, capture_envelopes): diff --git a/tests/test_scope.py b/tests/test_scope.py index 68c93f3036..1ace1cc73c 100644 --- a/tests/test_scope.py +++ b/tests/test_scope.py @@ -16,6 +16,8 @@ use_isolation_scope, use_scope, should_send_default_pii, + register_external_propagation_context, + remove_external_propagation_context, ) @@ -971,3 +973,47 @@ def test_handle_error_on_token_reset_isolation_scope(error_cls, scope_manager): mock_capture.assert_called_once() mock_current_scope.reset.assert_called_once_with(mock_current_token) + + +def test_trace_context_tracing(sentry_init): + sentry_init(traces_sample_rate=1.0) + + with sentry_sdk.start_transaction(name="trx") as transaction: + with sentry_sdk.start_span(op="span1"): + with sentry_sdk.start_span(op="span2") as span: + trace_context = sentry_sdk.get_current_scope().get_trace_context() + + assert trace_context["trace_id"] == transaction.trace_id + assert trace_context["span_id"] == span.span_id + assert trace_context["parent_span_id"] == span.parent_span_id + assert "dynamic_sampling_context" in trace_context + + +def test_trace_context_external_tracing(sentry_init): + sentry_init() + + def external_propagation_context(): + return ("trace_id_foo", "span_id_bar") + + register_external_propagation_context(external_propagation_context) + + trace_context = sentry_sdk.get_current_scope().get_trace_context() + + assert trace_context["trace_id"] == "trace_id_foo" + assert trace_context["span_id"] == "span_id_bar" + + remove_external_propagation_context() + + +def test_trace_context_without_performance(sentry_init): + sentry_init() + + with sentry_sdk.isolation_scope() as isolation_scope: + trace_context = sentry_sdk.get_current_scope().get_trace_context() + + propagation_context = isolation_scope._propagation_context + assert propagation_context is not None + assert trace_context["trace_id"] == propagation_context.trace_id + assert trace_context["span_id"] == propagation_context.span_id + assert trace_context["parent_span_id"] == propagation_context.parent_span_id + assert "dynamic_sampling_context" in trace_context