Skip to content

Commit 37dea4b

Browse files
herin049xrmx
andauthored
feat: add experimental logger configurator (#4980)
* feat: add experimental LoggerConfigurator * update SDK configuration to utilize logger configurator * generalize rule based configurator * add safe logger configurator application * add unit tests * update CHANGELOG.md * update type annotation for active loggers * Address PR comments. --------- Co-authored-by: Riccardo Magliocchetti <riccardo.magliocchetti@gmail.com>
1 parent 7c860ca commit 37dea4b

File tree

9 files changed

+414
-70
lines changed

9 files changed

+414
-70
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6969
([#5007](https://github.com/open-telemetry/opentelemetry-python/pull/5007))
7070
- Redo OTLPMetricExporter unit tests of `max_export_batch_size` to use real `export`
7171
([#5036](https://github.com/open-telemetry/opentelemetry-python/pull/5036))
72+
- `opentelemetry-sdk`: Implement experimental Logger configurator
73+
([#4980](https://github.com/open-telemetry/opentelemetry-python/pull/4980))
7274

7375
## Version 1.40.0/0.61b0 (2026-03-04)
7476

opentelemetry-sdk/src/opentelemetry/sdk/_configuration/__init__.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
LoggingHandler,
4343
LogRecordProcessor,
4444
)
45+
from opentelemetry.sdk._logs._internal import _LoggerConfiguratorT
4546
from opentelemetry.sdk._logs.export import (
4647
BatchLogRecordProcessor,
4748
LogRecordExporter,
@@ -52,6 +53,7 @@
5253
OTEL_EXPORTER_OTLP_METRICS_PROTOCOL,
5354
OTEL_EXPORTER_OTLP_PROTOCOL,
5455
OTEL_EXPORTER_OTLP_TRACES_PROTOCOL,
56+
OTEL_PYTHON_LOGGER_CONFIGURATOR,
5557
OTEL_PYTHON_METER_CONFIGURATOR,
5658
OTEL_PYTHON_TRACER_CONFIGURATOR,
5759
OTEL_TRACES_SAMPLER,
@@ -177,6 +179,10 @@ def _get_meter_configurator() -> str | None:
177179
return environ.get(OTEL_PYTHON_METER_CONFIGURATOR, None)
178180

179181

182+
def _get_logger_configurator() -> str | None:
183+
return environ.get(OTEL_PYTHON_LOGGER_CONFIGURATOR, None)
184+
185+
180186
def _get_exporter_entry_point(
181187
exporter_name: str, signal_type: Literal["traces", "metrics", "logs"]
182188
):
@@ -297,6 +303,7 @@ def _init_metrics(
297303
set_meter_provider(provider)
298304

299305

306+
# pylint: disable-next=too-many-locals
300307
def _init_logging(
301308
exporters: dict[str, Type[LogRecordExporter]],
302309
resource: Resource | None = None,
@@ -305,8 +312,11 @@ def _init_logging(
305312
log_record_processors: Sequence[LogRecordProcessor] | None = None,
306313
export_log_record_processor: _ConfigurationExporterLogRecordProcessorT
307314
| None = None,
315+
logger_configurator: _LoggerConfiguratorT | None = None,
308316
):
309-
provider = LoggerProvider(resource=resource)
317+
provider = LoggerProvider(
318+
resource=resource, _logger_configurator=logger_configurator
319+
)
310320
set_logger_provider(provider)
311321

312322
exporter_args_map = exporter_args_map or {}
@@ -377,6 +387,27 @@ def overwritten_config_fn(*args, **kwargs):
377387
logging.basicConfig = wrapper(logging.basicConfig)
378388

379389

390+
def _import_logger_configurator(
391+
logger_configurator_name: str | None,
392+
) -> _LoggerConfiguratorT | None:
393+
if not logger_configurator_name:
394+
return None
395+
396+
try:
397+
_, logger_configurator_impl = _import_config_components(
398+
[logger_configurator_name.strip()],
399+
"_opentelemetry_logger_configurator",
400+
)[0]
401+
except Exception as exc: # pylint: disable=broad-exception-caught
402+
_logger.warning(
403+
"Using default logger configurator. Failed to load logger configurator, %s: %s",
404+
logger_configurator_name,
405+
exc,
406+
)
407+
return None
408+
return logger_configurator_impl
409+
410+
380411
def _import_tracer_configurator(
381412
tracer_configurator_name: str | None,
382413
) -> _TracerConfiguratorT | None:
@@ -540,6 +571,7 @@ def _initialize_components(
540571
| None = None,
541572
tracer_configurator: _TracerConfiguratorT | None = None,
542573
meter_configurator: _MeterConfiguratorT | None = None,
574+
logger_configurator: _LoggerConfiguratorT | None = None,
543575
):
544576
# pylint: disable=too-many-locals
545577
if trace_exporter_names is None:
@@ -576,6 +608,11 @@ def _initialize_components(
576608
meter_configurator = _import_meter_configurator(
577609
meter_configurator_name
578610
)
611+
if logger_configurator is None:
612+
logger_configurator_name = _get_logger_configurator()
613+
logger_configurator = _import_logger_configurator(
614+
logger_configurator_name
615+
)
579616

580617
# if env var OTEL_RESOURCE_ATTRIBUTES is given, it will read the service_name
581618
# from the env variable else defaults to "unknown_service"
@@ -613,6 +650,7 @@ def _initialize_components(
613650
exporter_args_map=exporter_args_map,
614651
log_record_processors=log_record_processors,
615652
export_log_record_processor=export_log_record_processor,
653+
logger_configurator=logger_configurator,
616654
)
617655

618656

opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py

Lines changed: 107 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,16 @@
2626
from os import environ
2727
from threading import Lock
2828
from time import time_ns
29-
from typing import Any, Callable, Tuple, Union, cast, overload # noqa
29+
from typing import ( # noqa
30+
Any,
31+
Callable,
32+
Sequence,
33+
Tuple,
34+
Union,
35+
cast,
36+
overload,
37+
)
38+
from weakref import WeakSet
3039

3140
from typing_extensions import deprecated
3241

@@ -51,7 +60,10 @@
5160
)
5261
from opentelemetry.sdk.resources import Resource
5362
from opentelemetry.sdk.util import ns_to_iso_str
54-
from opentelemetry.sdk.util.instrumentation import InstrumentationScope
63+
from opentelemetry.sdk.util._configurator import RuleBasedConfigurator
64+
from opentelemetry.sdk.util.instrumentation import (
65+
InstrumentationScope,
66+
)
5567
from opentelemetry.semconv._incubating.attributes import code_attributes
5668
from opentelemetry.semconv.attributes import exception_attributes
5769
from opentelemetry.trace import (
@@ -63,6 +75,8 @@
6375
_DEFAULT_OTEL_ATTRIBUTE_COUNT_LIMIT = 128
6476
_ENV_VALUE_UNSET = ""
6577

78+
_logger = logging.getLogger(__name__)
79+
6680

6781
class BytesEncoder(json.JSONEncoder):
6882
def default(self, o):
@@ -637,6 +651,15 @@ def flush(self) -> None:
637651
thread.start()
638652

639653

654+
@dataclass
655+
class _LoggerConfig:
656+
is_enabled: bool = True
657+
658+
@classmethod
659+
def default(cls) -> _LoggerConfig:
660+
return _LoggerConfig()
661+
662+
640663
class Logger(APILogger):
641664
def __init__(
642665
self,
@@ -648,6 +671,7 @@ def __init__(
648671
instrumentation_scope: InstrumentationScope,
649672
*,
650673
logger_metrics: LoggerMetrics,
674+
_logger_config: _LoggerConfig,
651675
):
652676
super().__init__(
653677
instrumentation_scope.name,
@@ -659,6 +683,17 @@ def __init__(
659683
self._multi_log_record_processor = multi_log_record_processor
660684
self._instrumentation_scope = instrumentation_scope
661685
self._logger_metrics = logger_metrics
686+
self._logger_config = _logger_config
687+
688+
def _is_enabled(self) -> bool:
689+
return self._logger_config.is_enabled
690+
691+
def _set_logger_config(self, logger_config: _LoggerConfig) -> None:
692+
self._logger_config = logger_config
693+
694+
@property
695+
def instrumentation_scope(self):
696+
return self._instrumentation_scope
662697

663698
@property
664699
def resource(self):
@@ -681,6 +716,8 @@ def emit(
681716
"""Emits the :class:`ReadWriteLogRecord` by setting instrumentation scope
682717
and forwarding to the processor.
683718
"""
719+
if not self._is_enabled():
720+
return
684721
# If a record is provided, use it directly
685722
if record is not None:
686723
if not isinstance(record, ReadWriteLogRecord):
@@ -715,6 +752,22 @@ def emit(
715752
self._multi_log_record_processor.on_emit(writable_record)
716753

717754

755+
_LoggerConfiguratorT = Callable[[InstrumentationScope], _LoggerConfig]
756+
_RuleBasedLoggerConfigurator = RuleBasedConfigurator[_LoggerConfig]
757+
758+
759+
def _default_logger_configurator(
760+
_logger_scope: InstrumentationScope,
761+
) -> _LoggerConfig:
762+
return _LoggerConfig.default()
763+
764+
765+
def _disable_logger_configurator(
766+
_logger_scope: InstrumentationScope,
767+
) -> _LoggerConfig:
768+
return _LoggerConfig(is_enabled=False)
769+
770+
718771
class LoggerProvider(APILoggerProvider):
719772
def __init__(
720773
self,
@@ -725,6 +778,7 @@ def __init__(
725778
| None = None,
726779
*,
727780
meter_provider: MeterProvider | None = None,
781+
_logger_configurator: _LoggerConfiguratorT | None = None,
728782
):
729783
if resource is None:
730784
self._resource = Resource.create({})
@@ -738,11 +792,16 @@ def __init__(
738792
)
739793
disabled = environ.get(OTEL_SDK_DISABLED, "")
740794
self._disabled = disabled.lower().strip() == "true"
795+
self._logger_configurator = (
796+
_logger_configurator or _default_logger_configurator
797+
)
741798
self._at_exit_handler = None
742799
if shutdown_on_exit:
743800
self._at_exit_handler = atexit.register(self.shutdown)
744801
self._logger_cache = {}
745802
self._logger_cache_lock = Lock()
803+
self._active_loggers: WeakSet[Logger] = WeakSet()
804+
self._active_loggers_lock = Lock()
746805

747806
@property
748807
def resource(self):
@@ -755,16 +814,14 @@ def _get_logger_no_cache(
755814
schema_url: str | None = None,
756815
attributes: _ExtendedAttributes | None = None,
757816
) -> Logger:
817+
scope = InstrumentationScope(name, version, schema_url, attributes)
818+
758819
return Logger(
759820
self._resource,
760821
self._multi_log_record_processor,
761-
InstrumentationScope(
762-
name,
763-
version,
764-
schema_url,
765-
attributes,
766-
),
822+
scope,
767823
logger_metrics=self._logger_metrics,
824+
_logger_config=self._apply_logger_configurator(scope),
768825
)
769826

770827
def _get_logger_cached(
@@ -797,9 +854,16 @@ def get_logger(
797854
schema_url=schema_url,
798855
attributes=attributes,
799856
)
800-
if attributes is None:
801-
return self._get_logger_cached(name, version, schema_url)
802-
return self._get_logger_no_cache(name, version, schema_url, attributes)
857+
logger = (
858+
self._get_logger_cached(name, version, schema_url)
859+
if attributes is None
860+
else self._get_logger_no_cache(
861+
name, version, schema_url, attributes
862+
)
863+
)
864+
with self._active_loggers_lock:
865+
self._active_loggers.add(logger)
866+
return logger
803867

804868
def add_log_record_processor(
805869
self, log_record_processor: LogRecordProcessor
@@ -812,6 +876,38 @@ def add_log_record_processor(
812876
log_record_processor
813877
)
814878

879+
def _set_logger_configurator(
880+
self, *, logger_configurator: _LoggerConfiguratorT
881+
):
882+
"""Set a new LoggerConfigurator for this LoggerProvider.
883+
884+
Setting a new LoggerConfigurator will result in the configurator being called
885+
for each outstanding Logger and for any newly created loggers thereafter.
886+
Therefore, it is important that the provided function returns quickly.
887+
"""
888+
self._logger_configurator = logger_configurator
889+
with self._active_loggers_lock:
890+
for logger in self._active_loggers:
891+
# pylint: disable-next=protected-access
892+
logger._set_logger_config(
893+
self._apply_logger_configurator(
894+
logger.instrumentation_scope
895+
)
896+
)
897+
898+
def _apply_logger_configurator(
899+
self, instrumentation_scope: InstrumentationScope
900+
) -> _LoggerConfig:
901+
try:
902+
return self._logger_configurator(instrumentation_scope)
903+
# pylint: disable-next=broad-exception-caught
904+
except Exception:
905+
_logger.exception(
906+
"logger configurator failed for scope '%s', using default config",
907+
instrumentation_scope.name,
908+
)
909+
return _LoggerConfig.default()
910+
815911
def shutdown(self) -> None:
816912
"""Shuts down the log processors."""
817913
self._multi_log_record_processor.shutdown()

opentelemetry-sdk/src/opentelemetry/sdk/environment_variables/__init__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -826,3 +826,15 @@ def channel_credential_provider() -> grpc.ChannelCredentials:
826826
This is an experimental environment variable and the name of this variable and its behavior can
827827
change in a non-backwards compatible way.
828828
"""
829+
830+
OTEL_PYTHON_LOGGER_CONFIGURATOR = "OTEL_PYTHON_LOGGER_CONFIGURATOR"
831+
"""
832+
.. envvar:: OTEL_PYTHON_LOGGER_CONFIGURATOR
833+
834+
The :envvar:`OTEL_PYTHON_LOGGER_CONFIGURATOR` environment variable allows users to set a
835+
custom Logger Configurator function.
836+
Default: opentelemetry.sdk._logs._internal._default_logger_configurator
837+
838+
This is an experimental environment variable and the name of this variable and its behavior can
839+
change in a non-backwards compatible way.
840+
"""

opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/__init__.py

Lines changed: 2 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,9 @@
6363
SdkConfiguration,
6464
)
6565
from opentelemetry.sdk.resources import Resource
66+
from opentelemetry.sdk.util._configurator import RuleBasedConfigurator
6667
from opentelemetry.sdk.util.instrumentation import (
6768
InstrumentationScope,
68-
_InstrumentationScopePredicateT,
6969
)
7070
from opentelemetry.util._once import Once
7171
from opentelemetry.util.types import (
@@ -414,9 +414,7 @@ def _get_exemplar_filter(exemplar_filter: str) -> ExemplarFilter:
414414

415415

416416
_MeterConfiguratorT = Callable[[InstrumentationScope], _MeterConfig]
417-
_MeterConfiguratorRulesT = Sequence[
418-
tuple[_InstrumentationScopePredicateT, _MeterConfig]
419-
]
417+
_RuleBasedMeterConfigurator = RuleBasedConfigurator[_MeterConfig]
420418

421419

422420
def _default_meter_configurator(
@@ -431,27 +429,6 @@ def _disable_meter_configurator(
431429
return _MeterConfig(is_enabled=False)
432430

433431

434-
class _RuleBasedMeterConfigurator:
435-
def __init__(
436-
self,
437-
*,
438-
rules: _MeterConfiguratorRulesT,
439-
default_config: _MeterConfig,
440-
):
441-
self._rules = rules
442-
self._default_config = default_config
443-
444-
def __call__(self, meter_scope: InstrumentationScope) -> _MeterConfig:
445-
for predicate, meter_config in self._rules:
446-
if predicate(meter_scope):
447-
return meter_config
448-
# by default return default config
449-
return self._default_config
450-
451-
def update_rules(self, rules: _MeterConfiguratorRulesT) -> None:
452-
self._rules = rules
453-
454-
455432
class MeterProvider(APIMeterProvider):
456433
r"""See `opentelemetry.metrics.MeterProvider`.
457434

0 commit comments

Comments
 (0)