diff --git a/.changelog/5390.added b/.changelog/5390.added new file mode 100644 index 0000000000..0a95778651 --- /dev/null +++ b/.changelog/5390.added @@ -0,0 +1,8 @@ +`opentelemetry-configuration`: wire the experimental +`tracer_configurator/development`, `meter_configurator/development` and +`logger_configurator/development` fields into `create_tracer_provider`, +`create_meter_provider` and `create_logger_provider`, so per-instrumentation-scope +`enabled` overrides declared in the config file are applied to the provider +(previously these fields were parsed but silently discarded). The logger +`minimum_severity`/`trace_based` fields are not supported by the Python SDK and +are ignored with a warning. diff --git a/opentelemetry-configuration/src/opentelemetry/configuration/_logger_provider.py b/opentelemetry-configuration/src/opentelemetry/configuration/_logger_provider.py index 97deff8831..efe335d46b 100644 --- a/opentelemetry-configuration/src/opentelemetry/configuration/_logger_provider.py +++ b/opentelemetry-configuration/src/opentelemetry/configuration/_logger_provider.py @@ -19,6 +19,12 @@ from opentelemetry.configuration.models import ( BatchLogRecordProcessor as BatchLogRecordProcessorConfig, ) +from opentelemetry.configuration.models import ( + ExperimentalLoggerConfig as LoggerConfigConfig, +) +from opentelemetry.configuration.models import ( + ExperimentalLoggerConfigurator as LoggerConfiguratorConfig, +) from opentelemetry.configuration.models import ( ExperimentalOtlpFileExporter as ExperimentalOtlpFileExporterConfig, ) @@ -41,6 +47,10 @@ SimpleLogRecordProcessor as SimpleLogRecordProcessorConfig, ) from opentelemetry.sdk._logs import LoggerProvider +from opentelemetry.sdk._logs._internal import ( + _LoggerConfig, + _RuleBasedLoggerConfigurator, +) from opentelemetry.sdk._logs._internal.export import ( BatchLogRecordProcessor, ConsoleLogRecordExporter, @@ -48,6 +58,7 @@ SimpleLogRecordProcessor, ) from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.util.instrumentation import _scope_name_matches_glob _logger = logging.getLogger(__name__) @@ -235,6 +246,49 @@ def _create_log_record_processor( ) +def _to_logger_config(config: LoggerConfigConfig | None) -> _LoggerConfig: + """Map an experimental per-logger config to an SDK ``_LoggerConfig``. + + Only ``enabled`` is honored. ``minimum_severity`` and ``trace_based`` are + accepted by the config schema but not supported by the Python SDK + ``_LoggerConfig``; when set, they are ignored with a warning. An absent + ``enabled`` leaves the logger enabled. + """ + if config is None: + return _LoggerConfig.default() + if config.minimum_severity is not None or config.trace_based is not None: + _logger.warning( + "logger_configurator minimum_severity/trace_based are specified in " + "config but are not supported by the Python SDK LoggerProvider; " + "they will be ignored." + ) + if config.enabled is None: + return _LoggerConfig.default() + return _LoggerConfig(is_enabled=config.enabled) + + +def _create_logger_configurator( + config: LoggerConfiguratorConfig, +) -> _RuleBasedLoggerConfigurator: + """Build a rule-based logger configurator from experimental config. + + Each entry in ``loggers`` maps an instrumentation-scope name glob to a + per-logger config; ``default_config`` applies to scopes matching no glob. + Rules are evaluated in order, so earlier entries take precedence. + """ + rules = [ + ( + _scope_name_matches_glob(matcher.name), + _to_logger_config(matcher.config), + ) + for matcher in (config.loggers or []) + ] + return _RuleBasedLoggerConfigurator( + rules=rules, + default_config=_to_logger_config(config.default_config), + ) + + def create_logger_provider( config: LoggerProviderConfig | None, resource: Resource | None = None, @@ -251,7 +305,16 @@ def create_logger_provider( Returns: A configured LoggerProvider. """ - provider = LoggerProvider(resource=resource) + logger_configurator = ( + _create_logger_configurator(config.logger_configurator_development) + if config is not None + and config.logger_configurator_development is not None + else None + ) + + provider = LoggerProvider( + resource=resource, _logger_configurator=logger_configurator + ) if config is None: return provider diff --git a/opentelemetry-configuration/src/opentelemetry/configuration/_meter_provider.py b/opentelemetry-configuration/src/opentelemetry/configuration/_meter_provider.py index 23db41f207..6a6fbbb4d2 100644 --- a/opentelemetry-configuration/src/opentelemetry/configuration/_meter_provider.py +++ b/opentelemetry-configuration/src/opentelemetry/configuration/_meter_provider.py @@ -25,6 +25,12 @@ from opentelemetry.configuration.models import ( ExemplarFilter as ExemplarFilterConfig, ) +from opentelemetry.configuration.models import ( + ExperimentalMeterConfig as MeterConfigConfig, +) +from opentelemetry.configuration.models import ( + ExperimentalMeterConfigurator as MeterConfiguratorConfig, +) from opentelemetry.configuration.models import ( ExperimentalOtlpFileMetricExporter as ExperimentalOtlpFileMetricExporterConfig, ) @@ -76,6 +82,10 @@ UpDownCounter, _Gauge, ) +from opentelemetry.sdk.metrics._internal import ( + _MeterConfig, + _RuleBasedMeterConfigurator, +) from opentelemetry.sdk.metrics.export import ( AggregationTemporality, ConsoleMetricExporter, @@ -94,6 +104,7 @@ View, ) from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.util.instrumentation import _scope_name_matches_glob _logger = logging.getLogger(__name__) @@ -567,6 +578,39 @@ def _create_exemplar_filter( ) +def _to_meter_config(config: MeterConfigConfig | None) -> _MeterConfig: + """Map an experimental per-meter config to an SDK ``_MeterConfig``. + + Only ``enabled`` is honored — it is the sole field the SDK + ``_MeterConfig`` exposes. An absent ``enabled`` leaves the meter enabled. + """ + if config is None or config.enabled is None: + return _MeterConfig.default() + return _MeterConfig(is_enabled=config.enabled) + + +def _create_meter_configurator( + config: MeterConfiguratorConfig, +) -> _RuleBasedMeterConfigurator: + """Build a rule-based meter configurator from experimental config. + + Each entry in ``meters`` maps an instrumentation-scope name glob to a + per-meter config; ``default_config`` applies to scopes matching no glob. + Rules are evaluated in order, so earlier entries take precedence. + """ + rules = [ + ( + _scope_name_matches_glob(matcher.name), + _to_meter_config(matcher.config), + ) + for matcher in (config.meters or []) + ] + return _RuleBasedMeterConfigurator( + rules=rules, + default_config=_to_meter_config(config.default_config), + ) + + def create_meter_provider( config: MeterProviderConfig | None, resource: Resource | None = None, @@ -600,11 +644,19 @@ def create_meter_provider( for view_config in config.views: views.append(_create_view(view_config)) + meter_configurator = ( + _create_meter_configurator(config.meter_configurator_development) + if config is not None + and config.meter_configurator_development is not None + else None + ) + return MeterProvider( resource=resource, metric_readers=readers, exemplar_filter=exemplar_filter, # type: ignore[arg-type] views=views, + _meter_configurator=meter_configurator, ) diff --git a/opentelemetry-configuration/src/opentelemetry/configuration/_tracer_provider.py b/opentelemetry-configuration/src/opentelemetry/configuration/_tracer_provider.py index c65b659f4c..1dbb259f82 100644 --- a/opentelemetry-configuration/src/opentelemetry/configuration/_tracer_provider.py +++ b/opentelemetry-configuration/src/opentelemetry/configuration/_tracer_provider.py @@ -28,6 +28,12 @@ from opentelemetry.configuration.models import ( ExperimentalOtlpFileExporter as ExperimentalOtlpFileExporterConfig, ) +from opentelemetry.configuration.models import ( + ExperimentalTracerConfig as TracerConfigConfig, +) +from opentelemetry.configuration.models import ( + ExperimentalTracerConfigurator as TracerConfiguratorConfig, +) from opentelemetry.configuration.models import ( IdGenerator as IdGeneratorConfig, ) @@ -64,6 +70,8 @@ _DEFAULT_OTEL_SPAN_LINK_COUNT_LIMIT, SpanLimits, TracerProvider, + _RuleBasedTracerConfigurator, + _TracerConfig, ) from opentelemetry.sdk.trace._sampling_experimental import ( ComposableSampler, @@ -98,6 +106,7 @@ Sampler, TraceIdRatioBased, ) +from opentelemetry.sdk.util.instrumentation import _scope_name_matches_glob from opentelemetry.trace import SpanKind as TraceSpanKind _logger = logging.getLogger(__name__) @@ -436,6 +445,39 @@ def _create_span_limits(config: SpanLimitsConfig) -> SpanLimits: ) +def _to_tracer_config(config: TracerConfigConfig | None) -> _TracerConfig: + """Map an experimental per-tracer config to an SDK ``_TracerConfig``. + + Only ``enabled`` is honored — it is the sole field the SDK + ``_TracerConfig`` exposes. An absent ``enabled`` leaves the tracer enabled. + """ + if config is None or config.enabled is None: + return _TracerConfig.default() + return _TracerConfig(is_enabled=config.enabled) + + +def _create_tracer_configurator( + config: TracerConfiguratorConfig, +) -> _RuleBasedTracerConfigurator: + """Build a rule-based tracer configurator from experimental config. + + Each entry in ``tracers`` maps an instrumentation-scope name glob to a + per-tracer config; ``default_config`` applies to scopes matching no glob. + Rules are evaluated in order, so earlier entries take precedence. + """ + rules = [ + ( + _scope_name_matches_glob(matcher.name), + _to_tracer_config(matcher.config), + ) + for matcher in (config.tracers or []) + ] + return _RuleBasedTracerConfigurator( + rules=rules, + default_config=_to_tracer_config(config.default_config), + ) + + def create_tracer_provider( config: TracerProviderConfig | None, resource: Resource | None = None, @@ -475,11 +517,19 @@ def create_tracer_provider( ) ) + tracer_configurator = ( + _create_tracer_configurator(config.tracer_configurator_development) + if config is not None + and config.tracer_configurator_development is not None + else None + ) + provider = TracerProvider( resource=resource, sampler=sampler, span_limits=span_limits, id_generator=id_generator, + _tracer_configurator=tracer_configurator, ) if config is not None: diff --git a/opentelemetry-configuration/tests/test_logger_provider.py b/opentelemetry-configuration/tests/test_logger_provider.py index 4aadcd1a10..6dd7223847 100644 --- a/opentelemetry-configuration/tests/test_logger_provider.py +++ b/opentelemetry-configuration/tests/test_logger_provider.py @@ -25,6 +25,15 @@ from opentelemetry.configuration.models import ( BatchLogRecordProcessor as BatchLogRecordProcessorConfig, ) +from opentelemetry.configuration.models import ( + ExperimentalLoggerConfig as LoggerConfigConfig, +) +from opentelemetry.configuration.models import ( + ExperimentalLoggerConfigurator as LoggerConfiguratorConfig, +) +from opentelemetry.configuration.models import ( + ExperimentalLoggerMatcherAndConfig as LoggerMatcherAndConfig, +) from opentelemetry.configuration.models import ( ExperimentalOtlpFileExporter as ExperimentalOtlpFileExporterConfig, ) @@ -42,6 +51,7 @@ ) from opentelemetry.configuration.models import ( NameStringValuePair, + SeverityNumber, ) from opentelemetry.configuration.models import ( OtlpGrpcExporter as OtlpGrpcExporterConfig, @@ -59,6 +69,7 @@ SimpleLogRecordProcessor, ) from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.util.instrumentation import InstrumentationScope class TestCreateLoggerProviderBasic(unittest.TestCase): @@ -492,5 +503,103 @@ def test_no_limits_no_warning(): mock_logger.warning.assert_not_called() +# Configurator tests access the SDK LoggerProvider private +# _apply_logger_configurator to assert the wired config takes effect. +# pylint: disable=protected-access +class TestLoggerConfigurator(unittest.TestCase): + @staticmethod + def _enabled(provider, name): + return provider._apply_logger_configurator( + InstrumentationScope(name) + ).is_enabled + + def test_no_configurator_leaves_loggers_enabled(self): + provider = create_logger_provider(LoggerProviderConfig(processors=[])) + self.assertTrue(self._enabled(provider, "any.scope")) + + def test_matching_glob_disables_logger(self): + config = LoggerProviderConfig( + processors=[], + logger_configurator_development=LoggerConfiguratorConfig( + default_config=LoggerConfigConfig(enabled=True), + loggers=[ + LoggerMatcherAndConfig( + name="noisy.*", + config=LoggerConfigConfig(enabled=False), + ) + ], + ), + ) + provider = create_logger_provider(config) + self.assertFalse(self._enabled(provider, "noisy.http")) + self.assertTrue(self._enabled(provider, "app.service")) + + def test_default_config_applies_to_unmatched_scopes(self): + config = LoggerProviderConfig( + processors=[], + logger_configurator_development=LoggerConfiguratorConfig( + default_config=LoggerConfigConfig(enabled=False), + loggers=[ + LoggerMatcherAndConfig( + name="keep.*", + config=LoggerConfigConfig(enabled=True), + ) + ], + ), + ) + provider = create_logger_provider(config) + self.assertTrue(self._enabled(provider, "keep.me")) + self.assertFalse(self._enabled(provider, "other")) + + def test_first_matching_rule_wins(self): + config = LoggerProviderConfig( + processors=[], + logger_configurator_development=LoggerConfiguratorConfig( + loggers=[ + LoggerMatcherAndConfig( + name="a.*", + config=LoggerConfigConfig(enabled=False), + ), + LoggerMatcherAndConfig( + name="a.b", + config=LoggerConfigConfig(enabled=True), + ), + ], + ), + ) + provider = create_logger_provider(config) + self.assertFalse(self._enabled(provider, "a.b")) + + def test_absent_enabled_defaults_to_enabled(self): + config = LoggerProviderConfig( + processors=[], + logger_configurator_development=LoggerConfiguratorConfig( + default_config=LoggerConfigConfig(), + ), + ) + provider = create_logger_provider(config) + self.assertTrue(self._enabled(provider, "any.scope")) + + def test_unsupported_fields_log_warning(self): + config = LoggerProviderConfig( + processors=[], + logger_configurator_development=LoggerConfiguratorConfig( + default_config=LoggerConfigConfig( + enabled=True, + minimum_severity=SeverityNumber.warn, + ), + ), + ) + with self.assertLogs( + "opentelemetry.configuration._logger_provider", + level="WARNING", + ) as cm: + create_logger_provider(config) + self.assertTrue( + any("minimum_severity" in msg for msg in cm.output), + "Expected warning about unsupported minimum_severity/trace_based", + ) + + if __name__ == "__main__": unittest.main() diff --git a/opentelemetry-configuration/tests/test_meter_provider.py b/opentelemetry-configuration/tests/test_meter_provider.py index 5e1d7e686a..48e6c67dad 100644 --- a/opentelemetry-configuration/tests/test_meter_provider.py +++ b/opentelemetry-configuration/tests/test_meter_provider.py @@ -23,6 +23,15 @@ from opentelemetry.configuration.models import ( ConsoleMetricExporter as ConsoleMetricExporterConfig, ) +from opentelemetry.configuration.models import ( + ExperimentalMeterConfig as MeterConfigConfig, +) +from opentelemetry.configuration.models import ( + ExperimentalMeterConfigurator as MeterConfiguratorConfig, +) +from opentelemetry.configuration.models import ( + ExperimentalMeterMatcherAndConfig as MeterMatcherAndConfig, +) from opentelemetry.configuration.models import ( ExperimentalOtlpFileMetricExporter as ExperimentalOtlpFileMetricExporterConfig, ) @@ -92,6 +101,7 @@ View, ) from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.util.instrumentation import InstrumentationScope class TestCreateMeterProviderBasic(unittest.TestCase): @@ -975,3 +985,78 @@ def test_stream_aggregation_default(self): ) ) self.assertIsInstance(view._aggregation, DefaultAggregation) + + +class TestMeterConfigurator(unittest.TestCase): + @staticmethod + def _enabled(provider, name): + return provider._apply_meter_configurator( + InstrumentationScope(name) + ).is_enabled + + def test_no_configurator_leaves_meters_enabled(self): + provider = create_meter_provider(MeterProviderConfig(readers=[])) + self.assertTrue(self._enabled(provider, "any.scope")) + + def test_matching_glob_disables_meter(self): + config = MeterProviderConfig( + readers=[], + meter_configurator_development=MeterConfiguratorConfig( + default_config=MeterConfigConfig(enabled=True), + meters=[ + MeterMatcherAndConfig( + name="noisy.*", + config=MeterConfigConfig(enabled=False), + ) + ], + ), + ) + provider = create_meter_provider(config) + self.assertFalse(self._enabled(provider, "noisy.http")) + self.assertTrue(self._enabled(provider, "app.service")) + + def test_default_config_applies_to_unmatched_scopes(self): + config = MeterProviderConfig( + readers=[], + meter_configurator_development=MeterConfiguratorConfig( + default_config=MeterConfigConfig(enabled=False), + meters=[ + MeterMatcherAndConfig( + name="keep.*", + config=MeterConfigConfig(enabled=True), + ) + ], + ), + ) + provider = create_meter_provider(config) + self.assertTrue(self._enabled(provider, "keep.me")) + self.assertFalse(self._enabled(provider, "other")) + + def test_first_matching_rule_wins(self): + config = MeterProviderConfig( + readers=[], + meter_configurator_development=MeterConfiguratorConfig( + meters=[ + MeterMatcherAndConfig( + name="a.*", + config=MeterConfigConfig(enabled=False), + ), + MeterMatcherAndConfig( + name="a.b", + config=MeterConfigConfig(enabled=True), + ), + ], + ), + ) + provider = create_meter_provider(config) + self.assertFalse(self._enabled(provider, "a.b")) + + def test_absent_enabled_defaults_to_enabled(self): + config = MeterProviderConfig( + readers=[], + meter_configurator_development=MeterConfiguratorConfig( + default_config=MeterConfigConfig(), + ), + ) + provider = create_meter_provider(config) + self.assertTrue(self._enabled(provider, "any.scope")) diff --git a/opentelemetry-configuration/tests/test_tracer_provider.py b/opentelemetry-configuration/tests/test_tracer_provider.py index b8fe78f82a..e6cdbc6a13 100644 --- a/opentelemetry-configuration/tests/test_tracer_provider.py +++ b/opentelemetry-configuration/tests/test_tracer_provider.py @@ -42,6 +42,15 @@ from opentelemetry.configuration.models import ( ExperimentalSpanParent as SpanParentConfig, ) +from opentelemetry.configuration.models import ( + ExperimentalTracerConfig as TracerConfigConfig, +) +from opentelemetry.configuration.models import ( + ExperimentalTracerConfigurator as TracerConfiguratorConfig, +) +from opentelemetry.configuration.models import ( + ExperimentalTracerMatcherAndConfig as TracerMatcherAndConfig, +) from opentelemetry.configuration.models import ( IdGenerator as IdGeneratorConfig, ) @@ -94,6 +103,7 @@ Sampler, TraceIdRatioBased, ) +from opentelemetry.sdk.util.instrumentation import InstrumentationScope from opentelemetry.trace import SpanContext, TraceFlags from opentelemetry.trace import SpanKind as TraceSpanKind @@ -907,3 +917,80 @@ def test_empty_id_generator_raises_configuration_error(self): """Empty IdGenerator config (no type specified) raises ConfigurationError.""" with self.assertRaises(ConfigurationError): self._make_provider(IdGeneratorConfig()) + + +# Configurator tests access the SDK TracerProvider private +# _apply_tracer_configurator to assert the wired config takes effect. +class TestTracerConfigurator(unittest.TestCase): + @staticmethod + def _enabled(provider, name): + return provider._apply_tracer_configurator( + InstrumentationScope(name) + ).is_enabled + + def test_no_configurator_leaves_tracers_enabled(self): + provider = create_tracer_provider(TracerProviderConfig(processors=[])) + self.assertTrue(self._enabled(provider, "any.scope")) + + def test_matching_glob_disables_tracer(self): + config = TracerProviderConfig( + processors=[], + tracer_configurator_development=TracerConfiguratorConfig( + default_config=TracerConfigConfig(enabled=True), + tracers=[ + TracerMatcherAndConfig( + name="noisy.*", + config=TracerConfigConfig(enabled=False), + ) + ], + ), + ) + provider = create_tracer_provider(config) + self.assertFalse(self._enabled(provider, "noisy.http")) + self.assertTrue(self._enabled(provider, "app.service")) + + def test_default_config_applies_to_unmatched_scopes(self): + config = TracerProviderConfig( + processors=[], + tracer_configurator_development=TracerConfiguratorConfig( + default_config=TracerConfigConfig(enabled=False), + tracers=[ + TracerMatcherAndConfig( + name="keep.*", + config=TracerConfigConfig(enabled=True), + ) + ], + ), + ) + provider = create_tracer_provider(config) + self.assertTrue(self._enabled(provider, "keep.me")) + self.assertFalse(self._enabled(provider, "other")) + + def test_first_matching_rule_wins(self): + config = TracerProviderConfig( + processors=[], + tracer_configurator_development=TracerConfiguratorConfig( + tracers=[ + TracerMatcherAndConfig( + name="a.*", + config=TracerConfigConfig(enabled=False), + ), + TracerMatcherAndConfig( + name="a.b", + config=TracerConfigConfig(enabled=True), + ), + ], + ), + ) + provider = create_tracer_provider(config) + self.assertFalse(self._enabled(provider, "a.b")) + + def test_absent_enabled_defaults_to_enabled(self): + config = TracerProviderConfig( + processors=[], + tracer_configurator_development=TracerConfiguratorConfig( + default_config=TracerConfigConfig(), + ), + ) + provider = create_tracer_provider(config) + self.assertTrue(self._enabled(provider, "any.scope"))