diff --git a/CHANGELOG.md b/CHANGELOG.md index 941551d8a9..0085060e84 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)) - `opentelemetry-sdk`: add `additional_properties` support to generated config models via custom `datamodel-codegen` template, enabling plugin/custom component names to flow through typed dataclasses ([#5131](https://github.com/open-telemetry/opentelemetry-python/pull/5131)) - Fix incorrect code example in `create_tracer()` docstring diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_common.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_common.py index 5e4d569961..0cc1b5daf1 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_common.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_common.py @@ -109,5 +109,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 61673238b7..f1d70a493d 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_logger_provider.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_logger_provider.py @@ -18,7 +18,10 @@ from typing import Optional 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, @@ -136,20 +139,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 _: _create_console_log_exporter(), +} + + 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 257351135f..5f4d30f2e7 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_meter_provider.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_meter_provider.py @@ -18,7 +18,10 @@ from typing import Optional, Set, Type 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, @@ -349,20 +352,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 32dfd96567..db28910cd4 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_tracer_provider.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_tracer_provider.py @@ -18,7 +18,10 @@ from typing import Optional from opentelemetry import trace -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 ( OtlpGrpcExporter as OtlpGrpcExporterConfig, @@ -146,14 +149,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 ff820d105a..40f012dae1 100644 --- a/opentelemetry-sdk/tests/_configuration/test_logger_provider.py +++ b/opentelemetry-sdk/tests/_configuration/test_logger_provider.py @@ -228,16 +228,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 04d60847f0..3355895ebe 100644 --- a/opentelemetry-sdk/tests/_configuration/test_meter_provider.py +++ b/opentelemetry-sdk/tests/_configuration/test_meter_provider.py @@ -306,6 +306,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 5caf077cd5..c1aa7f9cbc 100644 --- a/opentelemetry-sdk/tests/_configuration/test_tracer_provider.py +++ b/opentelemetry-sdk/tests/_configuration/test_tracer_provider.py @@ -361,6 +361,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