diff --git a/CHANGELOG.md b/CHANGELOG.md index 87a6fd6d38..3f35da1a4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ([#5007](https://github.com/open-telemetry/opentelemetry-python/pull/5007)) - Redo OTLPMetricExporter unit tests of `max_export_batch_size` to use real `export` ([#5036](https://github.com/open-telemetry/opentelemetry-python/pull/5036)) +- `opentelemetry-sdk`: Implement experimental Logger configurator + ([#4980](https://github.com/open-telemetry/opentelemetry-python/pull/4980)) ## Version 1.40.0/0.61b0 (2026-03-04) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/__init__.py index a982b9de71..08b0a38b35 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/__init__.py @@ -42,6 +42,7 @@ LoggingHandler, LogRecordProcessor, ) +from opentelemetry.sdk._logs._internal import LoggerConfiguratorT from opentelemetry.sdk._logs.export import ( BatchLogRecordProcessor, LogRecordExporter, @@ -52,6 +53,7 @@ OTEL_EXPORTER_OTLP_METRICS_PROTOCOL, OTEL_EXPORTER_OTLP_PROTOCOL, OTEL_EXPORTER_OTLP_TRACES_PROTOCOL, + OTEL_PYTHON_LOGGER_CONFIGURATOR, OTEL_PYTHON_METER_CONFIGURATOR, OTEL_PYTHON_TRACER_CONFIGURATOR, OTEL_TRACES_SAMPLER, @@ -177,6 +179,10 @@ def _get_meter_configurator() -> str | None: return environ.get(OTEL_PYTHON_METER_CONFIGURATOR, None) +def _get_logger_configurator() -> str | None: + return environ.get(OTEL_PYTHON_LOGGER_CONFIGURATOR, None) + + def _get_exporter_entry_point( exporter_name: str, signal_type: Literal["traces", "metrics", "logs"] ): @@ -297,6 +303,7 @@ def _init_metrics( set_meter_provider(provider) +# pylint: disable-next=too-many-locals def _init_logging( exporters: dict[str, Type[LogRecordExporter]], resource: Resource | None = None, @@ -305,8 +312,11 @@ def _init_logging( log_record_processors: Sequence[LogRecordProcessor] | None = None, export_log_record_processor: _ConfigurationExporterLogRecordProcessorT | None = None, + logger_configurator: LoggerConfiguratorT | None = None, ): - provider = LoggerProvider(resource=resource) + provider = LoggerProvider( + resource=resource, logger_configurator=logger_configurator + ) set_logger_provider(provider) exporter_args_map = exporter_args_map or {} @@ -377,6 +387,27 @@ def overwritten_config_fn(*args, **kwargs): logging.basicConfig = wrapper(logging.basicConfig) +def _import_logger_configurator( + logger_configurator_name: str | None, +) -> LoggerConfiguratorT | None: + if not logger_configurator_name: + return None + + try: + _, logger_configurator_impl = _import_config_components( + [logger_configurator_name.strip()], + "_opentelemetry_logger_configurator", + )[0] + except Exception as exc: # pylint: disable=broad-exception-caught + _logger.warning( + "Using default logger configurator. Failed to load logger configurator, %s: %s", + logger_configurator_name, + exc, + ) + return None + return logger_configurator_impl + + def _import_tracer_configurator( tracer_configurator_name: str | None, ) -> _TracerConfiguratorT | None: @@ -540,6 +571,7 @@ def _initialize_components( | None = None, tracer_configurator: _TracerConfiguratorT | None = None, meter_configurator: _MeterConfiguratorT | None = None, + logger_configurator: LoggerConfiguratorT | None = None, ): # pylint: disable=too-many-locals if trace_exporter_names is None: @@ -576,6 +608,11 @@ def _initialize_components( meter_configurator = _import_meter_configurator( meter_configurator_name ) + if logger_configurator is None: + logger_configurator_name = _get_logger_configurator() + logger_configurator = _import_logger_configurator( + logger_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" @@ -613,6 +650,7 @@ def _initialize_components( exporter_args_map=exporter_args_map, log_record_processors=log_record_processors, export_log_record_processor=export_log_record_processor, + logger_configurator=logger_configurator, ) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py index d6a4aa16ab..ec9a033c7b 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py @@ -22,11 +22,20 @@ import threading import traceback import warnings +from _weakrefset import WeakSet from dataclasses import dataclass, field from os import environ from threading import Lock from time import time_ns -from typing import Any, Callable, Tuple, Union, cast, overload # noqa +from typing import ( # noqa + Any, + Callable, + Sequence, + Tuple, + Union, + cast, + overload, +) from typing_extensions import deprecated @@ -51,7 +60,10 @@ ) from opentelemetry.sdk.resources import Resource from opentelemetry.sdk.util import ns_to_iso_str -from opentelemetry.sdk.util.instrumentation import InstrumentationScope +from opentelemetry.sdk.util._configurator import RuleBasedConfigurator +from opentelemetry.sdk.util.instrumentation import ( + InstrumentationScope, +) from opentelemetry.semconv._incubating.attributes import code_attributes from opentelemetry.semconv.attributes import exception_attributes from opentelemetry.trace import ( @@ -63,6 +75,8 @@ _DEFAULT_OTEL_ATTRIBUTE_COUNT_LIMIT = 128 _ENV_VALUE_UNSET = "" +_logger = logging.getLogger(__name__) + class BytesEncoder(json.JSONEncoder): def default(self, o): @@ -637,6 +651,15 @@ def flush(self) -> None: thread.start() +@dataclass +class LoggerConfig: + is_enabled: bool = True + + @classmethod + def default(cls) -> LoggerConfig: + return LoggerConfig() + + class Logger(APILogger): def __init__( self, @@ -648,6 +671,7 @@ def __init__( instrumentation_scope: InstrumentationScope, *, logger_metrics: LoggerMetrics, + logger_config: LoggerConfig, ): super().__init__( instrumentation_scope.name, @@ -659,6 +683,17 @@ def __init__( self._multi_log_record_processor = multi_log_record_processor self._instrumentation_scope = instrumentation_scope self._logger_metrics = logger_metrics + self._logger_config = logger_config + + def _is_enabled(self) -> bool: + return self._logger_config.is_enabled + + def set_logger_config(self, logger_config: LoggerConfig) -> None: + self._logger_config = logger_config + + @property + def instrumentation_scope(self): + return self._instrumentation_scope @property def resource(self): @@ -681,6 +716,8 @@ def emit( """Emits the :class:`ReadWriteLogRecord` by setting instrumentation scope and forwarding to the processor. """ + if not self._is_enabled(): + return # If a record is provided, use it directly if record is not None: if not isinstance(record, ReadWriteLogRecord): @@ -715,6 +752,22 @@ def emit( self._multi_log_record_processor.on_emit(writable_record) +LoggerConfiguratorT = Callable[[InstrumentationScope], LoggerConfig] +RuleBasedLoggerConfigurator = RuleBasedConfigurator[LoggerConfig] + + +def _default_logger_configurator( + _logger_scope: InstrumentationScope, +) -> LoggerConfig: + return LoggerConfig.default() + + +def _disable_logger_configurator( + _logger_scope: InstrumentationScope, +) -> LoggerConfig: + return LoggerConfig(is_enabled=False) + + class LoggerProvider(APILoggerProvider): def __init__( self, @@ -725,6 +778,7 @@ def __init__( | None = None, *, meter_provider: MeterProvider | None = None, + logger_configurator: LoggerConfiguratorT | None = None, ): if resource is None: self._resource = Resource.create({}) @@ -738,11 +792,16 @@ def __init__( ) disabled = environ.get(OTEL_SDK_DISABLED, "") self._disabled = disabled.lower().strip() == "true" + self._logger_configurator = ( + logger_configurator or _default_logger_configurator + ) self._at_exit_handler = None if shutdown_on_exit: self._at_exit_handler = atexit.register(self.shutdown) self._logger_cache = {} self._logger_cache_lock = Lock() + self._active_loggers: WeakSet[Logger] = WeakSet() + self._active_loggers_lock = Lock() @property def resource(self): @@ -755,16 +814,14 @@ def _get_logger_no_cache( schema_url: str | None = None, attributes: _ExtendedAttributes | None = None, ) -> Logger: + scope = InstrumentationScope(name, version, schema_url, attributes) + return Logger( self._resource, self._multi_log_record_processor, - InstrumentationScope( - name, - version, - schema_url, - attributes, - ), + scope, logger_metrics=self._logger_metrics, + logger_config=self._apply_logger_configurator(scope), ) def _get_logger_cached( @@ -797,9 +854,16 @@ def get_logger( schema_url=schema_url, attributes=attributes, ) - if attributes is None: - return self._get_logger_cached(name, version, schema_url) - return self._get_logger_no_cache(name, version, schema_url, attributes) + logger = ( + self._get_logger_cached(name, version, schema_url) + if attributes is None + else self._get_logger_no_cache( + name, version, schema_url, attributes + ) + ) + with self._active_loggers_lock: + self._active_loggers.add(logger) + return logger def add_log_record_processor( self, log_record_processor: LogRecordProcessor @@ -812,6 +876,37 @@ def add_log_record_processor( log_record_processor ) + def set_logger_configurator( + self, *, logger_configurator: LoggerConfiguratorT + ): + """Set a new LoggerConfigurator for this LoggerProvider. + + Setting a new LoggerConfigurator will result in the configurator being called + for each outstanding Logger and for any newly created loggers thereafter. + Therefore, it is important that the provided function returns quickly. + """ + self._logger_configurator = logger_configurator + with self._active_loggers_lock: + for logger in self._active_loggers: + logger.set_logger_config( + self._apply_logger_configurator( + logger.instrumentation_scope + ) + ) + + def _apply_logger_configurator( + self, instrumentation_scope: InstrumentationScope + ) -> LoggerConfig: + try: + return self._logger_configurator(instrumentation_scope) + # pylint: disable-next=broad-exception-caught + except Exception: + _logger.exception( + "logger configurator failed for scope '%s', using default config", + instrumentation_scope.name, + ) + return LoggerConfig.default() + def shutdown(self) -> None: """Shuts down the log processors.""" self._multi_log_record_processor.shutdown() diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/environment_variables/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/environment_variables/__init__.py index 4795e641e5..2959163eed 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/environment_variables/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/environment_variables/__init__.py @@ -826,3 +826,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_LOGGER_CONFIGURATOR = "OTEL_PYTHON_LOGGER_CONFIGURATOR" +""" +.. envvar:: OTEL_PYTHON_LOGGER_CONFIGURATOR + +The :envvar:`OTEL_PYTHON_LOGGER_CONFIGURATOR` environment variable allows users to set a +custom Logger Configurator function. +Default: opentelemetry.sdk._logs._internal._default_logger_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/src/opentelemetry/sdk/metrics/_internal/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/__init__.py index 957cdaad20..e6583d1c5f 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/__init__.py @@ -63,9 +63,9 @@ SdkConfiguration, ) from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.util._configurator import RuleBasedConfigurator from opentelemetry.sdk.util.instrumentation import ( InstrumentationScope, - _InstrumentationScopePredicateT, ) from opentelemetry.util._once import Once from opentelemetry.util.types import ( @@ -414,9 +414,7 @@ def _get_exemplar_filter(exemplar_filter: str) -> ExemplarFilter: _MeterConfiguratorT = Callable[[InstrumentationScope], _MeterConfig] -_MeterConfiguratorRulesT = Sequence[ - tuple[_InstrumentationScopePredicateT, _MeterConfig] -] +_RuleBasedMeterConfigurator = RuleBasedConfigurator[_MeterConfig] def _default_meter_configurator( @@ -431,27 +429,6 @@ def _disable_meter_configurator( return _MeterConfig(is_enabled=False) -class _RuleBasedMeterConfigurator: - def __init__( - self, - *, - rules: _MeterConfiguratorRulesT, - default_config: _MeterConfig, - ): - self._rules = rules - self._default_config = default_config - - def __call__(self, meter_scope: InstrumentationScope) -> _MeterConfig: - for predicate, meter_config in self._rules: - if predicate(meter_scope): - return meter_config - # by default return default config - return self._default_config - - def update_rules(self, rules: _MeterConfiguratorRulesT) -> None: - self._rules = rules - - class MeterProvider(APIMeterProvider): r"""See `opentelemetry.metrics.MeterProvider`. diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py index 796a04d945..18fced7061 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py @@ -65,10 +65,10 @@ from opentelemetry.sdk.trace._tracer_metrics import TracerMetrics from opentelemetry.sdk.trace.id_generator import IdGenerator, RandomIdGenerator from opentelemetry.sdk.util import BoundedList +from opentelemetry.sdk.util._configurator import RuleBasedConfigurator from opentelemetry.sdk.util.instrumentation import ( InstrumentationInfo, InstrumentationScope, - _InstrumentationScopePredicateT, ) from opentelemetry.semconv.attributes.exception_attributes import ( EXCEPTION_ESCAPED, @@ -1264,28 +1264,7 @@ def start_span( # pylint: disable=too-many-locals _TracerConfiguratorT = Callable[[InstrumentationScope], _TracerConfig] -_TracerConfiguratorRulesT = Sequence[ - tuple[_InstrumentationScopePredicateT, _TracerConfig] -] - - -class _RuleBasedTracerConfigurator: - def __init__( - self, - *, - rules: _TracerConfiguratorRulesT, - default_config: _TracerConfig, - ): - self._rules = rules - self._default_config = default_config - - 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 the default config - return self._default_config +_RuleBasedTracerConfigurator = RuleBasedConfigurator[_TracerConfig] def _default_tracer_configurator( @@ -1299,7 +1278,7 @@ def _default_tracer_configurator( return _RuleBasedTracerConfigurator( rules=[], default_config=_TracerConfig.default(), - )(tracer_scope=tracer_scope) + )(tracer_scope) def _disable_tracer_configurator( @@ -1308,7 +1287,7 @@ def _disable_tracer_configurator( return _RuleBasedTracerConfigurator( rules=[], default_config=_TracerConfig(is_enabled=False), - )(tracer_scope=tracer_scope) + )(tracer_scope) class TracerProvider(trace_api.TracerProvider): diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/util/_configurator.py b/opentelemetry-sdk/src/opentelemetry/sdk/util/_configurator.py new file mode 100644 index 0000000000..c7c9e78c96 --- /dev/null +++ b/opentelemetry-sdk/src/opentelemetry/sdk/util/_configurator.py @@ -0,0 +1,38 @@ +# 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 typing import Generic, Sequence, TypeVar + +from opentelemetry.sdk.util.instrumentation import ( + InstrumentationScope, + _InstrumentationScopePredicateT, +) + +ConfigT = TypeVar("ConfigT") +ConfiguratorRulesT = Sequence[tuple[_InstrumentationScopePredicateT, ConfigT]] + + +class RuleBasedConfigurator(Generic[ConfigT]): + def __init__(self, *, rules: ConfiguratorRulesT, default_config: ConfigT): + self._rules = rules + self._default_config = default_config + + def __call__(self, scope: InstrumentationScope) -> ConfigT: + for predicate, config in self._rules: + if predicate(scope): + return config + # by default return default config + return self._default_config + + def update_rules(self, rules: ConfiguratorRulesT) -> None: + self._rules = rules diff --git a/opentelemetry-sdk/tests/logs/test_logs.py b/opentelemetry-sdk/tests/logs/test_logs.py index 6a9c95685c..7469592ea1 100644 --- a/opentelemetry-sdk/tests/logs/test_logs.py +++ b/opentelemetry-sdk/tests/logs/test_logs.py @@ -26,13 +26,19 @@ ReadableLogRecord, ) from opentelemetry.sdk._logs._internal import ( + LoggerConfig, LoggerMetrics, NoOpLogger, + RuleBasedLoggerConfigurator, SynchronousMultiLogRecordProcessor, + _disable_logger_configurator, ) from opentelemetry.sdk.environment_variables import OTEL_SDK_DISABLED from opentelemetry.sdk.resources import Resource -from opentelemetry.sdk.util.instrumentation import InstrumentationScope +from opentelemetry.sdk.util.instrumentation import ( + InstrumentationScope, + _scope_name_matches_glob, +) class TestLoggerProvider(unittest.TestCase): @@ -95,6 +101,136 @@ def test_logger_provider_init(self, resource_patch): ) self.assertIsNotNone(logger_provider._at_exit_handler) + def test_default_logger_configurator(self): + provider = LoggerProvider() + logger = provider.get_logger("module_name", "1.0", "schema_url") + other_logger = provider.get_logger( + "other_module_name", "1.0", "schema_url" + ) + self.assertTrue(logger._is_enabled()) + self.assertTrue(other_logger._is_enabled()) + + def test_logger_provider_with_disabled_configurator(self): + provider = LoggerProvider( + logger_configurator=_disable_logger_configurator + ) + logger = provider.get_logger("test") + self.assertFalse(logger._is_enabled()) + + def test_logger_provider_with_custom_configurator(self): + def configurator(scope): + if scope.name == "disabled_logger": + return LoggerConfig(is_enabled=False) + return LoggerConfig.default() + + provider = LoggerProvider(logger_configurator=configurator) + enabled = provider.get_logger("enabled_logger") + disabled = provider.get_logger("disabled_logger") + self.assertTrue(enabled._is_enabled()) + self.assertFalse(disabled._is_enabled()) + + def test_set_logger_configurator_updates_existing_loggers(self): + provider = LoggerProvider() + logger = provider.get_logger("test") + self.assertTrue(logger._is_enabled()) + + provider.set_logger_configurator( + logger_configurator=_disable_logger_configurator + ) + self.assertFalse(logger._is_enabled()) + + def test_set_logger_configurator_affects_new_loggers(self): + provider = LoggerProvider() + provider.set_logger_configurator( + logger_configurator=_disable_logger_configurator + ) + logger = provider.get_logger("new_logger") + self.assertFalse(logger._is_enabled()) + + # pylint: disable-next=no-self-use + def test_disabled_logger_skips_emit(self): + provider = LoggerProvider( + logger_configurator=_disable_logger_configurator + ) + logger = provider.get_logger("test") + processor_mock = Mock() + provider.add_log_record_processor(processor_mock) + + logger.emit( + LogRecord(observed_timestamp=0, body="should not be emitted") + ) + processor_mock.on_emit.assert_not_called() + + def test_rule_based_logger_configurator(self): + rules = [ + ( + _scope_name_matches_glob(glob_pattern="module_name"), + LoggerConfig(is_enabled=True), + ), + ( + _scope_name_matches_glob(glob_pattern="other_module_name"), + LoggerConfig(is_enabled=False), + ), + ] + configurator = RuleBasedLoggerConfigurator( + rules=rules, default_config=LoggerConfig(is_enabled=True) + ) + + provider = LoggerProvider() + logger = provider.get_logger("module_name", "1.0", "schema_url") + other_logger = provider.get_logger( + "other_module_name", "1.0", "schema_url" + ) + + self.assertTrue(logger._is_enabled()) + self.assertTrue(other_logger._is_enabled()) + + provider.set_logger_configurator(logger_configurator=configurator) + + self.assertTrue(logger._is_enabled()) + self.assertFalse(other_logger._is_enabled()) + + def test_rule_based_logger_configurator_default_when_rules_dont_match( + self, + ): + rules = [ + ( + _scope_name_matches_glob(glob_pattern="module_name"), + LoggerConfig(is_enabled=False), + ), + ] + configurator = RuleBasedLoggerConfigurator( + rules=rules, default_config=LoggerConfig(is_enabled=True) + ) + + provider = LoggerProvider() + logger = provider.get_logger("module_name", "1.0", "schema_url") + other_logger = provider.get_logger( + "other_module_name", "1.0", "schema_url" + ) + + self.assertTrue(logger._is_enabled()) + self.assertTrue(other_logger._is_enabled()) + + provider.set_logger_configurator(logger_configurator=configurator) + + self.assertFalse(logger._is_enabled()) + self.assertTrue(other_logger._is_enabled()) + + def test_rule_based_configurator_first_match_wins(self): + disabled_config = LoggerConfig(is_enabled=False) + enabled_config = LoggerConfig(is_enabled=True) + configurator = RuleBasedLoggerConfigurator( + rules=[ + (lambda s: s.name == "foo", disabled_config), + (lambda s: s.name == "foo", enabled_config), + ], + default_config=enabled_config, + ) + scope = InstrumentationScope("foo", "1.0") + result = configurator(scope) + self.assertFalse(result.is_enabled) + class TestReadableLogRecord(unittest.TestCase): def setUp(self): @@ -151,6 +287,7 @@ def _get_logger(): {"an": "attribute"}, ), logger_metrics=LoggerMetrics(NoOpMeterProvider()), + logger_config=LoggerConfig.default(), ) return logger, log_record_processor_mock diff --git a/opentelemetry-sdk/tests/test_configurator.py b/opentelemetry-sdk/tests/test_configurator.py index 720469c71f..621ac37457 100644 --- a/opentelemetry-sdk/tests/test_configurator.py +++ b/opentelemetry-sdk/tests/test_configurator.py @@ -35,12 +35,14 @@ _EXPORTER_OTLP_PROTO_HTTP, _get_exporter_names, _get_id_generator, + _get_logger_configurator, _get_meter_configurator, _get_sampler, _get_tracer_configurator, _import_config_components, _import_exporters, _import_id_generator, + _import_logger_configurator, _import_meter_configurator, _import_sampler, _import_tracer_configurator, @@ -51,12 +53,14 @@ _OTelSDKConfigurator, ) from opentelemetry.sdk._logs import LoggingHandler, LogRecordProcessor +from opentelemetry.sdk._logs._internal import RuleBasedLoggerConfigurator from opentelemetry.sdk._logs._internal.export import LogRecordExporter from opentelemetry.sdk._logs.export import ( ConsoleLogRecordExporter, SimpleLogRecordProcessor, ) from opentelemetry.sdk.environment_variables import ( + OTEL_PYTHON_LOGGER_CONFIGURATOR, OTEL_PYTHON_METER_CONFIGURATOR, OTEL_TRACES_SAMPLER, OTEL_TRACES_SAMPLER_ARG, @@ -115,9 +119,10 @@ def add_span_processor(self, processor): class DummyLoggerProvider: - def __init__(self, resource=None): + def __init__(self, resource=None, *, logger_configurator=None): self.resource = resource self.processors = [] + self._logger_configurator = logger_configurator def add_log_record_processor(self, processor): self.processors.append(processor) @@ -667,9 +672,7 @@ def test_trace_init_custom_tracer_configurator_with_env( self, mock_entry_points ): def custom_tracer_configurator(tracer_scope): - return mock.Mock(spec=_RuleBasedTracerConfigurator)( - tracer_scope=tracer_scope - ) + return mock.Mock(spec=_RuleBasedTracerConfigurator)(tracer_scope) mock_entry_points.configure_mock( return_value=[ @@ -879,6 +882,7 @@ def test_logging_init_disable_default(self, logging_mock, tracing_mock): exporter_args_map=None, log_record_processors=None, export_log_record_processor=None, + logger_configurator=None, ) @patch.dict( @@ -900,6 +904,7 @@ def test_logging_init_enable_env(self, logging_mock, tracing_mock): exporter_args_map=None, log_record_processors=None, export_log_record_processor=None, + logger_configurator=None, ) self.assertEqual(tracing_mock.call_count, 1) @@ -1041,6 +1046,7 @@ def test_initialize_components_kwargs( exporter_args_map={1: {"compression": "gzip"}}, log_record_processors=[], export_log_record_processor=SimpleLogRecordProcessor, + logger_configurator=None, ) def test_basicConfig_works_with_otel_handler(self): @@ -1139,6 +1145,67 @@ def test_dictConfig_preserves_otel_handler(self): "Should still have exactly one OpenTelemetry LoggingHandler", ) + def test_logging_init_logger_configurator_none_by_default(self): + with ResetGlobalLoggingState(): + _init_logging({}) + provider = self.set_provider_mock.call_args[0][0] + self.assertIsInstance(provider, DummyLoggerProvider) + self.assertIsNone(provider._logger_configurator) + + def test_logging_init_logger_configurator_passed_directly(self): + mock_configurator = Mock() + with ResetGlobalLoggingState(): + _init_logging({}, logger_configurator=mock_configurator) + provider = self.set_provider_mock.call_args[0][0] + self.assertIsInstance(provider, DummyLoggerProvider) + self.assertEqual(provider._logger_configurator, mock_configurator) + + @patch.dict( + "os.environ", + {OTEL_PYTHON_LOGGER_CONFIGURATOR: "non_existent_entry_point"}, + ) + def test_logging_init_custom_logger_configurator_with_env_non_existent_entry_point( + self, + ): + logger_configurator_name = _get_logger_configurator() + with self.assertLogs(level=WARNING): + logger_configurator = _import_logger_configurator( + logger_configurator_name + ) + with ResetGlobalLoggingState(): + _init_logging({}, logger_configurator=logger_configurator) + + @patch("opentelemetry.sdk._configuration.entry_points") + @patch.dict( + "os.environ", + {OTEL_PYTHON_LOGGER_CONFIGURATOR: "custom_logger_configurator"}, + ) + def test_logging_init_custom_logger_configurator_with_env( + self, mock_entry_points + ): + def custom_logger_configurator(logger_scope): + return mock.Mock(spec=RuleBasedLoggerConfigurator)(logger_scope) + + mock_entry_points.configure_mock( + return_value=[ + IterEntryPoint( + "custom_logger_configurator", + custom_logger_configurator, + ) + ] + ) + + logger_configurator_name = _get_logger_configurator() + logger_configurator = _import_logger_configurator( + logger_configurator_name + ) + with ResetGlobalLoggingState(): + _init_logging({}, logger_configurator=logger_configurator) + provider = self.set_provider_mock.call_args[0][0] + self.assertEqual( + provider._logger_configurator, custom_logger_configurator + ) + class TestMetricsInit(TestCase): def setUp(self): @@ -1264,9 +1331,7 @@ def test_metrics_init_custom_meter_configurator_with_env( self, mock_entry_points ): def custom_meter_configurator(meter_scope): - return mock.Mock(spec=_RuleBasedMeterConfigurator)( - meter_scope=meter_scope - ) + return mock.Mock(spec=_RuleBasedMeterConfigurator)(meter_scope) mock_entry_points.configure_mock( return_value=[