From b5ec1175f854795028038954b3f584008f7bebad Mon Sep 17 00:00:00 2001 From: Lukas Hering Date: Sat, 14 Mar 2026 00:32:01 -0400 Subject: [PATCH 1/7] feat: add experimental LoggerConfigurator --- .../sdk/_logs/_internal/__init__.py | 120 ++++++++++++++++-- .../sdk/environment_variables/__init__.py | 12 -- 2 files changed, 109 insertions(+), 23 deletions(-) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py index d6a4aa16ab..cf9727de59 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.instrumentation import ( + InstrumentationScope, + _InstrumentationScopePredicateT, +) from opentelemetry.semconv._incubating.attributes import code_attributes from opentelemetry.semconv.attributes import exception_attributes from opentelemetry.trace import ( @@ -637,6 +649,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 +669,7 @@ def __init__( instrumentation_scope: InstrumentationScope, *, logger_metrics: LoggerMetrics, + logger_config: LoggerConfig, ): super().__init__( instrumentation_scope.name, @@ -659,6 +681,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 +714,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 +750,42 @@ def emit( self._multi_log_record_processor.on_emit(writable_record) +LoggerConfiguratorT = Callable[[InstrumentationScope], LoggerConfig] +LoggerConfiguratorRulesT = Sequence[ + tuple[_InstrumentationScopePredicateT, 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 RuleBasedLoggerConfigurator: + def __init__( + self, + *, + rules: LoggerConfiguratorRulesT, + default_config: LoggerConfig, + ): + self._rules = rules + self._default_config = default_config + + def __call__(self, meter_scope: InstrumentationScope) -> LoggerConfig: + for predicate, meter_config in self._rules: + if predicate(meter_scope): + return meter_config + # by default return default config + return self._default_config + + class LoggerProvider(APILoggerProvider): def __init__( self, @@ -725,6 +796,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 +810,14 @@ def __init__( ) disabled = environ.get(OTEL_SDK_DISABLED, "") self._disabled = disabled.lower().strip() == "true" + self._logger_configurator = 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() + self._active_loggers_lock = Lock() @property def resource(self): @@ -755,16 +830,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._logger_configurator(scope), ) def _get_logger_cached( @@ -797,9 +870,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 +892,24 @@ 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: + if not isinstance(logger, Logger): + continue + logger.set_logger_config( + self._logger_configurator(logger.instrumentation_scope) + ) + 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..7e143dd3bb 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/environment_variables/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/environment_variables/__init__.py @@ -814,15 +814,3 @@ 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_METER_CONFIGURATOR = "OTEL_PYTHON_METER_CONFIGURATOR" -""" -.. envvar:: OTEL_PYTHON_METER_CONFIGURATOR - -The :envvar:`OTEL_PYTHON_METER_CONFIGURATOR` environment variable allows users to set a -custom Meter Configurator function. -Default: opentelemetry.sdk.metrics._internal._default_meter_configurator - -This is an experimental environment variable and the name of this variable and its behavior can -change in a non-backwards compatible way. -""" From 712e1a362f82fa77a509a84fee526ba6e408534d Mon Sep 17 00:00:00 2001 From: Lukas Hering Date: Sat, 14 Mar 2026 23:05:07 -0400 Subject: [PATCH 2/7] update SDK configuration to utilize logger configurator --- .../sdk/_configuration/__init__.py | 39 ++++++++++++++++++- .../sdk/_logs/_internal/__init__.py | 8 ++-- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/__init__.py index a982b9de71..f81ed531cd 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"] ): @@ -305,8 +311,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 +386,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 +570,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 +607,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 +649,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 cf9727de59..8c7ad125a5 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py @@ -778,10 +778,10 @@ def __init__( self._rules = rules self._default_config = default_config - def __call__(self, meter_scope: InstrumentationScope) -> LoggerConfig: - for predicate, meter_config in self._rules: - if predicate(meter_scope): - return meter_config + def __call__(self, logger_scope: InstrumentationScope) -> LoggerConfig: + for predicate, logger_config in self._rules: + if predicate(logger_scope): + return logger_config # by default return default config return self._default_config From e4206069ffefd76df65b320205360c65598c33b9 Mon Sep 17 00:00:00 2001 From: Lukas Hering Date: Fri, 3 Apr 2026 22:25:53 -0400 Subject: [PATCH 3/7] generalize rule based configurator --- .../sdk/_logs/_internal/__init__.py | 26 +++---------------- .../sdk/environment_variables/__init__.py | 24 +++++++++++++++++ .../sdk/metrics/_internal/__init__.py | 26 ++----------------- .../src/opentelemetry/sdk/trace/__init__.py | 25 ++---------------- .../opentelemetry/sdk/util/_configurator.py | 25 ++++++++++++++++++ 5 files changed, 56 insertions(+), 70 deletions(-) create mode 100644 opentelemetry-sdk/src/opentelemetry/sdk/util/_configurator.py diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py index 8c7ad125a5..e84b110101 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py @@ -60,9 +60,9 @@ ) from opentelemetry.sdk.resources import Resource from opentelemetry.sdk.util import ns_to_iso_str +from opentelemetry.sdk.util._configurator import RuleBasedConfigurator from opentelemetry.sdk.util.instrumentation import ( InstrumentationScope, - _InstrumentationScopePredicateT, ) from opentelemetry.semconv._incubating.attributes import code_attributes from opentelemetry.semconv.attributes import exception_attributes @@ -654,7 +654,7 @@ class LoggerConfig: is_enabled: bool = True @classmethod - def default(cls) -> "LoggerConfig": + def default(cls) -> LoggerConfig: return LoggerConfig() @@ -751,9 +751,7 @@ def emit( LoggerConfiguratorT = Callable[[InstrumentationScope], LoggerConfig] -LoggerConfiguratorRulesT = Sequence[ - tuple[_InstrumentationScopePredicateT, LoggerConfig] -] +RuleBasedLoggerConfigurator = RuleBasedConfigurator[LoggerConfig] def default_logger_configurator( @@ -768,24 +766,6 @@ def disable_logger_configurator( return LoggerConfig(is_enabled=False) -class RuleBasedLoggerConfigurator: - def __init__( - self, - *, - rules: LoggerConfiguratorRulesT, - default_config: LoggerConfig, - ): - self._rules = rules - self._default_config = default_config - - def __call__(self, logger_scope: InstrumentationScope) -> LoggerConfig: - for predicate, logger_config in self._rules: - if predicate(logger_scope): - return logger_config - # by default return default config - return self._default_config - - class LoggerProvider(APILoggerProvider): def __init__( self, diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/environment_variables/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/environment_variables/__init__.py index 7e143dd3bb..2959163eed 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/environment_variables/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/environment_variables/__init__.py @@ -814,3 +814,27 @@ 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_METER_CONFIGURATOR = "OTEL_PYTHON_METER_CONFIGURATOR" +""" +.. envvar:: OTEL_PYTHON_METER_CONFIGURATOR + +The :envvar:`OTEL_PYTHON_METER_CONFIGURATOR` environment variable allows users to set a +custom Meter Configurator function. +Default: opentelemetry.sdk.metrics._internal._default_meter_configurator + +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..82afc89664 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/__init__.py @@ -63,6 +63,7 @@ SdkConfiguration, ) from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.util._configurator import RuleBasedConfigurator from opentelemetry.sdk.util.instrumentation import ( InstrumentationScope, _InstrumentationScopePredicateT, @@ -414,9 +415,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 +430,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..8daf35a1cd 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( 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..eef485bc3a --- /dev/null +++ b/opentelemetry-sdk/src/opentelemetry/sdk/util/_configurator.py @@ -0,0 +1,25 @@ +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 From bf2e28b7cfdd7ad11eca699a12d9450f9c615521 Mon Sep 17 00:00:00 2001 From: Lukas Hering Date: Fri, 3 Apr 2026 22:56:37 -0400 Subject: [PATCH 4/7] add safe logger configurator application --- .../sdk/_logs/_internal/__init__.py | 29 +++++++++++++++---- .../sdk/metrics/_internal/__init__.py | 1 - .../opentelemetry/sdk/util/_configurator.py | 13 +++++++++ 3 files changed, 37 insertions(+), 6 deletions(-) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py index e84b110101..e20504fa19 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py @@ -75,6 +75,8 @@ _DEFAULT_OTEL_ATTRIBUTE_COUNT_LIMIT = 128 _ENV_VALUE_UNSET = "" +_logger = logging.getLogger(__name__) + class BytesEncoder(json.JSONEncoder): def default(self, o): @@ -754,13 +756,13 @@ def emit( RuleBasedLoggerConfigurator = RuleBasedConfigurator[LoggerConfig] -def default_logger_configurator( +def _default_logger_configurator( _logger_scope: InstrumentationScope, ) -> LoggerConfig: return LoggerConfig.default() -def disable_logger_configurator( +def _disable_logger_configurator( _logger_scope: InstrumentationScope, ) -> LoggerConfig: return LoggerConfig(is_enabled=False) @@ -790,7 +792,9 @@ def __init__( ) disabled = environ.get(OTEL_SDK_DISABLED, "") self._disabled = disabled.lower().strip() == "true" - self._logger_configurator = logger_configurator + 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) @@ -817,7 +821,7 @@ def _get_logger_no_cache( self._multi_log_record_processor, scope, logger_metrics=self._logger_metrics, - logger_config=self._logger_configurator(scope), + logger_config=self._apply_logger_configurator(scope), ) def _get_logger_cached( @@ -887,9 +891,24 @@ def set_logger_configurator( if not isinstance(logger, Logger): continue logger.set_logger_config( - self._logger_configurator(logger.instrumentation_scope) + 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/metrics/_internal/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/__init__.py index 82afc89664..e6583d1c5f 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/__init__.py @@ -66,7 +66,6 @@ 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 ( diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/util/_configurator.py b/opentelemetry-sdk/src/opentelemetry/sdk/util/_configurator.py index eef485bc3a..c7c9e78c96 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/util/_configurator.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/util/_configurator.py @@ -1,3 +1,16 @@ +# 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 ( From 86ef1013f88d61dc582364b209fa5d04a67ecc9d Mon Sep 17 00:00:00 2001 From: Lukas Hering Date: Fri, 3 Apr 2026 23:15:52 -0400 Subject: [PATCH 5/7] add unit tests --- .../src/opentelemetry/sdk/trace/__init__.py | 4 +- opentelemetry-sdk/tests/logs/test_logs.py | 138 +++++++++++++++++- opentelemetry-sdk/tests/test_configurator.py | 79 +++++++++- 3 files changed, 215 insertions(+), 6 deletions(-) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py index 8daf35a1cd..18fced7061 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py @@ -1278,7 +1278,7 @@ def _default_tracer_configurator( return _RuleBasedTracerConfigurator( rules=[], default_config=_TracerConfig.default(), - )(tracer_scope=tracer_scope) + )(tracer_scope) def _disable_tracer_configurator( @@ -1287,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/tests/logs/test_logs.py b/opentelemetry-sdk/tests/logs/test_logs.py index 6a9c95685c..289ee77030 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,135 @@ 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()) + + 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 +286,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..8f7ded8555 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) @@ -668,7 +673,7 @@ def test_trace_init_custom_tracer_configurator_with_env( ): def custom_tracer_configurator(tracer_scope): return mock.Mock(spec=_RuleBasedTracerConfigurator)( - tracer_scope=tracer_scope + tracer_scope ) mock_entry_points.configure_mock( @@ -879,6 +884,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 +906,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 +1048,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 +1147,71 @@ 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): @@ -1265,7 +1338,7 @@ def test_metrics_init_custom_meter_configurator_with_env( ): def custom_meter_configurator(meter_scope): return mock.Mock(spec=_RuleBasedMeterConfigurator)( - meter_scope=meter_scope + meter_scope ) mock_entry_points.configure_mock( From 8789466a4e01d6e26cc9c3083c1da166e34ed37b Mon Sep 17 00:00:00 2001 From: Lukas Hering Date: Fri, 3 Apr 2026 23:20:24 -0400 Subject: [PATCH 6/7] update CHANGELOG.md --- CHANGELOG.md | 2 ++ .../opentelemetry/sdk/_configuration/__init__.py | 1 + opentelemetry-sdk/tests/logs/test_logs.py | 1 + opentelemetry-sdk/tests/test_configurator.py | 16 ++++------------ 4 files changed, 8 insertions(+), 12 deletions(-) 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 f81ed531cd..08b0a38b35 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/__init__.py @@ -303,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, diff --git a/opentelemetry-sdk/tests/logs/test_logs.py b/opentelemetry-sdk/tests/logs/test_logs.py index 289ee77030..7469592ea1 100644 --- a/opentelemetry-sdk/tests/logs/test_logs.py +++ b/opentelemetry-sdk/tests/logs/test_logs.py @@ -147,6 +147,7 @@ def test_set_logger_configurator_affects_new_loggers(self): 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 diff --git a/opentelemetry-sdk/tests/test_configurator.py b/opentelemetry-sdk/tests/test_configurator.py index 8f7ded8555..621ac37457 100644 --- a/opentelemetry-sdk/tests/test_configurator.py +++ b/opentelemetry-sdk/tests/test_configurator.py @@ -672,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 - ) + return mock.Mock(spec=_RuleBasedTracerConfigurator)(tracer_scope) mock_entry_points.configure_mock( return_value=[ @@ -1160,9 +1158,7 @@ def test_logging_init_logger_configurator_passed_directly(self): _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 - ) + self.assertEqual(provider._logger_configurator, mock_configurator) @patch.dict( "os.environ", @@ -1188,9 +1184,7 @@ 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 - ) + return mock.Mock(spec=RuleBasedLoggerConfigurator)(logger_scope) mock_entry_points.configure_mock( return_value=[ @@ -1337,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 - ) + return mock.Mock(spec=_RuleBasedMeterConfigurator)(meter_scope) mock_entry_points.configure_mock( return_value=[ From 093663e47b14f6348a72ee9bdcc8bee7fadd196c Mon Sep 17 00:00:00 2001 From: Lukas Hering Date: Fri, 3 Apr 2026 23:24:42 -0400 Subject: [PATCH 7/7] update type annotation for active loggers --- .../src/opentelemetry/sdk/_logs/_internal/__init__.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py index e20504fa19..ec9a033c7b 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py @@ -800,7 +800,7 @@ def __init__( self._at_exit_handler = atexit.register(self.shutdown) self._logger_cache = {} self._logger_cache_lock = Lock() - self._active_loggers = WeakSet() + self._active_loggers: WeakSet[Logger] = WeakSet() self._active_loggers_lock = Lock() @property @@ -888,8 +888,6 @@ def set_logger_configurator( self._logger_configurator = logger_configurator with self._active_loggers_lock: for logger in self._active_loggers: - if not isinstance(logger, Logger): - continue logger.set_logger_config( self._apply_logger_configurator( logger.instrumentation_scope