diff --git a/CHANGELOG.md b/CHANGELOG.md index bab44ceb635..20af4a449cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ([#4910](https://github.com/open-telemetry/opentelemetry-python/pull/4910)) - Add configurable `max_export_batch_size` to OTLP HTTP metrics exporter ([#4576](https://github.com/open-telemetry/opentelemetry-python/pull/4576)) +- `opentelemetry-sdk`: cache TracerConfig into the tracer, this changes an internal interface. Only one Tracer with the same instrumentation scope will be created + ([#5007](https://github.com/open-telemetry/opentelemetry-python/pull/5007)) ## Version 1.40.0/0.61b0 (2026-03-04) diff --git a/opentelemetry-sdk/benchmarks/trace/test_benchmark_trace.py b/opentelemetry-sdk/benchmarks/trace/test_benchmark_trace.py index c2d5590144c..7864abbd781 100644 --- a/opentelemetry-sdk/benchmarks/trace/test_benchmark_trace.py +++ b/opentelemetry-sdk/benchmarks/trace/test_benchmark_trace.py @@ -26,7 +26,7 @@ sampling, ) -tracer = TracerProvider( +tracer_provider = TracerProvider( sampler=sampling.DEFAULT_ON, resource=Resource( { @@ -35,10 +35,11 @@ "service.instance.id": "123ab456-a123-12ab-12ab-12340a1abc12", } ), -).get_tracer("sdk_tracer_provider") +) +tracer = tracer_provider.get_tracer("sdk_tracer_provider") -@pytest.fixture(params=[None, 0, 1, 10, 50]) +@pytest.fixture(params=[0, 1, 10, 50]) def num_tracer_configurator_rules(request): return request.param @@ -81,18 +82,13 @@ def tracer_configurator(tracer_scope): default_config=_TracerConfig(is_enabled=True), )(tracer_scope=tracer_scope) - tracer_provider = tracer._tracer_provider tracer_provider._set_tracer_configurator( tracer_configurator=tracer_configurator ) - if num_tracer_configurator_rules is None: - tracer._tracer_provider = None benchmark(benchmark_start_span) tracer_provider._set_tracer_configurator( tracer_configurator=_default_tracer_configurator ) - if num_tracer_configurator_rules is None: - tracer._tracer_provider = tracer_provider def test_simple_start_as_current_span(benchmark): diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py index e0b639d81cf..f3935b8dab7 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py @@ -25,7 +25,6 @@ import typing import weakref from dataclasses import dataclass -from functools import lru_cache from os import environ from time import time_ns from types import MappingProxyType, TracebackType @@ -1103,6 +1102,10 @@ class _Span(Span): class _TracerConfig: is_enabled: bool + @classmethod + def default(cls): + return cls(is_enabled=True) + class Tracer(trace_api.Tracer): """See `opentelemetry.trace.Tracer`.""" @@ -1120,7 +1123,7 @@ def __init__( instrumentation_scope: InstrumentationScope, *, meter_provider: Optional[metrics_api.MeterProvider] = None, - _tracer_provider: Optional["TracerProvider"] = None, + _tracer_config: Optional[_TracerConfig] = None, ) -> None: self.sampler = sampler self.resource = resource @@ -1129,20 +1132,17 @@ def __init__( self.instrumentation_info = instrumentation_info self._span_limits = span_limits self._instrumentation_scope = instrumentation_scope - self._tracer_provider = _tracer_provider + self._tracer_config = _tracer_config or _TracerConfig.default() meter_provider = meter_provider or metrics_api.get_meter_provider() self._tracer_metrics = TracerMetrics(meter_provider) + def _set_tracer_config(self, tracer_config: _TracerConfig): + self._tracer_config = tracer_config + def _is_enabled(self) -> bool: """If the tracer is not enabled, start_span will create a NonRecordingSpan""" - - if not self._tracer_provider: - return True - tracer_config = self._tracer_provider._tracer_configurator( # pylint: disable=protected-access - self._instrumentation_scope - ) - return tracer_config.is_enabled + return self._tracer_config.is_enabled @_agnosticcontextmanager # pylint: disable=protected-access def start_as_current_span( @@ -1297,7 +1297,6 @@ def __call__(self, tracer_scope: InstrumentationScope) -> _TracerConfig: return self._default_config -@lru_cache def _default_tracer_configurator( tracer_scope: InstrumentationScope, ) -> _TracerConfig: @@ -1308,11 +1307,10 @@ def _default_tracer_configurator( implementing this interface returning a Tracer Config.""" return _RuleBasedTracerConfigurator( rules=[], - default_config=_TracerConfig(is_enabled=True), + default_config=_TracerConfig.default(), )(tracer_scope=tracer_scope) -@lru_cache def _disable_tracer_configurator( tracer_scope: InstrumentationScope, ) -> _TracerConfig: @@ -1365,28 +1363,42 @@ def __init__( self._tracer_configurator = ( _tracer_configurator or _default_tracer_configurator ) + self._tracers_lock = threading.Lock() + self._tracers: dict[InstrumentationScope, Tracer] = {} def _set_tracer_configurator( self, *, tracer_configurator: _TracerConfiguratorT ): """This is the function used to update the TracerProvider TracerConfigurator - Setting a new TracerConfigurator for a TracerProvider will make all the Tracers created from - this TracerProvider reference the new TracerConfigurator. - - The tracer checks its configuration at span creation time. Since this is an hot path - it's important that it'll execute quickly so it is suggested to memoize it with - functools.lru_cache. - If your TracerConfigurator is using some dynamic rules you can still use functools.lru_cache - decorator if you remember to clear its cache with the decorator cache_clear() function when - the rules change. + Setting a new TracerConfigurator for a TracerProvider will update the + TracerConfig of all Tracers create by this TracerProvider. """ self._tracer_configurator = tracer_configurator + with self._tracers_lock: + for instrumentation_scope, tracer in self._tracers.items(): + tracer_config = self._apply_tracer_configurator( + instrumentation_scope + ) + # pylint: disable-next=protected-access + tracer._set_tracer_config(tracer_config) @property def resource(self) -> Resource: return self._resource + def _apply_tracer_configurator( + self, instrumentation_scope: InstrumentationScope + ): + try: + return self._tracer_configurator(instrumentation_scope) + except Exception: # pylint: disable=broad-exception-caught + logger.exception( + "Failed to create a Tracer Config for %s, using default Tracer config", + instrumentation_scope, + ) + return _TracerConfig.default() + def get_tracer( self, instrumenting_module_name: str, @@ -1417,23 +1429,33 @@ def get_tracer( schema_url, ) - 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, - ), - meter_provider=self._meter_provider, - _tracer_provider=self, + instrumentation_scope = InstrumentationScope( + instrumenting_module_name, + instrumenting_library_version, + schema_url, + attributes, ) + with self._tracers_lock: + if instrumentation_scope in self._tracers: + return self._tracers[instrumentation_scope] + + tracer_config = self._apply_tracer_configurator( + instrumentation_scope + ) + tracer = Tracer( + self.sampler, + self.resource, + self._active_span_processor, + self.id_generator, + instrumentation_info, + self._span_limits, + instrumentation_scope, + meter_provider=self._meter_provider, + _tracer_config=tracer_config, + ) + self._tracers[instrumentation_scope] = tracer + return tracer def add_span_processor(self, span_processor: SpanProcessor) -> None: diff --git a/opentelemetry-sdk/tests/trace/test_trace.py b/opentelemetry-sdk/tests/trace/test_trace.py index 0f617523163..72187abb9ed 100644 --- a/opentelemetry-sdk/tests/trace/test_trace.py +++ b/opentelemetry-sdk/tests/trace/test_trace.py @@ -16,6 +16,7 @@ # pylint: disable=no-member import copy +import dataclasses import shutil import subprocess import unittest @@ -196,6 +197,43 @@ def test_get_tracer_sdk(self): {"key1": "value1", "key2": 6}, ) + def test_get_tracer_sdk_returns_same_tracer_when_called_with_same_instrumentation_scope( + self, + ): + tracer_provider = trace.TracerProvider() + tracer1 = tracer_provider.get_tracer( + "module_name", + "library_version", + "schema_url", + {"key1": "value1", "key2": 6}, + ) + + tracer2 = tracer_provider.get_tracer( + "module_name", + "library_version", + "schema_url", + {"key1": "value1", "key2": 6}, + ) + + self.assertEqual(tracer1, tracer2) + self.assertTrue(tracer1 is tracer2) + + def test_get_tracer_sdk_sets_default_tracer_config_if_configurator_raises( + self, + ): + def raising_tracer_configurator(tracer_scope): + raise ValueError() + + tracer_provider = trace.TracerProvider( + _tracer_configurator=raising_tracer_configurator + ) + tracer = tracer_provider.get_tracer( + "module_name", + "library_version", + ) + # pylint: disable=protected-access + self.assertEqual(tracer._tracer_config, _TracerConfig.default()) + @mock.patch.dict("os.environ", {OTEL_SDK_DISABLED: "true"}) def test_get_tracer_with_sdk_disabled(self): tracer_provider = trace.TracerProvider() @@ -2259,6 +2297,23 @@ def test_child_parent_span_exception(self): self.assertTupleEqual(parent_span.events, ()) +class TestTracerConfig(unittest.TestCase): + def test_default(self): + self.assertEqual( + _TracerConfig.default(), + _TracerConfig(is_enabled=True), + ) + + def test_equality(self): + config = _TracerConfig(is_enabled=True) + same_config = _TracerConfig(is_enabled=True) + other_config = _TracerConfig(is_enabled=False) + + self.assertEqual(config, same_config) + self.assertNotEqual(config, other_config) + self.assertNotEqual(config, "string") + + # pylint: disable=protected-access class TestTracerProvider(unittest.TestCase): @patch("opentelemetry.sdk.trace.sampling._get_from_env_or_default") @@ -2297,6 +2352,26 @@ def test_default_tracer_configurator(self): self.assertEqual(tracer._is_enabled(), True) self.assertEqual(other_tracer._is_enabled(), True) + def test_set_tracer_configurator_sets_default_tracer_config_if_configurator_raises( + self, + ): + def raising_tracer_configurator(tracer_scope): + raise ValueError() + + tracer_provider = trace.TracerProvider() + tracer = tracer_provider.get_tracer( + "module_name", + "library_version", + ) + tracer_provider._set_tracer_configurator( + tracer_configurator=raising_tracer_configurator + ) + # pylint: disable=protected-access + self.assertEqual( + dataclasses.asdict(tracer._tracer_config), + dataclasses.asdict(_TracerConfig.default()), + ) + def test_rule_based_tracer_configurator(self): # pylint: disable=protected-access rules = [