From 8cf8584b74cd212ef867f8257d836865fd39ece9 Mon Sep 17 00:00:00 2001 From: Riccardo Magliocchetti Date: Fri, 17 Apr 2026 16:09:19 +0200 Subject: [PATCH 1/7] RuleBased Tracecontext propagator with trace continuation strategy --- .../src/opentelemetry/trace/__init__.py | 4 + .../trace/propagation/__init__.py | 32 ++++ .../trace/propagation/tracecontext.py | 148 ++++++++++++++++++ .../src/opentelemetry/sdk/trace/__init__.py | 7 +- 4 files changed, 190 insertions(+), 1 deletion(-) diff --git a/opentelemetry-api/src/opentelemetry/trace/__init__.py b/opentelemetry-api/src/opentelemetry/trace/__init__.py index 7ec36a21533..65dc5f0117e 100644 --- a/opentelemetry-api/src/opentelemetry/trace/__init__.py +++ b/opentelemetry-api/src/opentelemetry/trace/__init__.py @@ -88,7 +88,9 @@ from opentelemetry.environment_variables import OTEL_PYTHON_TRACER_PROVIDER from opentelemetry.trace.propagation import ( _SPAN_KEY, + get_current_link, get_current_span, + set_link_in_context, set_span_in_context, ) from opentelemetry.trace.span import ( @@ -673,6 +675,8 @@ def use_span( "set_tracer_provider", "set_span_in_context", "use_span", + "get_current_link", + "set_link_in_context", "Status", "StatusCode", ] diff --git a/opentelemetry-api/src/opentelemetry/trace/propagation/__init__.py b/opentelemetry-api/src/opentelemetry/trace/propagation/__init__.py index d3529e1779e..d651d1d7ded 100644 --- a/opentelemetry-api/src/opentelemetry/trace/propagation/__init__.py +++ b/opentelemetry-api/src/opentelemetry/trace/propagation/__init__.py @@ -15,10 +15,12 @@ from opentelemetry.context import create_key, get_value, set_value from opentelemetry.context.context import Context +from opentelemetry.trace import Link from opentelemetry.trace.span import INVALID_SPAN, Span SPAN_KEY = "current-span" _SPAN_KEY = create_key("current-span") +_LINK_KEY = create_key("current-link") def set_span_in_context( @@ -49,3 +51,33 @@ def get_current_span(context: Optional[Context] = None) -> Span: if span is None or not isinstance(span, Span): return INVALID_SPAN return span + + +def set_link_in_context( + link: Link, context: Optional[Context] = None +) -> Context: + """Set the link in the given context. + + Args: + link: The Link to set. + context: a Context object. if one is not passed, the + default current context is used instead. + """ + ctx = set_value(_LINK_KEY, link, context=context) + return ctx + + +def get_current_link(context: Optional[Context] = None) -> Optional[Link]: + """Retrieve the current link. + + Args: + context: A Context object. If one is not passed, the + default current context is used instead. + + Returns: + The Link set in the context if it exists. None otherwise. + """ + link = get_value(_LINK_KEY, context=context) + if link is None or not isinstance(link, Link): + return None + return link diff --git a/opentelemetry-api/src/opentelemetry/trace/propagation/tracecontext.py b/opentelemetry-api/src/opentelemetry/trace/propagation/tracecontext.py index af16a08f0be..a228bcbdce6 100644 --- a/opentelemetry-api/src/opentelemetry/trace/propagation/tracecontext.py +++ b/opentelemetry-api/src/opentelemetry/trace/propagation/tracecontext.py @@ -14,6 +14,8 @@ # import re import typing +from enum import Enum +from typing import Protocol, Sequence from opentelemetry import trace from opentelemetry.context.context import Context @@ -116,3 +118,149 @@ def fields(self) -> typing.Set[str]: `opentelemetry.propagators.textmap.TextMapPropagator.fields` """ return {self._TRACEPARENT_HEADER_NAME, self._TRACESTATE_HEADER_NAME} + + +class PropagatorTracePolicy(Enum): + CONTINUE = 0 + RESTART_WITH_LINK = 1 + RESTART_WITHOUT_LINK = 2 + + +class PredicateT(Protocol): + def __call__( + self, + tracestate: TraceState | None, + ) -> bool: ... + + def __str__(self) -> str: ... + + +class OnKeyPresence(PredicateT): + def __init__(self, key: str): + self._key = key + + def __call__( + self, + tracestate: TraceState | None, + ) -> bool: + return tracestate is not None and self._key in tracestate + + def __str__(self): + return f"{self._key}=*" + + +class OnKeyValue(PredicateT): + def __init__(self, key: str, value: str): + self._key = key + self._value = value + + def __call__( + self, + tracestate: TraceState | None, + ) -> bool: + return ( + tracestate is not None and tracestate.get(self._key) == self._value + ) + + def __str__(self): + return f"{self._key}={self._value}" + + +class AlwaysPredicate(PredicateT): + def __call__( + self, + tracestate: TraceState | None, + ) -> bool: + return True + + def __str__(self): + return "*" + + +RulesT = Sequence[tuple[PredicateT, PropagatorTracePolicy]] + + +class RuleBasedTraceContextTextMapPropagator(textmap.TextMapPropagator): + """Extracts and injects using W3C TraceContext's headers. + + Rules based on the tracestate decide if the trace should be continued + or restarted.""" + + _TRACEPARENT_HEADER_NAME = "traceparent" + _TRACESTATE_HEADER_NAME = "tracestate" + _TRACEPARENT_HEADER_FORMAT = ( + "^[ \t]*([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})" + + "(-.*)?[ \t]*$" + ) + _TRACEPARENT_HEADER_FORMAT_RE = re.compile(_TRACEPARENT_HEADER_FORMAT) + + def __init__(self, rules: RulesT): + self._rules = rules + + def extract( + self, + carrier: textmap.CarrierT, + context: typing.Optional[Context] = None, + getter: textmap.Getter[textmap.CarrierT] = textmap.default_getter, + ) -> Context: + """Extracts SpanContext from the carrier and add it as a Link. + + See `opentelemetry.propagators.textmap.TextMapPropagator.extract` + """ + if context is None: + context = Context() + + header = getter.get(carrier, self._TRACEPARENT_HEADER_NAME) + + if not header: + return context + + match = re.search(self._TRACEPARENT_HEADER_FORMAT_RE, header[0]) + if not match: + return context + + version: str = match.group(1) + trace_id: str = match.group(2) + span_id: str = match.group(3) + trace_flags: str = match.group(4) + + if trace_id == "0" * 32 or span_id == "0" * 16: + return context + + if version == "00": + if match.group(5): # type: ignore + return context + if version == "ff": + return context + + tracestate_headers = getter.get(carrier, self._TRACESTATE_HEADER_NAME) + if tracestate_headers is None: + tracestate = None + else: + tracestate = TraceState.from_header(tracestate_headers) + + span_context = trace.SpanContext( + trace_id=int(trace_id, 16), + span_id=int(span_id, 16), + is_remote=True, + trace_flags=trace.TraceFlags(int(trace_flags, 16)), + trace_state=tracestate, + ) + + propagator_policy = PropagatorTracePolicy.CONTINUE + for predicate, policy in self._rules: + if predicate(tracestate): + propagator_policy = policy + break + + match propagator_policy: + case PropagatorTracePolicy.CONTINUE: + return trace.set_span_in_context( + trace.NonRecordingSpan(span_context), context + ) + case PropagatorTracePolicy.RESTART_WITH_LINK: + return trace.set_link_in_context( + trace.Link(span_context), context + ) + case PropagatorTracePolicy.RESTART_WITHOUT_LINK: + return context diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py index 18fced70612..ee6dc72c7ed 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py @@ -1188,7 +1188,7 @@ def start_span( # pylint: disable=too-many-locals record_exception: bool = True, set_status_on_exception: bool = True, ) -> trace_api.Span: - links = links or () + links = tuple(links) if links else () parent_span_context = trace_api.get_current_span( context ).get_span_context() @@ -1203,6 +1203,11 @@ def start_span( # pylint: disable=too-many-locals if not self._is_enabled(): return trace_api.NonRecordingSpan(context=parent_span_context) + # pick any eventual link added by the propagators + current_link = trace_api.get_current_link(context) + if current_link is not None: + links += (current_link,) + # is_valid determines root span if parent_span_context is None or not parent_span_context.is_valid: parent_span_context = None From 17402f0b710637ba60fa9441bc0006cce2a48170 Mon Sep 17 00:00:00 2001 From: Riccardo Magliocchetti Date: Wed, 22 Apr 2026 12:04:47 +0200 Subject: [PATCH 2/7] Add rule based baggage propagator --- .../baggage/propagation/__init__.py | 177 +++++++++++++++++- 1 file changed, 176 insertions(+), 1 deletion(-) diff --git a/opentelemetry-api/src/opentelemetry/baggage/propagation/__init__.py b/opentelemetry-api/src/opentelemetry/baggage/propagation/__init__.py index 49fb378eabd..de6ea4a9c94 100644 --- a/opentelemetry-api/src/opentelemetry/baggage/propagation/__init__.py +++ b/opentelemetry-api/src/opentelemetry/baggage/propagation/__init__.py @@ -14,7 +14,7 @@ # from logging import getLogger from re import split -from typing import Iterable, List, Mapping, Optional, Set +from typing import Iterable, List, Mapping, Optional, Protocol, Sequence, Set from urllib.parse import quote_plus, unquote_plus from opentelemetry.baggage import _is_valid_pair, get_all, set_baggage @@ -144,3 +144,178 @@ def _extract_first_element( if items is None: return None return next(iter(items), None) + + +class PredicateT(Protocol): + def __call__( + self, + name: str, + value: str, + ) -> bool: ... + + def __str__(self) -> str: ... + + +class OnKeyPresence(PredicateT): + def __init__(self, key: str): + self._key = key + + def __call__( + self, + name: str, + value: str, + ) -> bool: + return name == self._key + + def __str__(self): + return f"{self._key}=*" + + +class OnKeyValue(PredicateT): + def __init__(self, key: str, value: str): + self._key = key + self._value = value + + def __call__( + self, + name: str, + value: str, + ) -> bool: + return name == self._key and value == self._value + + def __str__(self): + return f"{self._key}={self._value}" + + +class AlwaysPredicate(PredicateT): + def __call__( + self, + name: str, + value: str, + ) -> bool: + return True + + def __str__(self): + return "*" + + +RulesT = Sequence[tuple[PredicateT, bool]] + + +class RuleBasedW3CBaggagePropagator(textmap.TextMapPropagator): + """Extracts and injects Baggage which is used to annotate telemetry. + + Baggage entries are injected depending on the rules.""" + + _MAX_HEADER_LENGTH = 8192 + _MAX_PAIR_LENGTH = 4096 + _MAX_PAIRS = 180 + _BAGGAGE_HEADER_NAME = "baggage" + + def __init__(self, rules: RulesT): + self._rules = rules + + def extract( + self, + carrier: textmap.CarrierT, + context: Optional[Context] = None, + getter: textmap.Getter[textmap.CarrierT] = textmap.default_getter, + ) -> Context: + """Extract Baggage from the carrier. + + See + `opentelemetry.propagators.textmap.TextMapPropagator.extract` + """ + + if context is None: + context = get_current() + + header = _extract_first_element( + getter.get(carrier, self._BAGGAGE_HEADER_NAME) + ) + + if not header: + return context + + if len(header) > self._MAX_HEADER_LENGTH: + _logger.warning( + "Baggage header `%s` exceeded the maximum number of bytes per baggage-string", + header, + ) + return context + + baggage_entries: List[str] = split(_DELIMITER_PATTERN, header) + total_baggage_entries = self._MAX_PAIRS + + if len(baggage_entries) > self._MAX_PAIRS: + _logger.warning( + "Baggage header `%s` exceeded the maximum number of list-members", + header, + ) + + for entry in baggage_entries: + if len(entry) > self._MAX_PAIR_LENGTH: + _logger.warning( + "Baggage entry `%s` exceeded the maximum number of bytes per list-member", + entry, + ) + continue + if not entry: # empty string + continue + try: + name, value = entry.split("=", 1) + except Exception: # pylint: disable=broad-exception-caught + _logger.warning( + "Baggage list-member `%s` doesn't match the format", entry + ) + continue + + if not _is_valid_pair(name, value): + _logger.warning("Invalid baggage entry: `%s`", entry) + continue + + name = unquote_plus(name).strip() + value = unquote_plus(value).strip() + + skip_entry = False + for predicate, outcome in self._rules: + if predicate(name, value): + skip_entry = outcome + break + + if skip_entry: + continue + + context = set_baggage( + name, + value, + context=context, + ) + total_baggage_entries -= 1 + if total_baggage_entries == 0: + break + + return context + + def inject( + self, + carrier: textmap.CarrierT, + context: Optional[Context] = None, + setter: textmap.Setter[textmap.CarrierT] = textmap.default_setter, + ) -> None: + """Injects Baggage into the carrier. + + See + `opentelemetry.propagators.textmap.TextMapPropagator.inject` + """ + baggage_entries = get_all(context=context) + if not baggage_entries: + return + + baggage_string = _format_baggage(baggage_entries) + setter.set(carrier, self._BAGGAGE_HEADER_NAME, baggage_string) + + @property + def fields(self) -> Set[str]: + """Returns a set with the fields set in `inject`.""" + return {self._BAGGAGE_HEADER_NAME} From 7a5e21bcb1d4d4bb71cc2f5d3d18415cb2edb7ec Mon Sep 17 00:00:00 2001 From: Riccardo Magliocchetti Date: Wed, 22 Apr 2026 17:17:31 +0200 Subject: [PATCH 3/7] Fix import loop --- .../src/opentelemetry/trace/__init__.py | 43 +------------ .../src/opentelemetry/trace/link.py | 60 +++++++++++++++++++ .../trace/propagation/__init__.py | 2 +- 3 files changed, 62 insertions(+), 43 deletions(-) create mode 100644 opentelemetry-api/src/opentelemetry/trace/link.py diff --git a/opentelemetry-api/src/opentelemetry/trace/__init__.py b/opentelemetry-api/src/opentelemetry/trace/__init__.py index 65dc5f0117e..8c0f837620b 100644 --- a/opentelemetry-api/src/opentelemetry/trace/__init__.py +++ b/opentelemetry-api/src/opentelemetry/trace/__init__.py @@ -83,9 +83,9 @@ from typing_extensions import deprecated from opentelemetry import context as context_api -from opentelemetry.attributes import BoundedAttributes from opentelemetry.context.context import Context from opentelemetry.environment_variables import OTEL_PYTHON_TRACER_PROVIDER +from opentelemetry.trace.link import Link from opentelemetry.trace.propagation import ( _SPAN_KEY, get_current_link, @@ -117,47 +117,6 @@ logger = getLogger(__name__) -class _LinkBase(ABC): - def __init__(self, context: "SpanContext") -> None: - self._context = context - - @property - def context(self) -> "SpanContext": - return self._context - - @property - @abstractmethod - def attributes(self) -> types.Attributes: - pass - - -class Link(_LinkBase): - """A link to a `Span`. The attributes of a Link are immutable. - - Args: - context: `SpanContext` of the `Span` to link to. - attributes: Link's attributes. - """ - - def __init__( - self, - context: "SpanContext", - attributes: types.Attributes = None, - ) -> None: - super().__init__(context) - self._attributes = attributes - - @property - def attributes(self) -> types.Attributes: - return self._attributes - - @property - def dropped_attributes(self) -> int: - if isinstance(self._attributes, BoundedAttributes): - return self._attributes.dropped - return 0 - - _Links = Optional[Sequence[Link]] diff --git a/opentelemetry-api/src/opentelemetry/trace/link.py b/opentelemetry-api/src/opentelemetry/trace/link.py new file mode 100644 index 00000000000..8b4f70494ef --- /dev/null +++ b/opentelemetry-api/src/opentelemetry/trace/link.py @@ -0,0 +1,60 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from abc import ABC, abstractmethod + +from opentelemetry.attributes import BoundedAttributes +from opentelemetry.trace.span import SpanContext +from opentelemetry.util import types + + +class _LinkBase(ABC): + def __init__(self, context: SpanContext) -> None: + self._context = context + + @property + def context(self) -> SpanContext: + return self._context + + @property + @abstractmethod + def attributes(self) -> types.Attributes: + pass + + +class Link(_LinkBase): + """A link to a `Span`. The attributes of a Link are immutable. + + Args: + context: `SpanContext` of the `Span` to link to. + attributes: Link's attributes. + """ + + def __init__( + self, + context: SpanContext, + attributes: types.Attributes = None, + ) -> None: + super().__init__(context) + self._attributes = attributes + + @property + def attributes(self) -> types.Attributes: + return self._attributes + + @property + def dropped_attributes(self) -> int: + if isinstance(self._attributes, BoundedAttributes): + return self._attributes.dropped + return 0 diff --git a/opentelemetry-api/src/opentelemetry/trace/propagation/__init__.py b/opentelemetry-api/src/opentelemetry/trace/propagation/__init__.py index d651d1d7ded..b3fbea1ef19 100644 --- a/opentelemetry-api/src/opentelemetry/trace/propagation/__init__.py +++ b/opentelemetry-api/src/opentelemetry/trace/propagation/__init__.py @@ -15,7 +15,7 @@ from opentelemetry.context import create_key, get_value, set_value from opentelemetry.context.context import Context -from opentelemetry.trace import Link +from opentelemetry.trace.link import Link from opentelemetry.trace.span import INVALID_SPAN, Span SPAN_KEY = "current-span" From 3adad601491525fc28fafba7b2500174ead99b67 Mon Sep 17 00:00:00 2001 From: Riccardo Magliocchetti Date: Wed, 22 Apr 2026 17:25:42 +0200 Subject: [PATCH 4/7] baggage propagator tests --- .../propagators/test_w3cbaggagepropagator.py | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/opentelemetry-api/tests/propagators/test_w3cbaggagepropagator.py b/opentelemetry-api/tests/propagators/test_w3cbaggagepropagator.py index 46db45f4d34..ad3f3f765e7 100644 --- a/opentelemetry-api/tests/propagators/test_w3cbaggagepropagator.py +++ b/opentelemetry-api/tests/propagators/test_w3cbaggagepropagator.py @@ -20,6 +20,10 @@ from opentelemetry.baggage import get_all, set_baggage from opentelemetry.baggage.propagation import ( + AlwaysPredicate, + OnKeyPresence, + OnKeyValue, + RuleBasedW3CBaggagePropagator, W3CBaggagePropagator, _format_baggage, ) @@ -265,3 +269,49 @@ def test_inject_extract(self): self.assertEqual( context, {"abc": {"transaction": "string with spaces"}} ) + + +class TestRuleBasedW3CBaggagePropagator(TestCase): + def _extract(self, rules, header_value): + propagator = RuleBasedW3CBaggagePropagator(rules) + header = {"baggage": [header_value]} + return get_all(propagator.extract(header)) + + def test_extract_skips_entry_when_key_matches(self): + self.assertEqual( + self._extract( + [(OnKeyPresence("key1"), True)], + "key1=val1,key2=val2", + ), + {"key2": "val2"}, + ) + + def test_extract_skips_entry_when_key_value_matches(self): + self.assertEqual( + self._extract( + [(OnKeyValue("key1", "val1"), True)], + "key1=val1,key2=val2", + ), + {"key2": "val2"}, + ) + + def test_extract_uses_first_matching_rule(self): + self.assertEqual( + self._extract( + [ + (OnKeyPresence("key1"), False), + (OnKeyValue("key1", "val1"), True), + ], + "key1=val1,key2=val2", + ), + {"key1": "val1", "key2": "val2"}, + ) + + def test_extract_always_predicate_can_skip_all_entries(self): + self.assertEqual( + self._extract( + [(AlwaysPredicate(), True)], + "key1=val1,key2=val2", + ), + {}, + ) From 2b035c83b781a81968febf53a7c153a820a6edcb Mon Sep 17 00:00:00 2001 From: Riccardo Magliocchetti Date: Wed, 22 Apr 2026 17:27:14 +0200 Subject: [PATCH 5/7] add missing inject --- .../trace/propagation/tracecontext.py | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/opentelemetry-api/src/opentelemetry/trace/propagation/tracecontext.py b/opentelemetry-api/src/opentelemetry/trace/propagation/tracecontext.py index a228bcbdce6..5b4114357f5 100644 --- a/opentelemetry-api/src/opentelemetry/trace/propagation/tracecontext.py +++ b/opentelemetry-api/src/opentelemetry/trace/propagation/tracecontext.py @@ -264,3 +264,34 @@ def extract( ) case PropagatorTracePolicy.RESTART_WITHOUT_LINK: return context + + def inject( + self, + carrier: textmap.CarrierT, + context: typing.Optional[Context] = None, + setter: textmap.Setter[textmap.CarrierT] = textmap.default_setter, + ) -> None: + """Injects SpanContext into the carrier. + + See `opentelemetry.propagators.textmap.TextMapPropagator.inject` + """ + span = trace.get_current_span(context) + span_context = span.get_span_context() + if span_context == trace.INVALID_SPAN_CONTEXT: + return + traceparent_string = f"00-{format_trace_id(span_context.trace_id)}-{format_span_id(span_context.span_id)}-{span_context.trace_flags:02x}" + setter.set(carrier, self._TRACEPARENT_HEADER_NAME, traceparent_string) + if span_context.trace_state: + tracestate_string = span_context.trace_state.to_header() + setter.set( + carrier, self._TRACESTATE_HEADER_NAME, tracestate_string + ) + + @property + def fields(self) -> typing.Set[str]: + """Returns a set with the fields set in `inject`. + + See + `opentelemetry.propagators.textmap.TextMapPropagator.fields` + """ + return {self._TRACEPARENT_HEADER_NAME, self._TRACESTATE_HEADER_NAME} From e1d316bba7aaa37f83e54b2e9b970b95343f097e Mon Sep 17 00:00:00 2001 From: Riccardo Magliocchetti Date: Wed, 22 Apr 2026 17:30:37 +0200 Subject: [PATCH 6/7] tracecontext tests --- .../test_tracecontexthttptextformat.py | 118 ++++++++++++++++++ opentelemetry-sdk/tests/trace/test_trace.py | 54 +++++++- 2 files changed, 171 insertions(+), 1 deletion(-) diff --git a/opentelemetry-api/tests/trace/propagation/test_tracecontexthttptextformat.py b/opentelemetry-api/tests/trace/propagation/test_tracecontexthttptextformat.py index 4ad9e89069d..76ac982320c 100644 --- a/opentelemetry-api/tests/trace/propagation/test_tracecontexthttptextformat.py +++ b/opentelemetry-api/tests/trace/propagation/test_tracecontexthttptextformat.py @@ -30,6 +30,18 @@ class TestTraceContextFormat(unittest.TestCase): TRACE_ID = int("12345678901234567890123456789012", 16) # type:int SPAN_ID = int("1234567890123456", 16) # type:int + def _traceparent(self): + return ( + f"00-{format(self.TRACE_ID, '032x')}-" + f"{format(self.SPAN_ID, '016x')}-00" + ) + + def _carrier(self, tracestate_value="foo=1,bar=2"): + return { + "traceparent": [self._traceparent()], + "tracestate": [tracestate_value], + } + def test_no_traceparent_header(self): """When tracecontext headers are not present, a new SpanContext should be created. @@ -318,3 +330,109 @@ def test_extract_invalid_trace_parent_to_implicit_ctx(self): ctx = FORMAT.extract(carrier) self.assertDictEqual(Context(), ctx) + + def test_rule_based_continue_policy_sets_current_span(self): + propagator = tracecontext.RuleBasedTraceContextTextMapPropagator( + [ + ( + tracecontext.OnKeyPresence("foo"), + tracecontext.PropagatorTracePolicy.CONTINUE, + ) + ] + ) + + ctx = propagator.extract(self._carrier()) + span_context = trace.get_current_span(ctx).get_span_context() + + self.assertEqual(span_context.trace_id, self.TRACE_ID) + self.assertEqual(span_context.span_id, self.SPAN_ID) + self.assertEqual(span_context.trace_state, {"foo": "1", "bar": "2"}) + self.assertIsNone(trace.get_current_link(ctx)) + + def test_rule_based_restart_with_link_sets_current_link(self): + propagator = tracecontext.RuleBasedTraceContextTextMapPropagator( + [ + ( + tracecontext.OnKeyValue("foo", "1"), + tracecontext.PropagatorTracePolicy.RESTART_WITH_LINK, + ) + ] + ) + + ctx = propagator.extract(self._carrier()) + link = trace.get_current_link(ctx) + + self.assertEqual( + trace.get_current_span(ctx).get_span_context(), + trace.INVALID_SPAN_CONTEXT, + ) + self.assertIsNotNone(link) + self.assertEqual(link.context.trace_id, self.TRACE_ID) + self.assertEqual(link.context.span_id, self.SPAN_ID) + self.assertEqual(link.context.trace_state, {"foo": "1", "bar": "2"}) + + def test_rule_based_restart_without_link_keeps_context_empty(self): + propagator = tracecontext.RuleBasedTraceContextTextMapPropagator( + [ + ( + tracecontext.AlwaysPredicate(), + tracecontext.PropagatorTracePolicy.RESTART_WITHOUT_LINK, + ) + ] + ) + + ctx = propagator.extract(self._carrier()) + + self.assertEqual( + trace.get_current_span(ctx).get_span_context(), + trace.INVALID_SPAN_CONTEXT, + ) + self.assertIsNone(trace.get_current_link(ctx)) + + def test_rule_based_propagator_uses_first_matching_rule(self): + propagator = tracecontext.RuleBasedTraceContextTextMapPropagator( + [ + ( + tracecontext.AlwaysPredicate(), + tracecontext.PropagatorTracePolicy.RESTART_WITHOUT_LINK, + ), + ( + tracecontext.OnKeyValue("foo", "1"), + tracecontext.PropagatorTracePolicy.CONTINUE, + ), + ] + ) + + ctx = propagator.extract(self._carrier()) + + self.assertEqual( + trace.get_current_span(ctx).get_span_context(), + trace.INVALID_SPAN_CONTEXT, + ) + self.assertIsNone(trace.get_current_link(ctx)) + + def test_rule_based_propagator_injects_tracecontext_fields(self): + propagator = tracecontext.RuleBasedTraceContextTextMapPropagator( + [ + ( + tracecontext.AlwaysPredicate(), + tracecontext.PropagatorTracePolicy.CONTINUE, + ) + ] + ) + span_context = trace.SpanContext( + self.TRACE_ID, + self.SPAN_ID, + is_remote=False, + trace_state=TraceState([("foo", "1")]), + ) + ctx = trace.set_span_in_context(trace.NonRecordingSpan(span_context)) + carrier: typing.Dict[str, str] = {} + + propagator.inject(carrier, context=ctx) + + self.assertEqual( + carrier["traceparent"], + self._traceparent(), + ) + self.assertEqual(propagator.fields, FORMAT.fields) diff --git a/opentelemetry-sdk/tests/trace/test_trace.py b/opentelemetry-sdk/tests/trace/test_trace.py index 2c6fdf3f929..609215bb451 100644 --- a/opentelemetry-sdk/tests/trace/test_trace.py +++ b/opentelemetry-sdk/tests/trace/test_trace.py @@ -30,7 +30,7 @@ from opentelemetry import trace as trace_api from opentelemetry.attributes import BoundedAttributes -from opentelemetry.context import Context +from opentelemetry.context import Context, attach, detach from opentelemetry.sdk import resources, trace from opentelemetry.sdk.environment_variables import ( OTEL_ATTRIBUTE_COUNT_LIMIT, @@ -364,6 +364,58 @@ def test_start_span_invalid_spancontext(self): self.assertTrue(new_span.context.is_valid) self.assertIsNone(new_span.parent) + def test_start_span_appends_link_from_context(self): + tracer = new_tracer() + explicit_context = trace_api.SpanContext( + trace_id=0x10000000000000000000000000000001, + span_id=0x1000000000000001, + is_remote=True, + ) + propagated_context = trace_api.SpanContext( + trace_id=0x20000000000000000000000000000002, + span_id=0x2000000000000002, + is_remote=True, + ) + ctx = trace_api.set_link_in_context(trace_api.Link(propagated_context)) + + root = tracer.start_span( + "root", + context=ctx, + links=[trace_api.Link(explicit_context)], + ) + + self.assertIsNone(root.parent) + self.assertEqual(len(root.links), 2) + self.assertEqual( + root.links[0].context.trace_id, explicit_context.trace_id + ) + self.assertEqual( + root.links[1].context.trace_id, + propagated_context.trace_id, + ) + + def test_child_span_does_not_inherit_link_from_context(self): + tracer = new_tracer() + propagated_context = trace_api.SpanContext( + trace_id=0x20000000000000000000000000000002, + span_id=0x2000000000000002, + is_remote=True, + ) + parent_context = trace_api.set_link_in_context( + trace_api.Link(propagated_context) + ) + root = tracer.start_span("root", context=parent_context) + token = attach(trace_api.set_span_in_context(root, parent_context)) + + try: + child = tracer.start_span("child") + finally: + detach(token) + + self.assertEqual(len(root.links), 1) + self.assertEqual(child.parent, root.get_span_context()) + self.assertEqual(len(child.links), 0) + def test_instrumentation_info(self): tracer_provider = trace.TracerProvider() schema_url = "https://opentelemetry.io/schemas/1.3.0" From d449070402fd0eae1ca15b78b5fd54651f0763e4 Mon Sep 17 00:00:00 2001 From: Riccardo Magliocchetti Date: Wed, 22 Apr 2026 17:40:16 +0200 Subject: [PATCH 7/7] Fix handling of child span the link in context should be added do the root and then removed from the context --- .../src/opentelemetry/trace/__init__.py | 3 +-- .../trace/propagation/__init__.py | 3 ++- opentelemetry-api/tests/trace/test_globals.py | 25 +++++++++++++++++++ .../src/opentelemetry/sdk/trace/__init__.py | 10 ++++---- 4 files changed, 33 insertions(+), 8 deletions(-) diff --git a/opentelemetry-api/src/opentelemetry/trace/__init__.py b/opentelemetry-api/src/opentelemetry/trace/__init__.py index 8c0f837620b..e93bea34bee 100644 --- a/opentelemetry-api/src/opentelemetry/trace/__init__.py +++ b/opentelemetry-api/src/opentelemetry/trace/__init__.py @@ -87,7 +87,6 @@ from opentelemetry.environment_variables import OTEL_PYTHON_TRACER_PROVIDER from opentelemetry.trace.link import Link from opentelemetry.trace.propagation import ( - _SPAN_KEY, get_current_link, get_current_span, set_link_in_context, @@ -575,7 +574,7 @@ def use_span( this mechanism if it was previously set manually. """ try: - token = context_api.attach(context_api.set_value(_SPAN_KEY, span)) + token = context_api.attach(set_span_in_context(span)) try: yield span finally: diff --git a/opentelemetry-api/src/opentelemetry/trace/propagation/__init__.py b/opentelemetry-api/src/opentelemetry/trace/propagation/__init__.py index b3fbea1ef19..5ef73160a62 100644 --- a/opentelemetry-api/src/opentelemetry/trace/propagation/__init__.py +++ b/opentelemetry-api/src/opentelemetry/trace/propagation/__init__.py @@ -34,7 +34,8 @@ def set_span_in_context( default current context is used instead. """ ctx = set_value(_SPAN_KEY, span, context=context) - return ctx + # A pending restart link is a one-shot hint for the next activated span. + return set_value(_LINK_KEY, None, context=ctx) def get_current_span(context: Optional[Context] = None) -> Span: diff --git a/opentelemetry-api/tests/trace/test_globals.py b/opentelemetry-api/tests/trace/test_globals.py index 920ed4b7b7c..2f88af309e8 100644 --- a/opentelemetry-api/tests/trace/test_globals.py +++ b/opentelemetry-api/tests/trace/test_globals.py @@ -120,6 +120,18 @@ def test_get_current_span(self): context.detach(token) self.assertEqual(trace.get_current_span(), trace.INVALID_SPAN) + def test_set_span_in_context_clears_current_link(self): + link = trace.Link( + trace.SpanContext(trace_id=1, span_id=1, is_remote=True) + ) + span = trace.NonRecordingSpan(trace.INVALID_SPAN_CONTEXT) + ctx = trace.set_link_in_context(link) + + ctx = trace.set_span_in_context(span, ctx) + + self.assertIs(trace.get_current_span(ctx), span) + self.assertIsNone(trace.get_current_link(ctx)) + class TestUseTracer(unittest.TestCase): def test_use_span(self): @@ -140,6 +152,19 @@ def test_use_span_end_on_exit(self): pass self.assertTrue(test_span.has_ended) + def test_use_span_clears_current_link_while_active(self): + link = trace.Link( + trace.SpanContext(trace_id=1, span_id=1, is_remote=True) + ) + token = context.attach(trace.set_link_in_context(link)) + try: + span = trace.NonRecordingSpan(trace.INVALID_SPAN_CONTEXT) + with trace.use_span(span): + self.assertIsNone(trace.get_current_link()) + self.assertIs(trace.get_current_span(), span) + finally: + context.detach(token) + def test_use_span_exception(self): class TestUseSpanException(Exception): pass diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py index ee6dc72c7ed..71032691477 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py @@ -1203,14 +1203,14 @@ def start_span( # pylint: disable=too-many-locals if not self._is_enabled(): return trace_api.NonRecordingSpan(context=parent_span_context) - # pick any eventual link added by the propagators - current_link = trace_api.get_current_link(context) - if current_link is not None: - links += (current_link,) - # is_valid determines root span if parent_span_context is None or not parent_span_context.is_valid: parent_span_context = None + # Propagators may request a restarted root span with a link to the + # incoming remote context. Child spans must not inherit that link. + current_link = trace_api.get_current_link(context) + if current_link is not None: + links += (current_link,) trace_id = self.id_generator.generate_trace_id() else: trace_id = parent_span_context.trace_id