From 572c0cfba8c6cd2b57c2b3cd3c8b22faa58a3bb6 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Tue, 20 Jan 2026 08:13:23 +0100 Subject: [PATCH] ref: Refactor propagation context --- sentry_sdk/scope.py | 96 ++++++++++++++++++++++++------------- sentry_sdk/tracing.py | 28 ++++++++++- sentry_sdk/tracing_utils.py | 21 +++++++- 3 files changed, 107 insertions(+), 38 deletions(-) diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index 6df26690c8..fe0daa29f6 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -570,40 +570,39 @@ def get_dynamic_sampling_context(self) -> "Optional[Dict[str, str]]": def get_traceparent(self, *args: "Any", **kwargs: "Any") -> "Optional[str]": """ - Returns the Sentry "sentry-trace" header (aka the traceparent) from the - currently active span or the scopes Propagation Context. + Returns the Sentry "sentry-trace" header from the Propagation Context. """ - client = self.get_client() + propagation_context = self.get_active_propagation_context() + + # Get sampled from current span if available (span.sampled may change after entering) + sampled = propagation_context.sampled + if self.span is not None: + sampled = self.span.sampled + + if sampled is True: + sampled_str = "1" + elif sampled is False: + sampled_str = "0" + else: + sampled_str = None - # If we have an active span, return traceparent from there - if has_tracing_enabled(client.options) and self.span is not None: - return self.span.to_traceparent() + traceparent = f"{propagation_context.trace_id}-{propagation_context.span_id}" + if sampled_str is not None: + traceparent += f"-{sampled_str}" - # else return traceparent from the propagation context - return self.get_active_propagation_context().to_traceparent() + return traceparent def get_baggage(self, *args: "Any", **kwargs: "Any") -> "Optional[Baggage]": """ - Returns the Sentry "baggage" header containing trace information from the - currently active span or the scopes Propagation Context. + Returns the Sentry "baggage" header from the Propagation Context. """ - client = self.get_client() - - # If we have an active span, return baggage from there - if has_tracing_enabled(client.options) and self.span is not None: - return self.span.to_baggage() - - # else return baggage from the propagation context return self.get_active_propagation_context().get_baggage() def get_trace_context(self) -> "Dict[str, Any]": """ Returns the Sentry "trace" context from the Propagation Context. """ - if has_tracing_enabled(self.get_client().options) and self._span is not None: - return self._span.get_trace_context() - - # if we are tracing externally (otel), those values take precedence + # External propagation context (OTel) takes precedence external_propagation_context = get_external_propagation_context() if external_propagation_context: trace_id, span_id = external_propagation_context @@ -611,13 +610,37 @@ def get_trace_context(self) -> "Dict[str, Any]": propagation_context = self.get_active_propagation_context() - return { + # Base context from PropagationContext + rv: "Dict[str, Any]" = { "trace_id": propagation_context.trace_id, "span_id": propagation_context.span_id, "parent_span_id": propagation_context.parent_span_id, "dynamic_sampling_context": propagation_context.dynamic_sampling_context, } + # Add additional context from the current span if available + if self.span is not None: + rv["parent_span_id"] = self.span.parent_span_id + rv["op"] = self.span.op + rv["description"] = self.span.description + rv["origin"] = self.span.origin + if self.span.status: + rv["status"] = self.span.status + # Add thread data if available + data = {} + from sentry_sdk.consts import SPANDATA + + thread_id = self.span._data.get(SPANDATA.THREAD_ID) + if thread_id is not None: + data["thread.id"] = thread_id + thread_name = self.span._data.get(SPANDATA.THREAD_NAME) + if thread_name is not None: + data["thread.name"] = thread_name + if data: + rv["data"] = data + + return rv + def trace_propagation_meta(self, *args: "Any", **kwargs: "Any") -> str: """ Return meta tags which should be injected into HTML templates @@ -648,10 +671,7 @@ def iter_trace_propagation_headers( self, *args: "Any", **kwargs: "Any" ) -> "Generator[Tuple[str, str], None, None]": """ - Return HTTP headers which allow propagation of trace data. - - If a span is given, the trace data will taken from the span. - If no span is given, the trace data is taken from the scope. + Return HTTP headers for trace propagation. """ client = self.get_client() if not client.options.get("propagate_traces"): @@ -663,18 +683,26 @@ def iter_trace_propagation_headers( return span = kwargs.pop("span", None) - span = span or self.span - if has_tracing_enabled(client.options) and span is not None: + # When using external propagation (OTel), leave to external propagator + if has_external_propagation_context(): + return + + # If a span is explicitly passed, use that span's headers for backwards compatibility + # This is needed for integrations that create spans but don't use them as context managers + if span is not None: for header in span.iter_headers(): yield header - elif has_external_propagation_context(): - # when we have an external_propagation_context (otlp) - # we leave outgoing propagation to the propagator return - else: - for header in self.get_active_propagation_context().iter_headers(): - yield header + + # Otherwise, use PropagationContext (with sampled from current span if available) + yield SENTRY_TRACE_HEADER_NAME, self.get_traceparent() + + baggage = self.get_baggage() + if baggage is not None: + serialized_baggage = baggage.serialize() + if serialized_baggage: + yield BAGGAGE_HEADER_NAME, serialized_baggage def get_active_propagation_context(self) -> "PropagationContext": if self._propagation_context is not None: diff --git a/sentry_sdk/tracing.py b/sentry_sdk/tracing.py index c4b38e4528..f1a2065784 100644 --- a/sentry_sdk/tracing.py +++ b/sentry_sdk/tracing.py @@ -390,7 +390,20 @@ def __enter__(self) -> "Span": scope = self.scope or sentry_sdk.get_current_scope() old_span = scope.span scope.span = self - self._context_manager_state = (scope, old_span) + + # Sync PropagationContext with the new active span + # Use the same PropagationContext that get_active_propagation_context() would return + propagation_context = scope.get_active_propagation_context() + old_propagation_context_state = ( + propagation_context._trace_id, + propagation_context._span_id, + propagation_context.sampled, + ) + propagation_context._trace_id = self.trace_id + propagation_context._span_id = self.span_id + propagation_context.sampled = self.sampled + + self._context_manager_state = (scope, old_span, propagation_context, old_propagation_context_state) return self def __exit__( @@ -400,11 +413,17 @@ def __exit__( self.set_status(SPANSTATUS.INTERNAL_ERROR) with capture_internal_exceptions(): - scope, old_span = self._context_manager_state + scope, old_span, propagation_context, old_propagation_context_state = self._context_manager_state del self._context_manager_state self.finish(scope) scope.span = old_span + # Restore PropagationContext state + old_trace_id, old_span_id, old_sampled = old_propagation_context_state + propagation_context._trace_id = old_trace_id + propagation_context._span_id = old_span_id + propagation_context.sampled = old_sampled + @property def containing_transaction(self) -> "Optional[Transaction]": """The ``Transaction`` that this span belongs to. @@ -858,6 +877,11 @@ def __enter__(self) -> "Transaction": super().__enter__() + # Sync baggage to PropagationContext + isolation_scope = sentry_sdk.get_isolation_scope() + if isolation_scope._propagation_context is not None: + isolation_scope._propagation_context.baggage = self.get_baggage() + if self._profile is not None: self._profile.__enter__() diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index 742582423b..e64e02cde5 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -405,6 +405,7 @@ class PropagationContext: "_span_id", "parent_span_id", "parent_sampled", + "sampled", "baggage", ) @@ -414,6 +415,7 @@ def __init__( span_id: "Optional[str]" = None, parent_span_id: "Optional[str]" = None, parent_sampled: "Optional[bool]" = None, + sampled: "Optional[bool]" = None, dynamic_sampling_context: "Optional[Dict[str, str]]" = None, baggage: "Optional[Baggage]" = None, ) -> None: @@ -432,6 +434,9 @@ def __init__( Important when the parent span originated in an upstream service, because we want to sample the whole trace, or nothing from the trace.""" + self.sampled = sampled + """Boolean indicator if the current span is sampled.""" + self.baggage = baggage """Parsed baggage header that is used for dynamic sampling decisions.""" @@ -499,7 +504,18 @@ def dynamic_sampling_context(self) -> "Optional[Dict[str, Any]]": return self.get_baggage().dynamic_sampling_context() def to_traceparent(self) -> str: - return f"{self.trace_id}-{self.span_id}" + if self.sampled is True: + sampled = "1" + elif self.sampled is False: + sampled = "0" + else: + sampled = None + + traceparent = f"{self.trace_id}-{self.span_id}" + if sampled is not None: + traceparent += f"-{sampled}" + + return traceparent def get_baggage(self) -> "Baggage": if self.baggage is None: @@ -527,11 +543,12 @@ def update(self, other_dict: "Dict[str, Any]") -> None: pass def __repr__(self) -> str: - return "".format( + return "".format( self._trace_id, self._span_id, self.parent_span_id, self.parent_sampled, + self.sampled, self.baggage, )