diff --git a/CHANGELOG.md b/CHANGELOG.md index 3dbba339718..cf1f19c0942 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +- `opentelemetry-sdk`: add exporter plugin loading to declarative file configuration for all three signals (traces, metrics, logs) via the `opentelemetry_*_exporter` entry point groups + ([#5128](https://github.com/open-telemetry/opentelemetry-python/pull/5128)) - Apply fixes for `UP` ruff rule ([#5133](https://github.com/open-telemetry/opentelemetry-python/pull/5133)) - Switch to SPDX license headers and add CI enforcement diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_common.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_common.py index f70358b1510..7acc6f95c5b 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_common.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_common.py @@ -97,5 +97,8 @@ def _parse_headers( ) if headers: for pair in headers: - result[pair.name] = pair.value or "" + if isinstance(pair, dict): + result[pair["name"]] = pair.get("value") or "" + else: + result[pair.name] = pair.value or "" return result diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_logger_provider.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_logger_provider.py index 26c9e226c4e..6718d5635d0 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_logger_provider.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_logger_provider.py @@ -6,7 +6,10 @@ import logging from opentelemetry._logs import set_logger_provider -from opentelemetry.sdk._configuration._common import _parse_headers +from opentelemetry.sdk._configuration._common import ( + _parse_headers, + load_entry_point, +) from opentelemetry.sdk._configuration._exceptions import ConfigurationError from opentelemetry.sdk._configuration.models import ( BatchLogRecordProcessor as BatchLogRecordProcessorConfig, @@ -124,20 +127,30 @@ def _create_otlp_grpc_log_exporter( ) +_LOG_EXPORTER_REGISTRY: dict = { + "otlp_http": _create_otlp_http_log_exporter, + "otlp_grpc": _create_otlp_grpc_log_exporter, + "console": lambda _: ConsoleLogRecordExporter(), +} + + def _create_log_record_exporter( config: LogRecordExporterConfig, ) -> LogRecordExporter: - """Create a log record exporter from config.""" - if config.console is not None: - return _create_console_log_exporter() - if config.otlp_http is not None: - return _create_otlp_http_log_exporter(config.otlp_http) - if config.otlp_grpc is not None: - return _create_otlp_grpc_log_exporter(config.otlp_grpc) - if config.otlp_file_development is not None: - raise ConfigurationError( - "otlp_file_development log exporter is experimental and not yet supported." - ) + """Create a log record exporter from config. + + Known exporter types are checked via typed fields on the LogRecordExporter + dataclass. Unknown exporter names captured in additional_properties + by the @_additional_properties decorator are loaded via the + ``opentelemetry_logs_exporter`` entry point group. + """ + for name, factory in _LOG_EXPORTER_REGISTRY.items(): + value = getattr(config, name, None) + if value is not None: + return factory(value) + if config.additional_properties: + name = next(iter(config.additional_properties)) + return load_entry_point("opentelemetry_logs_exporter", name)() raise ConfigurationError( "No exporter type specified in log record exporter config. " "Supported types: console, otlp_http, otlp_grpc." diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_meter_provider.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_meter_provider.py index 2bfd11b4223..c78de6e292d 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_meter_provider.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_meter_provider.py @@ -6,7 +6,10 @@ import logging from opentelemetry import metrics -from opentelemetry.sdk._configuration._common import _parse_headers +from opentelemetry.sdk._configuration._common import ( + _parse_headers, + load_entry_point, +) from opentelemetry.sdk._configuration._exceptions import ConfigurationError from opentelemetry.sdk._configuration.models import ( Aggregation as AggregationConfig, @@ -337,20 +340,30 @@ def _create_otlp_grpc_metric_exporter( ) +_METRIC_EXPORTER_REGISTRY: dict = { + "otlp_http": _create_otlp_http_metric_exporter, + "otlp_grpc": _create_otlp_grpc_metric_exporter, + "console": _create_console_metric_exporter, +} + + def _create_push_metric_exporter( config: PushMetricExporterConfig, ) -> MetricExporter: - """Create a push metric exporter from config.""" - if config.console is not None: - return _create_console_metric_exporter(config.console) - if config.otlp_http is not None: - return _create_otlp_http_metric_exporter(config.otlp_http) - if config.otlp_grpc is not None: - return _create_otlp_grpc_metric_exporter(config.otlp_grpc) - if config.otlp_file_development is not None: - raise ConfigurationError( - "otlp_file_development metric exporter is experimental and not yet supported." - ) + """Create a push metric exporter from config. + + Known exporter types are checked via typed fields on the PushMetricExporter + dataclass. Unknown exporter names captured in additional_properties + by the @_additional_properties decorator are loaded via the + ``opentelemetry_metrics_exporter`` entry point group. + """ + for name, factory in _METRIC_EXPORTER_REGISTRY.items(): + value = getattr(config, name, None) + if value is not None: + return factory(value) + if config.additional_properties: + name = next(iter(config.additional_properties)) + return load_entry_point("opentelemetry_metrics_exporter", name)() raise ConfigurationError( "No exporter type specified in push metric exporter config. " "Supported types: console, otlp_http, otlp_grpc." diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_tracer_provider.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_tracer_provider.py index af4200e4748..9e807b37a9c 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_tracer_provider.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_tracer_provider.py @@ -137,14 +137,28 @@ def _create_otlp_grpc_span_exporter( ) +_SPAN_EXPORTER_REGISTRY: dict = { + "otlp_http": _create_otlp_http_span_exporter, + "otlp_grpc": _create_otlp_grpc_span_exporter, + "console": lambda _: ConsoleSpanExporter(), +} + + def _create_span_exporter(config: SpanExporterConfig) -> SpanExporter: - """Create a span exporter from config.""" - if config.otlp_http is not None: - return _create_otlp_http_span_exporter(config.otlp_http) - if config.otlp_grpc is not None: - return _create_otlp_grpc_span_exporter(config.otlp_grpc) - if config.console is not None: - return ConsoleSpanExporter() + """Create a span exporter from config. + + Known exporter types are checked via typed fields on the SpanExporter + dataclass. Unknown exporter names captured in additional_properties + by the @_additional_properties decorator are loaded via the + ``opentelemetry_traces_exporter`` entry point group. + """ + for name, factory in _SPAN_EXPORTER_REGISTRY.items(): + value = getattr(config, name, None) + if value is not None: + return factory(value) + if config.additional_properties: + name = next(iter(config.additional_properties)) + return load_entry_point("opentelemetry_traces_exporter", name)() raise ConfigurationError( "No exporter type specified in span exporter config. " "Supported types: otlp_http, otlp_grpc, console." diff --git a/opentelemetry-sdk/tests/_configuration/test_logger_provider.py b/opentelemetry-sdk/tests/_configuration/test_logger_provider.py index 32d1f995ef5..ff348bd9b1f 100644 --- a/opentelemetry-sdk/tests/_configuration/test_logger_provider.py +++ b/opentelemetry-sdk/tests/_configuration/test_logger_provider.py @@ -217,16 +217,35 @@ def test_console_exporter(self): exporter = _create_log_record_exporter(config) self.assertIsInstance(exporter, ConsoleLogRecordExporter) - def test_otlp_file_development_raises(self): - config = LogRecordExporterConfig(otlp_file_development={}) - with self.assertRaises(ConfigurationError): - _create_log_record_exporter(config) - def test_no_exporter_type_raises(self): config = LogRecordExporterConfig() with self.assertRaises(ConfigurationError): _create_log_record_exporter(config) + def test_plugin_log_exporter_loaded_via_entry_point(self): + mock_exporter = MagicMock() + mock_class = MagicMock(return_value=mock_exporter) + with patch( + "opentelemetry.sdk._configuration._common.entry_points", + return_value=[MagicMock(**{"load.return_value": mock_class})], + ): + # pylint: disable=unexpected-keyword-arg + result = _create_log_record_exporter( + LogRecordExporterConfig(my_custom_exporter={}) + ) + self.assertIs(result, mock_exporter) + + def test_unknown_log_exporter_raises_configuration_error(self): + with patch( + "opentelemetry.sdk._configuration._common.entry_points", + return_value=[], + ): + with self.assertRaises(ConfigurationError): + # pylint: disable=unexpected-keyword-arg + _create_log_record_exporter( + LogRecordExporterConfig(no_such_exporter={}) + ) + def test_otlp_http_missing_package_raises(self): config = LogRecordExporterConfig( otlp_http=OtlpHttpExporterConfig(endpoint="http://localhost:4318") diff --git a/opentelemetry-sdk/tests/_configuration/test_meter_provider.py b/opentelemetry-sdk/tests/_configuration/test_meter_provider.py index de153dd78bf..097d5187861 100644 --- a/opentelemetry-sdk/tests/_configuration/test_meter_provider.py +++ b/opentelemetry-sdk/tests/_configuration/test_meter_provider.py @@ -295,6 +295,32 @@ def test_no_exporter_type_raises(self): with self.assertRaises(ConfigurationError): create_meter_provider(config) + def test_plugin_metric_exporter_loaded_via_entry_point(self): + mock_exporter = MagicMock() + mock_class = MagicMock(return_value=mock_exporter) + with patch( + "opentelemetry.sdk._configuration._common.entry_points", + return_value=[MagicMock(**{"load.return_value": mock_class})], + ): + # pylint: disable=unexpected-keyword-arg + config = self._make_periodic_config( + PushMetricExporterConfig(my_custom_exporter={}) + ) + provider = create_meter_provider(config) + self.assertEqual(len(provider._sdk_config.metric_readers), 1) + + def test_unknown_metric_exporter_raises_configuration_error(self): + with patch( + "opentelemetry.sdk._configuration._common.entry_points", + return_value=[], + ): + # pylint: disable=unexpected-keyword-arg + config = self._make_periodic_config( + PushMetricExporterConfig(no_such_exporter={}) + ) + with self.assertRaises(ConfigurationError): + create_meter_provider(config) + def test_multiple_readers(self): config = MeterProviderConfig( readers=[ diff --git a/opentelemetry-sdk/tests/_configuration/test_tracer_provider.py b/opentelemetry-sdk/tests/_configuration/test_tracer_provider.py index 80267df426f..21d5d22ec70 100644 --- a/opentelemetry-sdk/tests/_configuration/test_tracer_provider.py +++ b/opentelemetry-sdk/tests/_configuration/test_tracer_provider.py @@ -369,6 +369,34 @@ def test_no_exporter_type_raises(self): with self.assertRaises(ConfigurationError): create_tracer_provider(config) + def test_plugin_span_exporter_loaded_via_entry_point(self): + mock_exporter = MagicMock() + mock_class = MagicMock(return_value=mock_exporter) + with patch( + "opentelemetry.sdk._configuration._common.entry_points", + return_value=[MagicMock(**{"load.return_value": mock_class})], + ): + config = self._make_batch_config( + # pylint: disable=unexpected-keyword-arg + SpanExporterConfig(my_custom_exporter={}) + ) + provider = create_tracer_provider(config) + self.assertEqual( + len(provider._active_span_processor._span_processors), 1 + ) + + def test_unknown_span_exporter_raises_configuration_error(self): + with patch( + "opentelemetry.sdk._configuration._common.entry_points", + return_value=[], + ): + config = self._make_batch_config( + # pylint: disable=unexpected-keyword-arg + SpanExporterConfig(no_such_exporter={}) + ) + with self.assertRaises(ConfigurationError): + create_tracer_provider(config) + class TestCreateSpanLimits(unittest.TestCase): # pylint: disable=no-self-use