From 43ecdd443403829ff4e8f9ea8456133195ff2cb9 Mon Sep 17 00:00:00 2001 From: Riccardo Magliocchetti Date: Tue, 30 Sep 2025 09:43:15 +0200 Subject: [PATCH 01/15] opentelemetry-sdk: Implement tracer configurator Implement part of the tracing SDK spec to configure the tracers https://opentelemetry.io/docs/specs/otel/trace/sdk/#configuration At the moment this adds helper in order to enable or disable a tracer after it has been created. The spec in is development so attributes, helpers and classes are prefixed with underscore. TODO: hook into sdk configuration --- .../sdk/_configuration/__init__.py | 1 + .../src/opentelemetry/sdk/trace/__init__.py | 115 ++++++++++++++++-- opentelemetry-sdk/tests/trace/test_trace.py | 63 +++++++++- 3 files changed, 170 insertions(+), 9 deletions(-) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/__init__.py index 7c0d0468f8..963e3a236b 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/__init__.py @@ -211,6 +211,7 @@ def _init_tracing( resource: Resource | None = None, exporter_args_map: ExporterArgsMap | None = None, ): + # FIXME: get configurator from entrypoints / env var provider = TracerProvider( id_generator=id_generator, sampler=sampler, diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py index 9ae9a2234e..cc0f41db41 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py @@ -23,6 +23,7 @@ import traceback import typing import weakref +from dataclasses import dataclass from os import environ from time import time_ns from types import MappingProxyType, TracebackType @@ -41,6 +42,7 @@ Union, ) from warnings import filterwarnings +from weakref import WeakSet from typing_extensions import deprecated @@ -73,7 +75,7 @@ EXCEPTION_STACKTRACE, EXCEPTION_TYPE, ) -from opentelemetry.trace import NoOpTracer, SpanContext +from opentelemetry.trace import INVALID_SPAN, NoOpTracer, SpanContext from opentelemetry.trace.status import Status, StatusCode from opentelemetry.util import types from opentelemetry.util._decorator import _agnosticcontextmanager @@ -1083,6 +1085,11 @@ class _Span(Span): """ +@dataclass +class _TracerConfig: + is_enabled: bool + + class Tracer(trace_api.Tracer): """See `opentelemetry.trace.Tracer`.""" @@ -1097,6 +1104,8 @@ def __init__( instrumentation_info: InstrumentationInfo, span_limits: SpanLimits, instrumentation_scope: InstrumentationScope, + *, + _tracer_config: _TracerConfig | None = None, ) -> None: self.sampler = sampler self.resource = resource @@ -1106,6 +1115,19 @@ def __init__( self._span_limits = span_limits self._instrumentation_scope = instrumentation_scope + self._enabled = ( + _tracer_config.is_enabled if _tracer_config is not None else True + ) + + def _update_tracer_config(self, tracer_config: _TracerConfig): + self._enabled = tracer_config.is_enabled + + @property + def _is_enabled(self) -> bool: + """Instrumentations needs to call this API each time to check if they should + create a new span.""" + return self._enabled + @_agnosticcontextmanager # pylint: disable=protected-access def start_as_current_span( self, @@ -1148,6 +1170,9 @@ def start_span( # pylint: disable=too-many-locals record_exception: bool = True, set_status_on_exception: bool = True, ) -> trace_api.Span: + if not self._is_enabled: + return INVALID_SPAN + parent_span_context = trace_api.get_current_span( context ).get_span_context() @@ -1214,6 +1239,26 @@ def start_span( # pylint: disable=too-many-locals return span +_TracerConfiguratorT = Callable[[InstrumentationScope], _TracerConfig] + + +def _default_tracer_configurator( + tracer_scope: InstrumentationScope, +) -> _TracerConfig: + """Default configurator functions for Tracers + + In order to update Tracers configs you need to call + TracerProvider._set_tracer_configurator with a function + implementing this interface returning a Tracer Config.""" + return _TracerConfig(is_enabled=True) + + +def _disable_tracer_configurator( + tracer_scope: InstrumentationScope, +) -> _TracerConfig: + return _TracerConfig(is_enabled=False) + + class TracerProvider(trace_api.TracerProvider): """See `opentelemetry.trace.TracerProvider`.""" @@ -1227,6 +1272,8 @@ def __init__( ] = None, id_generator: Optional[IdGenerator] = None, span_limits: Optional[SpanLimits] = None, + *, + _tracer_configurator: Optional[_TracerConfiguratorT] = None, ) -> None: self._active_span_processor = ( active_span_processor or SynchronousMultiSpanProcessor() @@ -1250,6 +1297,49 @@ def __init__( if shutdown_on_exit: self._atexit_handler = atexit.register(self.shutdown) + self._tracer_configurator = ( + _tracer_configurator or _default_tracer_configurator + ) + self._cached_tracers: WeakSet[Tracer] = WeakSet() + + def _set_tracer_configurator( + self, *, tracer_configurator: _TracerConfiguratorT + ): + self._tracer_configurator = tracer_configurator + self._update_tracers(tracer_configurator) + + def _update_tracers( + self, + *, + tracer_names: Optional[Sequence[str]] = None, + tracer_configurator: _TracerConfiguratorT, + ): + if tracer_names: + tracers = [ + t + for t in self._cached_tracers + if t._instrumentation_scope.name in tracer_names + ] + else: + tracers = self._cached_tracers + for tracer in tracers: + tracer_config = tracer_configurator(tracer._instrumentation_scope) + tracer._update_tracer_config(tracer_config) + + def _enable_tracers(self, *, tracer_names: Optional[Sequence[str]] = None): + self._update_tracers( + tracer_names=tracer_names, + tracer_configurator=_default_tracer_configurator, + ) + + def _disable_tracers( + self, *, tracer_names: Optional[Sequence[str]] = None + ): + self._update_tracers( + tracer_names=tracer_names, + tracer_configurator=_disable_tracer_configurator, + ) + @property def resource(self) -> Resource: return self._resource @@ -1284,21 +1374,30 @@ def get_tracer( schema_url, ) - return Tracer( + instrumentation_scope = InstrumentationScope( + instrumenting_module_name, + instrumenting_library_version, + schema_url, + attributes, + ) + + tracer_config = self._tracer_configurator(instrumentation_scope) + + tracer = Tracer( self.sampler, self.resource, self._active_span_processor, self.id_generator, instrumentation_info, self._span_limits, - InstrumentationScope( - instrumenting_module_name, - instrumenting_library_version, - schema_url, - attributes, - ), + instrumentation_scope, + _tracer_config=tracer_config, ) + self._cached_tracers.add(tracer) + + return tracer + def add_span_processor(self, span_processor: SpanProcessor) -> None: """Registers a new :class:`SpanProcessor` for this `TracerProvider`. diff --git a/opentelemetry-sdk/tests/trace/test_trace.py b/opentelemetry-sdk/tests/trace/test_trace.py index b83b000f4d..765e9b1949 100644 --- a/opentelemetry-sdk/tests/trace/test_trace.py +++ b/opentelemetry-sdk/tests/trace/test_trace.py @@ -43,7 +43,7 @@ OTEL_TRACES_SAMPLER, OTEL_TRACES_SAMPLER_ARG, ) -from opentelemetry.sdk.trace import Resource, TracerProvider +from opentelemetry.sdk.trace import Resource, TracerProvider, _TracerConfig from opentelemetry.sdk.trace.id_generator import RandomIdGenerator from opentelemetry.sdk.trace.sampling import ( ALWAYS_OFF, @@ -64,6 +64,7 @@ get_tracer, set_tracer_provider, ) +from opentelemetry.trace.span import INVALID_SPAN class TestTracer(unittest.TestCase): @@ -196,6 +197,43 @@ def test_get_tracer_with_sdk_disabled(self): tracer_provider.get_tracer(Mock()), trace_api.NoOpTracer ) + def test_update_tracer_config(self): + tracer_provider = trace.TracerProvider() + tracer = tracer_provider.get_tracer( + "module_name", + "library_version", + "schema_url", + {}, + ) + + self.assertEqual(tracer._is_enabled, True) + + tracer_config = _TracerConfig(is_enabled=False) + tracer._update_tracer_config(tracer_config) + self.assertEqual(tracer._is_enabled, False) + + tracer_config = _TracerConfig(is_enabled=True) + tracer._update_tracer_config(tracer_config) + self.assertEqual(tracer._is_enabled, True) + + def test_start_span_returns_invalid_span_if_not_enabled(self): + tracer_provider = trace.TracerProvider() + tracer = tracer_provider.get_tracer( + "module_name", + "library_version", + "schema_url", + {}, + ) + + self.assertEqual(tracer._is_enabled, True) + + tracer_config = _TracerConfig(is_enabled=False) + tracer._update_tracer_config(tracer_config) + self.assertEqual(tracer._is_enabled, False) + + span = tracer.start_span(name="invalid span") + self.assertIs(span, INVALID_SPAN) + class TestTracerSampling(unittest.TestCase): def tearDown(self): @@ -2183,6 +2221,29 @@ def test_tracer_provider_init_default(self, resource_patch, sample_patch): self.assertIsNotNone(tracer_provider._span_limits) self.assertIsNotNone(tracer_provider._atexit_handler) + def test_tracer_configurator(self): + tracer_provider = trace.TracerProvider() + tracer = tracer_provider.get_tracer( + "module_name", + "library_version", + "schema_url", + {}, + ) + # pylint: disable=protected-access + self.assertEqual(tracer._instrumentation_scope.name, "module_name") + + # pylint: disable=protected-access + self.assertEqual(tracer._is_enabled, True) + + tracer_provider._disable_tracers(tracer_names=["different_name"]) + self.assertEqual(tracer._is_enabled, True) + + tracer_provider._disable_tracers(tracer_names=["module_name"]) + self.assertEqual(tracer._is_enabled, False) + + tracer_provider._enable_tracers(tracer_names=["module_name"]) + self.assertEqual(tracer._is_enabled, True) + class TestRandomIdGenerator(unittest.TestCase): _TRACE_ID_MAX_VALUE = 2**128 - 1 From 6b2d6958c49e949eb5496fa6c29fc98b55c533d7 Mon Sep 17 00:00:00 2001 From: Riccardo Magliocchetti Date: Wed, 17 Dec 2025 12:43:26 +0100 Subject: [PATCH 02/15] Please lint --- opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py | 7 +++++-- opentelemetry-sdk/tests/trace/test_trace.py | 2 ++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py index cc0f41db41..23777d492e 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py @@ -1105,7 +1105,7 @@ def __init__( span_limits: SpanLimits, instrumentation_scope: InstrumentationScope, *, - _tracer_config: _TracerConfig | None = None, + _tracer_config: Optional[_TracerConfig] = None, ) -> None: self.sampler = sampler self.resource = resource @@ -1306,7 +1306,7 @@ def _set_tracer_configurator( self, *, tracer_configurator: _TracerConfiguratorT ): self._tracer_configurator = tracer_configurator - self._update_tracers(tracer_configurator) + self._update_tracers(tracer_configurator=tracer_configurator) def _update_tracers( self, @@ -1314,6 +1314,9 @@ def _update_tracers( tracer_names: Optional[Sequence[str]] = None, tracer_configurator: _TracerConfiguratorT, ): + # pylint: disable=protected-access + # FIXME: the configurator should be rule based and so the logic to filter to which tracer applies this to + # should be there and not a parameter here so that _enable_tracers / _disable_tracers should call _set_tracer_configurator if tracer_names: tracers = [ t diff --git a/opentelemetry-sdk/tests/trace/test_trace.py b/opentelemetry-sdk/tests/trace/test_trace.py index 765e9b1949..2f725325ad 100644 --- a/opentelemetry-sdk/tests/trace/test_trace.py +++ b/opentelemetry-sdk/tests/trace/test_trace.py @@ -198,6 +198,7 @@ def test_get_tracer_with_sdk_disabled(self): ) def test_update_tracer_config(self): + # pylint: disable=protected-access tracer_provider = trace.TracerProvider() tracer = tracer_provider.get_tracer( "module_name", @@ -217,6 +218,7 @@ def test_update_tracer_config(self): self.assertEqual(tracer._is_enabled, True) def test_start_span_returns_invalid_span_if_not_enabled(self): + # pylint: disable=protected-access tracer_provider = trace.TracerProvider() tracer = tracer_provider.get_tracer( "module_name", From 2622c0c0ab7c98221555ea1f6b1c3675b7bc5734 Mon Sep 17 00:00:00 2001 From: Riccardo Magliocchetti Date: Wed, 17 Dec 2025 15:34:33 +0100 Subject: [PATCH 03/15] Add rule based tracer configurator --- .../src/opentelemetry/sdk/trace/__init__.py | 33 ++++++++++++++++--- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py index 23777d492e..e6ec4caf46 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py @@ -306,7 +306,7 @@ def force_flush(self, timeout_millis: int = 30000) -> bool: timeout, False otherwise. """ futures = [] - for sp in self._span_processors: # type: SpanProcessor + for sp in self._span_processors: future = self._executor.submit(sp.force_flush, timeout_millis) futures.append(future) @@ -1240,23 +1240,48 @@ def start_span( # pylint: disable=too-many-locals _TracerConfiguratorT = Callable[[InstrumentationScope], _TracerConfig] +_TracerConfiguratorRulesPredicateT = Callable[ + [Optional[InstrumentationScope]], bool +] +_TracerConfiguratorRulesT = Sequence[ + Tuple[_TracerConfiguratorRulesPredicateT, _TracerConfig] +] + + +class _RuleBaseTracerConfigurator: + def __init__(self, *, rules: _TracerConfiguratorRulesT): + self._rules = rules + + def __call__( + self, tracer_scope: Optional[InstrumentationScope] = None + ) -> _TracerConfig: + for predicate, tracer_config in self._rules: + if predicate(tracer_scope): + return tracer_config + + # if no rule matched return a default one + return _TracerConfig(is_enabled=True) def _default_tracer_configurator( tracer_scope: InstrumentationScope, ) -> _TracerConfig: - """Default configurator functions for Tracers + """Default Tracer Configurator implementation In order to update Tracers configs you need to call TracerProvider._set_tracer_configurator with a function implementing this interface returning a Tracer Config.""" - return _TracerConfig(is_enabled=True) + return _RuleBaseTracerConfigurator( + rules=[(lambda x: True, _TracerConfig(is_enabled=True))], + )(tracer_scope=tracer_scope) def _disable_tracer_configurator( tracer_scope: InstrumentationScope, ) -> _TracerConfig: - return _TracerConfig(is_enabled=False) + return _RuleBaseTracerConfigurator( + rules=[(lambda x: True, _TracerConfig(is_enabled=False))], + )(tracer_scope=tracer_scope) class TracerProvider(trace_api.TracerProvider): From 82f2d05a4f20e48beba7c4685238720f009ccee1 Mon Sep 17 00:00:00 2001 From: Riccardo Magliocchetti Date: Wed, 17 Dec 2025 16:01:30 +0100 Subject: [PATCH 04/15] Assume rule based tracer configurator in helpers --- .../src/opentelemetry/sdk/trace/__init__.py | 21 +--- opentelemetry-sdk/tests/trace/test_trace.py | 109 ++++++++++++++++-- 2 files changed, 105 insertions(+), 25 deletions(-) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py index e6ec4caf46..806ad3bb13 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py @@ -1336,35 +1336,20 @@ def _set_tracer_configurator( def _update_tracers( self, *, - tracer_names: Optional[Sequence[str]] = None, tracer_configurator: _TracerConfiguratorT, ): # pylint: disable=protected-access - # FIXME: the configurator should be rule based and so the logic to filter to which tracer applies this to - # should be there and not a parameter here so that _enable_tracers / _disable_tracers should call _set_tracer_configurator - if tracer_names: - tracers = [ - t - for t in self._cached_tracers - if t._instrumentation_scope.name in tracer_names - ] - else: - tracers = self._cached_tracers - for tracer in tracers: + for tracer in self._cached_tracers: tracer_config = tracer_configurator(tracer._instrumentation_scope) tracer._update_tracer_config(tracer_config) - def _enable_tracers(self, *, tracer_names: Optional[Sequence[str]] = None): + def _enable_tracers(self): self._update_tracers( - tracer_names=tracer_names, tracer_configurator=_default_tracer_configurator, ) - def _disable_tracers( - self, *, tracer_names: Optional[Sequence[str]] = None - ): + def _disable_tracers(self): self._update_tracers( - tracer_names=tracer_names, tracer_configurator=_disable_tracer_configurator, ) diff --git a/opentelemetry-sdk/tests/trace/test_trace.py b/opentelemetry-sdk/tests/trace/test_trace.py index 2f725325ad..4304f5d9ee 100644 --- a/opentelemetry-sdk/tests/trace/test_trace.py +++ b/opentelemetry-sdk/tests/trace/test_trace.py @@ -43,7 +43,12 @@ OTEL_TRACES_SAMPLER, OTEL_TRACES_SAMPLER_ARG, ) -from opentelemetry.sdk.trace import Resource, TracerProvider, _TracerConfig +from opentelemetry.sdk.trace import ( + Resource, + TracerProvider, + _RuleBaseTracerConfigurator, + _TracerConfig, +) from opentelemetry.sdk.trace.id_generator import RandomIdGenerator from opentelemetry.sdk.trace.sampling import ( ALWAYS_OFF, @@ -2223,7 +2228,8 @@ def test_tracer_provider_init_default(self, resource_patch, sample_patch): self.assertIsNotNone(tracer_provider._span_limits) self.assertIsNotNone(tracer_provider._atexit_handler) - def test_tracer_configurator(self): + def test_default_tracer_configurator(self): + # pylint: disable=protected-access tracer_provider = trace.TracerProvider() tracer = tracer_provider.get_tracer( "module_name", @@ -2231,20 +2237,109 @@ def test_tracer_configurator(self): "schema_url", {}, ) - # pylint: disable=protected-access + other_tracer = tracer_provider.get_tracer( + "other_module_name", + "library_version", + "schema_url", + {}, + ) self.assertEqual(tracer._instrumentation_scope.name, "module_name") + self.assertEqual( + other_tracer._instrumentation_scope.name, "other_module_name" + ) + + self.assertEqual(tracer._is_enabled, True) + self.assertEqual(other_tracer._is_enabled, True) + + tracer_provider._disable_tracers() + self.assertEqual(tracer._is_enabled, False) + self.assertEqual(other_tracer._is_enabled, False) + + tracer_provider._enable_tracers() + self.assertEqual(tracer._is_enabled, True) + self.assertEqual(other_tracer._is_enabled, True) + def test_rule_based_tracer_configurator(self): # pylint: disable=protected-access + rules = [ + ( + lambda x: True if x.name == "module_name" else False, + _TracerConfig(is_enabled=True), + ), + ( + lambda x: True if x.name == "other_module_name" else False, + _TracerConfig(is_enabled=False), + ), + ] + configurator = _RuleBaseTracerConfigurator(rules=rules) + + tracer_provider = trace.TracerProvider() + tracer = tracer_provider.get_tracer( + "module_name", + "library_version", + "schema_url", + {}, + ) + other_tracer = tracer_provider.get_tracer( + "other_module_name", + "library_version", + "schema_url", + {}, + ) + self.assertEqual(tracer._instrumentation_scope.name, "module_name") + self.assertEqual( + other_tracer._instrumentation_scope.name, "other_module_name" + ) + self.assertEqual(tracer._is_enabled, True) + self.assertEqual(other_tracer._is_enabled, True) + + tracer_provider._set_tracer_configurator( + tracer_configurator=configurator + ) - tracer_provider._disable_tracers(tracer_names=["different_name"]) self.assertEqual(tracer._is_enabled, True) + self.assertEqual(other_tracer._is_enabled, False) - tracer_provider._disable_tracers(tracer_names=["module_name"]) - self.assertEqual(tracer._is_enabled, False) + def test_rule_based_tracer_configurator_default_when_rules_dont_match( + self, + ): + # pylint: disable=protected-access + rules = [ + ( + lambda x: True if x.name == "module_name" else False, + _TracerConfig(is_enabled=False), + ), + ] + configurator = _RuleBaseTracerConfigurator(rules=rules) + + tracer_provider = trace.TracerProvider() + tracer = tracer_provider.get_tracer( + "module_name", + "library_version", + "schema_url", + {}, + ) + other_tracer = tracer_provider.get_tracer( + "other_module_name", + "library_version", + "schema_url", + {}, + ) + self.assertEqual(tracer._instrumentation_scope.name, "module_name") + self.assertEqual( + other_tracer._instrumentation_scope.name, "other_module_name" + ) - tracer_provider._enable_tracers(tracer_names=["module_name"]) self.assertEqual(tracer._is_enabled, True) + self.assertEqual(other_tracer._is_enabled, True) + + tracer_provider._set_tracer_configurator( + tracer_configurator=configurator + ) + + self.assertEqual(tracer._is_enabled, False) + self.assertEqual(other_tracer._is_enabled, True) class TestRandomIdGenerator(unittest.TestCase): From f2d88410a9bb3ed1bf30357c6a9ea9d06cda0f6a Mon Sep 17 00:00:00 2001 From: Riccardo Magliocchetti Date: Wed, 17 Dec 2025 16:06:18 +0100 Subject: [PATCH 05/15] Fix lint --- opentelemetry-sdk/tests/trace/test_trace.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/opentelemetry-sdk/tests/trace/test_trace.py b/opentelemetry-sdk/tests/trace/test_trace.py index 4304f5d9ee..bf0aafea16 100644 --- a/opentelemetry-sdk/tests/trace/test_trace.py +++ b/opentelemetry-sdk/tests/trace/test_trace.py @@ -2263,11 +2263,11 @@ def test_rule_based_tracer_configurator(self): # pylint: disable=protected-access rules = [ ( - lambda x: True if x.name == "module_name" else False, + lambda x: x.name == "module_name", _TracerConfig(is_enabled=True), ), ( - lambda x: True if x.name == "other_module_name" else False, + lambda x: x.name == "other_module_name", _TracerConfig(is_enabled=False), ), ] @@ -2307,7 +2307,7 @@ def test_rule_based_tracer_configurator_default_when_rules_dont_match( # pylint: disable=protected-access rules = [ ( - lambda x: True if x.name == "module_name" else False, + lambda x: x.name == "module_name", _TracerConfig(is_enabled=False), ), ] From a17f947a3eee2cad60d56353cf3b79453f3ecfaf Mon Sep 17 00:00:00 2001 From: Riccardo Magliocchetti Date: Wed, 24 Dec 2025 15:46:41 +0100 Subject: [PATCH 06/15] Add changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a83e9bfa2f..e442f96230 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ([#4862](https://github.com/open-telemetry/opentelemetry-python/pull/4862)) - `opentelemetry-exporter-otlp-proto-http`: fix retry logic and error handling for connection failures in trace, metric, and log exporters ([#4709](https://github.com/open-telemetry/opentelemetry-python/pull/4709)) +- Implement experimental TracerConfigurator + ([#4861](https://github.com/open-telemetry/opentelemetry-python/pull/4861)) ## Version 1.39.0/0.60b0 (2025-12-03) From 5998b32c3c61decaa9c58001b1408ba91fa41bcb Mon Sep 17 00:00:00 2001 From: Riccardo Magliocchetti Date: Wed, 24 Dec 2025 16:36:07 +0100 Subject: [PATCH 07/15] hook into auto-instrumentation --- .../sdk/_configuration/__init__.py | 39 +++++++++++- .../sdk/environment_variables/__init__.py | 12 ++++ opentelemetry-sdk/tests/test_configurator.py | 62 ++++++++++++++++++- 3 files changed, 110 insertions(+), 3 deletions(-) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/__init__.py index 963e3a236b..f7ed28658a 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/__init__.py @@ -48,6 +48,7 @@ OTEL_EXPORTER_OTLP_METRICS_PROTOCOL, OTEL_EXPORTER_OTLP_PROTOCOL, OTEL_EXPORTER_OTLP_TRACES_PROTOCOL, + OTEL_PYTHON_TRACER_CONFIGURATOR, OTEL_TRACES_SAMPLER, OTEL_TRACES_SAMPLER_ARG, ) @@ -58,7 +59,7 @@ PeriodicExportingMetricReader, ) from opentelemetry.sdk.resources import Attributes, Resource -from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace import TracerProvider, _TracerConfiguratorT from opentelemetry.sdk.trace.export import BatchSpanProcessor, SpanExporter from opentelemetry.sdk.trace.id_generator import IdGenerator from opentelemetry.sdk.trace.sampling import Sampler @@ -146,6 +147,10 @@ def _get_id_generator() -> str: return environ.get(OTEL_PYTHON_ID_GENERATOR, _DEFAULT_ID_GENERATOR) +def _get_tracer_configurator() -> str | None: + return environ.get(OTEL_PYTHON_TRACER_CONFIGURATOR, None) + + def _get_exporter_entry_point( exporter_name: str, signal_type: Literal["traces", "metrics", "logs"] ): @@ -210,12 +215,13 @@ def _init_tracing( sampler: Sampler | None = None, resource: Resource | None = None, exporter_args_map: ExporterArgsMap | None = None, + tracer_configurator: _TracerConfiguratorT | None = None, ): - # FIXME: get configurator from entrypoints / env var provider = TracerProvider( id_generator=id_generator, sampler=sampler, resource=resource, + _tracer_configurator=tracer_configurator, ) set_tracer_provider(provider) @@ -316,6 +322,27 @@ def overwritten_config_fn(*args, **kwargs): logging.basicConfig = wrapper(logging.basicConfig) +def _import_tracer_configurator( + tracer_configurator_name: str | None, +) -> _TracerConfiguratorT | None: + if not tracer_configurator_name: + return None + + try: + _, tracer_configurator_impl = _import_config_components( + [tracer_configurator_name.strip()], + "_opentelemetry_tracer_configurator", + )[0] + except Exception as exc: # pylint: disable=broad-exception-caught + _logger.warning( + "Using default tracer configurator. Failed to load tracer configurator, %s: %s", + tracer_configurator_name, + exc, + ) + return None + return tracer_configurator_impl + + def _import_exporters( trace_exporter_names: Sequence[str], metric_exporter_names: Sequence[str], @@ -430,6 +457,7 @@ def _initialize_components( id_generator: IdGenerator | None = None, setup_logging_handler: bool | None = None, exporter_args_map: ExporterArgsMap | None = None, + tracer_configurator: _TracerConfiguratorT | None = None, ): if trace_exporter_names is None: trace_exporter_names = [] @@ -455,6 +483,12 @@ def _initialize_components( resource_attributes[ResourceAttributes.TELEMETRY_AUTO_VERSION] = ( # type: ignore[reportIndexIssue] auto_instrumentation_version ) + if tracer_configurator is None: + tracer_configurator_name = _get_tracer_configurator() + tracer_configurator = _import_tracer_configurator( + tracer_configurator_name + ) + # if env var OTEL_RESOURCE_ATTRIBUTES is given, it will read the service_name # from the env variable else defaults to "unknown_service" resource = Resource.create(resource_attributes) @@ -465,6 +499,7 @@ def _initialize_components( sampler=sampler, resource=resource, exporter_args_map=exporter_args_map, + tracer_configurator=tracer_configurator, ) _init_metrics( metric_exporters, resource, exporter_args_map=exporter_args_map diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/environment_variables/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/environment_variables/__init__.py index 5baf5fcd55..ae3959ef15 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/environment_variables/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/environment_variables/__init__.py @@ -866,3 +866,15 @@ def channel_credential_provider() -> grpc.ChannelCredentials: This is an experimental environment variable and the name of this variable and its behavior can change in a non-backwards compatible way. """ + +OTEL_PYTHON_TRACER_CONFIGURATOR = "OTEL_PYTHON_TRACER_CONFIGURATOR" +""" +.. envvar:: OTEL_PYTHON_TRACER_CONFIGURATOR + +The :envvar:`OTEL_PYTHON_TRACER_CONFIGURATOR` environment variable allows users to set a +custom Tracer Configurator function. +Default: opentelemetry.sdk.trace._default_tracer_configurator + +This is an experimental environment variable and the name of this variable and its behavior can +change in a non-backwards compatible way. +""" diff --git a/opentelemetry-sdk/tests/test_configurator.py b/opentelemetry-sdk/tests/test_configurator.py index 8edc9190da..a747df195b 100644 --- a/opentelemetry-sdk/tests/test_configurator.py +++ b/opentelemetry-sdk/tests/test_configurator.py @@ -11,6 +11,7 @@ # 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. + # type: ignore # pylint: skip-file from __future__ import annotations @@ -35,10 +36,12 @@ _get_exporter_names, _get_id_generator, _get_sampler, + _get_tracer_configurator, _import_config_components, _import_exporters, _import_id_generator, _import_sampler, + _import_tracer_configurator, _init_logging, _init_metrics, _init_tracing, @@ -62,6 +65,7 @@ ) from opentelemetry.sdk.metrics.view import Aggregation from opentelemetry.sdk.resources import SERVICE_NAME, Resource +from opentelemetry.sdk.trace import _RuleBaseTracerConfigurator from opentelemetry.sdk.trace.export import ConsoleSpanExporter from opentelemetry.sdk.trace.id_generator import IdGenerator, RandomIdGenerator from opentelemetry.sdk.trace.sampling import ( @@ -79,10 +83,18 @@ class Provider: - def __init__(self, resource=None, sampler=None, id_generator=None): + def __init__( + self, + resource=None, + sampler=None, + id_generator=None, + *, + _tracer_configurator=None, + ): self.sampler = sampler self.id_generator = id_generator self.processor = None + self._tracer_configurator = _tracer_configurator self.resource = resource or Resource.create({}) def add_span_processor(self, processor): @@ -597,6 +609,52 @@ def verify_default_sampler(self, tracer_provider): # pylint: disable=protected-access self.assertEqual(tracer_provider.sampler._root, ALWAYS_ON) + @patch.dict( + "os.environ", + {"OTEL_PYTHON_TRACER_CONFIGURATOR": "non_existent_entry_point"}, + ) + def test_trace_init_custom_tracer_configurator_with_env_non_existent_entry_point( + self, + ): + tracer_configurator_name = _get_tracer_configurator() + with self.assertLogs(level=WARNING): + tracer_configurator = _import_tracer_configurator( + tracer_configurator_name + ) + _init_tracing({}, tracer_configurator=tracer_configurator) + + @patch("opentelemetry.sdk._configuration.entry_points") + @patch.dict( + "os.environ", + {"OTEL_PYTHON_TRACER_CONFIGURATOR": "custom_tracer_configurator"}, + ) + def test_trace_init_custom_tracer_configurator_with_env( + self, mock_entry_points + ): + def custom_tracer_configurator(tracer_scope): + return mock.Mock(spec=_RuleBaseTracerConfigurator)( + tracer_scope=tracer_scope + ) + + mock_entry_points.configure_mock( + return_value=[ + IterEntryPoint( + "custom_tracer_configurator", + custom_tracer_configurator, + ) + ] + ) + + tracer_configurator_name = _get_tracer_configurator() + tracer_configurator = _import_tracer_configurator( + tracer_configurator_name + ) + _init_tracing({}, tracer_configurator=tracer_configurator) + provider = self.set_provider_mock.call_args[0][0] + self.assertEqual( + provider._tracer_configurator, custom_tracer_configurator + ) + class TestLoggingInit(TestCase): def setUp(self): @@ -843,6 +901,7 @@ def test_initialize_components_kwargs( "id_generator": "TEST_GENERATOR", "setup_logging_handler": True, "exporter_args_map": {1: {"compression": "gzip"}}, + "tracer_configurator": "tracer_configurator_test", } _initialize_components(**kwargs) @@ -877,6 +936,7 @@ def test_initialize_components_kwargs( sampler="TEST_SAMPLER", resource="TEST_RESOURCE", exporter_args_map={1: {"compression": "gzip"}}, + tracer_configurator="tracer_configurator_test", ) metrics_mock.assert_called_once_with( "TEST_METRICS_EXPORTERS_DICT", From 456a2ff9eb0260a29f335322210372eedb0d7649 Mon Sep 17 00:00:00 2001 From: Riccardo Magliocchetti Date: Wed, 14 Jan 2026 15:10:31 +0100 Subject: [PATCH 08/15] Don't return invalid spans from NoOpTracer --- .../src/opentelemetry/trace/__init__.py | 28 +++++++++++++++++-- .../tests/test_implementation.py | 27 ++++++++++++------ opentelemetry-api/tests/trace/test_proxy.py | 4 +-- opentelemetry-api/tests/trace/test_tracer.py | 2 +- 4 files changed, 48 insertions(+), 13 deletions(-) diff --git a/opentelemetry-api/src/opentelemetry/trace/__init__.py b/opentelemetry-api/src/opentelemetry/trace/__init__.py index 529c73989c..a23bbe8b80 100644 --- a/opentelemetry-api/src/opentelemetry/trace/__init__.py +++ b/opentelemetry-api/src/opentelemetry/trace/__init__.py @@ -471,7 +471,15 @@ def start_span( record_exception: bool = True, set_status_on_exception: bool = True, ) -> "Span": - return INVALID_SPAN + parent_span_context = get_current_span(context).get_span_context() + if parent_span_context is not None and not isinstance( + parent_span_context, SpanContext + ): + raise TypeError( + "parent_span_context must be a SpanContext or None." + ) + + return NonRecordingSpan(context=parent_span_context) @_agnosticcontextmanager def start_as_current_span( @@ -486,7 +494,23 @@ def start_as_current_span( set_status_on_exception: bool = True, end_on_exit: bool = True, ) -> Iterator["Span"]: - yield INVALID_SPAN + span = self.start_span( + name=name, + context=context, + kind=kind, + attributes=attributes, + links=links, + start_time=start_time, + record_exception=record_exception, + set_status_on_exception=set_status_on_exception, + ) + with use_span( + span, + end_on_exit=end_on_exit, + record_exception=record_exception, + set_status_on_exception=set_status_on_exception, + ) as span: + yield span @deprecated("You should use NoOpTracer. Deprecated since version 1.9.0.") diff --git a/opentelemetry-api/tests/test_implementation.py b/opentelemetry-api/tests/test_implementation.py index 913efbffb3..4747fa2d9e 100644 --- a/opentelemetry-api/tests/test_implementation.py +++ b/opentelemetry-api/tests/test_implementation.py @@ -36,18 +36,29 @@ def test_default_tracer(self): tracer_provider = trace.NoOpTracerProvider() tracer = tracer_provider.get_tracer(__name__) with tracer.start_span("test") as span: - self.assertEqual( - span.get_span_context(), trace.INVALID_SPAN_CONTEXT - ) - self.assertEqual(span, trace.INVALID_SPAN) + self.assertFalse(span.get_span_context().is_valid) self.assertIs(span.is_recording(), False) with tracer.start_span("test2") as span2: - self.assertEqual( - span2.get_span_context(), trace.INVALID_SPAN_CONTEXT - ) - self.assertEqual(span2, trace.INVALID_SPAN) + self.assertFalse(span2.get_span_context().is_valid) self.assertIs(span2.is_recording(), False) + def test_default_tracer_context_propagation(self): + tracer_provider = trace.NoOpTracerProvider() + tracer = tracer_provider.get_tracer(__name__) + ctx = trace.set_span_in_context( + trace.NonRecordingSpan( + trace.SpanContext( + 2604504634922341076776623263868986797, + 5213367945872657620, + False, + trace.TraceFlags(0x01), + ) + ) + ) + with tracer.start_span("test", context=ctx) as span: + self.assertTrue(span.get_span_context().is_valid) + self.assertIs(span.is_recording(), False) + def test_span(self): with self.assertRaises(TypeError): # pylint: disable=abstract-class-instantiated diff --git a/opentelemetry-api/tests/trace/test_proxy.py b/opentelemetry-api/tests/trace/test_proxy.py index caf847777c..ad969e7661 100644 --- a/opentelemetry-api/tests/trace/test_proxy.py +++ b/opentelemetry-api/tests/trace/test_proxy.py @@ -96,8 +96,8 @@ def my_function() -> Span: return trace.get_current_span() # call function before configuring tracing provider, should - # return INVALID_SPAN from the NoOpTracer - self.assertEqual(my_function(), trace.INVALID_SPAN) + # return NonRecordingSpan from the NoOpTracer + self.assertFalse(my_function().is_recording()) # configure tracing provider trace.set_tracer_provider(TestProvider()) diff --git a/opentelemetry-api/tests/trace/test_tracer.py b/opentelemetry-api/tests/trace/test_tracer.py index fae836d564..632232e59e 100644 --- a/opentelemetry-api/tests/trace/test_tracer.py +++ b/opentelemetry-api/tests/trace/test_tracer.py @@ -79,5 +79,5 @@ async def function_async(data: str) -> int: def test_get_current_span(self): with self.tracer.start_as_current_span("test") as span: get_current_span().set_attribute("test", "test") - self.assertEqual(span, INVALID_SPAN) + self.assertFalse(span.is_recording()) self.assertFalse(hasattr("span", "attributes")) From c80b2d8e64567a08ee9594f4ed46172138984d30 Mon Sep 17 00:00:00 2001 From: Riccardo Magliocchetti Date: Wed, 14 Jan 2026 16:01:46 +0100 Subject: [PATCH 09/15] Adapt after nooptracer behaviour change --- opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py | 8 ++++---- opentelemetry-sdk/tests/trace/test_trace.py | 3 +-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py index 806ad3bb13..56e8b810e8 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py @@ -75,7 +75,7 @@ EXCEPTION_STACKTRACE, EXCEPTION_TYPE, ) -from opentelemetry.trace import INVALID_SPAN, NoOpTracer, SpanContext +from opentelemetry.trace import NoOpTracer, SpanContext from opentelemetry.trace.status import Status, StatusCode from opentelemetry.util import types from opentelemetry.util._decorator import _agnosticcontextmanager @@ -1170,9 +1170,6 @@ def start_span( # pylint: disable=too-many-locals record_exception: bool = True, set_status_on_exception: bool = True, ) -> trace_api.Span: - if not self._is_enabled: - return INVALID_SPAN - parent_span_context = trace_api.get_current_span( context ).get_span_context() @@ -1184,6 +1181,9 @@ def start_span( # pylint: disable=too-many-locals "parent_span_context must be a SpanContext or None." ) + if not self._is_enabled: + return trace_api.NonRecordingSpan(context=parent_span_context) + # is_valid determines root span if parent_span_context is None or not parent_span_context.is_valid: parent_span_context = None diff --git a/opentelemetry-sdk/tests/trace/test_trace.py b/opentelemetry-sdk/tests/trace/test_trace.py index bf0aafea16..4e3a0db969 100644 --- a/opentelemetry-sdk/tests/trace/test_trace.py +++ b/opentelemetry-sdk/tests/trace/test_trace.py @@ -69,7 +69,6 @@ get_tracer, set_tracer_provider, ) -from opentelemetry.trace.span import INVALID_SPAN class TestTracer(unittest.TestCase): @@ -239,7 +238,7 @@ def test_start_span_returns_invalid_span_if_not_enabled(self): self.assertEqual(tracer._is_enabled, False) span = tracer.start_span(name="invalid span") - self.assertIs(span, INVALID_SPAN) + self.assertFalse(span.is_recording()) class TestTracerSampling(unittest.TestCase): From b86b3228bd8a3a366fb231ca03a60db8078f8082 Mon Sep 17 00:00:00 2001 From: Riccardo Magliocchetti Date: Wed, 14 Jan 2026 16:06:03 +0100 Subject: [PATCH 10/15] Please lint --- .../src/opentelemetry/sdk/_configuration/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/__init__.py index f7ed28658a..39a5a1471f 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/__init__.py @@ -459,6 +459,7 @@ def _initialize_components( exporter_args_map: ExporterArgsMap | None = None, tracer_configurator: _TracerConfiguratorT | None = None, ): + # pylint: disable=too-many-locals if trace_exporter_names is None: trace_exporter_names = [] if metric_exporter_names is None: From 774abd22d4a146ea323ff45454bc28c77ba01235 Mon Sep 17 00:00:00 2001 From: Riccardo Magliocchetti Date: Fri, 16 Jan 2026 11:03:10 +0100 Subject: [PATCH 11/15] Rework _RuleBasedTracerConfigurator interface to match the one from declarative config schema --- .../src/opentelemetry/sdk/trace/__init__.py | 42 ++++++++++++------- opentelemetry-sdk/tests/test_configurator.py | 4 +- opentelemetry-sdk/tests/trace/test_trace.py | 17 +++++--- 3 files changed, 41 insertions(+), 22 deletions(-) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py index 56e8b810e8..15b4e054b6 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py @@ -16,6 +16,7 @@ import abc import atexit import concurrent.futures +import fnmatch import json import logging import os @@ -1240,27 +1241,38 @@ def start_span( # pylint: disable=too-many-locals _TracerConfiguratorT = Callable[[InstrumentationScope], _TracerConfig] -_TracerConfiguratorRulesPredicateT = Callable[ - [Optional[InstrumentationScope]], bool -] +_TracerConfiguratorRulesPredicateT = Callable[[InstrumentationScope], bool] _TracerConfiguratorRulesT = Sequence[ Tuple[_TracerConfiguratorRulesPredicateT, _TracerConfig] ] -class _RuleBaseTracerConfigurator: - def __init__(self, *, rules: _TracerConfiguratorRulesT): +def _tracer_name_matches_glob( + glob_pattern: str, +) -> _TracerConfiguratorRulesPredicateT: + def inner(tracer_scope: InstrumentationScope) -> bool: + return fnmatch.fnmatch(tracer_scope.name, glob_pattern) + + return inner + + +class _RuleBasedTracerConfigurator: + def __init__( + self, + *, + rules: _TracerConfiguratorRulesT, + default_config: _TracerConfig, + ): self._rules = rules + self._default_config = default_config - def __call__( - self, tracer_scope: Optional[InstrumentationScope] = None - ) -> _TracerConfig: + def __call__(self, tracer_scope: InstrumentationScope) -> _TracerConfig: for predicate, tracer_config in self._rules: if predicate(tracer_scope): return tracer_config - # if no rule matched return a default one - return _TracerConfig(is_enabled=True) + # if no rule matched return the default config + return self._default_config def _default_tracer_configurator( @@ -1271,16 +1283,18 @@ def _default_tracer_configurator( In order to update Tracers configs you need to call TracerProvider._set_tracer_configurator with a function implementing this interface returning a Tracer Config.""" - return _RuleBaseTracerConfigurator( - rules=[(lambda x: True, _TracerConfig(is_enabled=True))], + return _RuleBasedTracerConfigurator( + rules=[], + default_config=_TracerConfig(is_enabled=True), )(tracer_scope=tracer_scope) def _disable_tracer_configurator( tracer_scope: InstrumentationScope, ) -> _TracerConfig: - return _RuleBaseTracerConfigurator( - rules=[(lambda x: True, _TracerConfig(is_enabled=False))], + return _RuleBasedTracerConfigurator( + rules=[], + default_config=_TracerConfig(is_enabled=False), )(tracer_scope=tracer_scope) diff --git a/opentelemetry-sdk/tests/test_configurator.py b/opentelemetry-sdk/tests/test_configurator.py index a747df195b..ce37184486 100644 --- a/opentelemetry-sdk/tests/test_configurator.py +++ b/opentelemetry-sdk/tests/test_configurator.py @@ -65,7 +65,7 @@ ) from opentelemetry.sdk.metrics.view import Aggregation from opentelemetry.sdk.resources import SERVICE_NAME, Resource -from opentelemetry.sdk.trace import _RuleBaseTracerConfigurator +from opentelemetry.sdk.trace import _RuleBasedTracerConfigurator from opentelemetry.sdk.trace.export import ConsoleSpanExporter from opentelemetry.sdk.trace.id_generator import IdGenerator, RandomIdGenerator from opentelemetry.sdk.trace.sampling import ( @@ -632,7 +632,7 @@ def test_trace_init_custom_tracer_configurator_with_env( self, mock_entry_points ): def custom_tracer_configurator(tracer_scope): - return mock.Mock(spec=_RuleBaseTracerConfigurator)( + return mock.Mock(spec=_RuleBasedTracerConfigurator)( tracer_scope=tracer_scope ) diff --git a/opentelemetry-sdk/tests/trace/test_trace.py b/opentelemetry-sdk/tests/trace/test_trace.py index 4e3a0db969..ed05a09ce5 100644 --- a/opentelemetry-sdk/tests/trace/test_trace.py +++ b/opentelemetry-sdk/tests/trace/test_trace.py @@ -46,7 +46,8 @@ from opentelemetry.sdk.trace import ( Resource, TracerProvider, - _RuleBaseTracerConfigurator, + _RuleBasedTracerConfigurator, + _tracer_name_matches_glob, _TracerConfig, ) from opentelemetry.sdk.trace.id_generator import RandomIdGenerator @@ -2262,15 +2263,17 @@ def test_rule_based_tracer_configurator(self): # pylint: disable=protected-access rules = [ ( - lambda x: x.name == "module_name", + _tracer_name_matches_glob(glob_pattern="module_name"), _TracerConfig(is_enabled=True), ), ( - lambda x: x.name == "other_module_name", + _tracer_name_matches_glob(glob_pattern="other_module_name"), _TracerConfig(is_enabled=False), ), ] - configurator = _RuleBaseTracerConfigurator(rules=rules) + configurator = _RuleBasedTracerConfigurator( + rules=rules, default_config=_TracerConfig(is_enabled=True) + ) tracer_provider = trace.TracerProvider() tracer = tracer_provider.get_tracer( @@ -2306,11 +2309,13 @@ def test_rule_based_tracer_configurator_default_when_rules_dont_match( # pylint: disable=protected-access rules = [ ( - lambda x: x.name == "module_name", + _tracer_name_matches_glob(glob_pattern="module_name"), _TracerConfig(is_enabled=False), ), ] - configurator = _RuleBaseTracerConfigurator(rules=rules) + configurator = _RuleBasedTracerConfigurator( + rules=rules, default_config=_TracerConfig(is_enabled=True) + ) tracer_provider = trace.TracerProvider() tracer = tracer_provider.get_tracer( From 3a51dee88f7874be244f8fd94c762de3f46da646 Mon Sep 17 00:00:00 2001 From: Riccardo Magliocchetti Date: Fri, 16 Jan 2026 11:37:13 +0100 Subject: [PATCH 12/15] More correct nooptracer and more test coverage --- .../src/opentelemetry/trace/__init__.py | 5 +- .../tests/test_implementation.py | 92 +++++++++++++++++-- 2 files changed, 86 insertions(+), 11 deletions(-) diff --git a/opentelemetry-api/src/opentelemetry/trace/__init__.py b/opentelemetry-api/src/opentelemetry/trace/__init__.py index a23bbe8b80..064412d2f1 100644 --- a/opentelemetry-api/src/opentelemetry/trace/__init__.py +++ b/opentelemetry-api/src/opentelemetry/trace/__init__.py @@ -471,7 +471,10 @@ def start_span( record_exception: bool = True, set_status_on_exception: bool = True, ) -> "Span": - parent_span_context = get_current_span(context).get_span_context() + current_span = get_current_span(context) + if isinstance(current_span, NonRecordingSpan): + return current_span + parent_span_context = current_span.get_span_context() if parent_span_context is not None and not isinstance( parent_span_context, SpanContext ): diff --git a/opentelemetry-api/tests/test_implementation.py b/opentelemetry-api/tests/test_implementation.py index 4747fa2d9e..f82497cb8d 100644 --- a/opentelemetry-api/tests/test_implementation.py +++ b/opentelemetry-api/tests/test_implementation.py @@ -17,6 +17,63 @@ from opentelemetry import trace +class RecordingSpan(trace.Span): + def __init__(self, context: trace.SpanContext) -> None: + self._context = context + + def get_span_context(self) -> trace.SpanContext: + return self._context + + def is_recording(self) -> bool: + return True + + def end(self, end_time=None) -> None: + pass + + def set_attributes(self, attributes) -> None: + pass + + def set_attribute(self, key, value) -> None: + pass + + def add_event( + self, + name: str, + attributes=None, + timestamp=None, + ) -> None: + pass + + def add_link( + self, + context, + attributes=None, + ) -> None: + pass + + def update_name(self, name) -> None: + pass + + def set_status( + self, + status, + description=None, + ) -> None: + pass + + def record_exception( + self, + exception, + attributes=None, + timestamp=None, + escaped=False, + ) -> None: + pass + + def __repr__(self) -> str: + return f"RecordingSpan({self._context!r})" + + class TestAPIOnlyImplementation(unittest.TestCase): """ This test is in place to ensure the API is returning values that @@ -42,23 +99,38 @@ def test_default_tracer(self): self.assertFalse(span2.get_span_context().is_valid) self.assertIs(span2.is_recording(), False) - def test_default_tracer_context_propagation(self): + def test_default_tracer_context_propagation_recording_span(self): tracer_provider = trace.NoOpTracerProvider() tracer = tracer_provider.get_tracer(__name__) - ctx = trace.set_span_in_context( - trace.NonRecordingSpan( - trace.SpanContext( - 2604504634922341076776623263868986797, - 5213367945872657620, - False, - trace.TraceFlags(0x01), - ) - ) + span_context = trace.SpanContext( + 2604504634922341076776623263868986797, + 5213367945872657620, + False, + trace.TraceFlags(0x01), ) + ctx = trace.set_span_in_context(RecordingSpan(context=span_context)) with tracer.start_span("test", context=ctx) as span: self.assertTrue(span.get_span_context().is_valid) + self.assertEqual(span.get_span_context(), span_context) self.assertIs(span.is_recording(), False) + def test_default_tracer_context_propagation_non_recording_span(self): + tracer_provider = trace.NoOpTracerProvider() + tracer = tracer_provider.get_tracer(__name__) + ctx = trace.set_span_in_context(trace.INVALID_SPAN) + with tracer.start_span("test", context=ctx) as span: + self.assertFalse(span.get_span_context().is_valid) + self.assertIs(span, trace.INVALID_SPAN) + + def test_default_tracer_context_propagation_with_invalid_context(self): + tracer_provider = trace.NoOpTracerProvider() + tracer = tracer_provider.get_tracer(__name__) + ctx = trace.set_span_in_context( + RecordingSpan(context="invalid_context") + ) + with self.assertRaises(TypeError): + tracer.start_span("test", context=ctx) + def test_span(self): with self.assertRaises(TypeError): # pylint: disable=abstract-class-instantiated From 6bf38a7da79865ba7c0cf70cb638d57c7bb7a3aa Mon Sep 17 00:00:00 2001 From: Riccardo Magliocchetti Date: Tue, 20 Jan 2026 09:38:55 +0100 Subject: [PATCH 13/15] Ignore typechecking in validation test --- opentelemetry-api/tests/test_implementation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/opentelemetry-api/tests/test_implementation.py b/opentelemetry-api/tests/test_implementation.py index f82497cb8d..28931298e0 100644 --- a/opentelemetry-api/tests/test_implementation.py +++ b/opentelemetry-api/tests/test_implementation.py @@ -126,7 +126,7 @@ def test_default_tracer_context_propagation_with_invalid_context(self): tracer_provider = trace.NoOpTracerProvider() tracer = tracer_provider.get_tracer(__name__) ctx = trace.set_span_in_context( - RecordingSpan(context="invalid_context") + RecordingSpan(context="invalid_context") # type: ignore[reportArgumentType] ) with self.assertRaises(TypeError): tracer.start_span("test", context=ctx) From 9726fec3aed886f00e77edab0faa30e4afc7ef23 Mon Sep 17 00:00:00 2001 From: Riccardo Magliocchetti Date: Thu, 22 Jan 2026 11:05:10 +0100 Subject: [PATCH 14/15] Handle invalid span context more gracefully --- opentelemetry-api/src/opentelemetry/trace/__init__.py | 7 +++++-- opentelemetry-api/tests/test_implementation.py | 4 ++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/opentelemetry-api/src/opentelemetry/trace/__init__.py b/opentelemetry-api/src/opentelemetry/trace/__init__.py index 064412d2f1..9895e0cb05 100644 --- a/opentelemetry-api/src/opentelemetry/trace/__init__.py +++ b/opentelemetry-api/src/opentelemetry/trace/__init__.py @@ -478,9 +478,12 @@ def start_span( if parent_span_context is not None and not isinstance( parent_span_context, SpanContext ): - raise TypeError( - "parent_span_context must be a SpanContext or None." + logger.warning( + "Invalid Span Context for %s: %s", + current_span, + parent_span_context, ) + parent_span_context = None return NonRecordingSpan(context=parent_span_context) diff --git a/opentelemetry-api/tests/test_implementation.py b/opentelemetry-api/tests/test_implementation.py index 28931298e0..dd1620472a 100644 --- a/opentelemetry-api/tests/test_implementation.py +++ b/opentelemetry-api/tests/test_implementation.py @@ -128,8 +128,8 @@ def test_default_tracer_context_propagation_with_invalid_context(self): ctx = trace.set_span_in_context( RecordingSpan(context="invalid_context") # type: ignore[reportArgumentType] ) - with self.assertRaises(TypeError): - tracer.start_span("test", context=ctx) + with tracer.start_span("test", context=ctx) as span: + self.assertIsNone(span.get_span_context()) def test_span(self): with self.assertRaises(TypeError): From 4714f5e0ff78980618bb7f5d8d40eba02dade93e Mon Sep 17 00:00:00 2001 From: Riccardo Magliocchetti Date: Thu, 22 Jan 2026 11:25:15 +0100 Subject: [PATCH 15/15] Return invalid span in case span context is invalid --- opentelemetry-api/src/opentelemetry/trace/__init__.py | 4 ++-- opentelemetry-api/tests/test_implementation.py | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/opentelemetry-api/src/opentelemetry/trace/__init__.py b/opentelemetry-api/src/opentelemetry/trace/__init__.py index 9895e0cb05..5ecc025122 100644 --- a/opentelemetry-api/src/opentelemetry/trace/__init__.py +++ b/opentelemetry-api/src/opentelemetry/trace/__init__.py @@ -479,11 +479,11 @@ def start_span( parent_span_context, SpanContext ): logger.warning( - "Invalid Span Context for %s: %s", + "Invalid span context for %s: %s", current_span, parent_span_context, ) - parent_span_context = None + return INVALID_SPAN return NonRecordingSpan(context=parent_span_context) diff --git a/opentelemetry-api/tests/test_implementation.py b/opentelemetry-api/tests/test_implementation.py index dd1620472a..769f1ee8e0 100644 --- a/opentelemetry-api/tests/test_implementation.py +++ b/opentelemetry-api/tests/test_implementation.py @@ -129,7 +129,8 @@ def test_default_tracer_context_propagation_with_invalid_context(self): RecordingSpan(context="invalid_context") # type: ignore[reportArgumentType] ) with tracer.start_span("test", context=ctx) as span: - self.assertIsNone(span.get_span_context()) + self.assertFalse(span.get_span_context().is_valid) + self.assertIs(span, trace.INVALID_SPAN) def test_span(self): with self.assertRaises(TypeError):