From f2d964bcfae00cfda35827528c219ac333654ec8 Mon Sep 17 00:00:00 2001 From: Mike Goldsmith Date: Mon, 20 Apr 2026 11:53:30 +0100 Subject: [PATCH 1/4] add exporter plugin loading to declarative config for all three signals SpanExporter, LogRecordExporter, and PushMetricExporter are changed from @dataclass to TypeAlias = dict[str, Any] in models.py, preserving unknown exporter names as dict keys through the config pipeline. Each signal's factory function (_create_span_exporter, _create_log_record_exporter, _create_push_metric_exporter) now has a registry of known exporter names (otlp_http, otlp_grpc, console) with fallback to load_entry_point for unknown names via the opentelemetry_{traces,logs,metrics}_exporter entry point groups. _parse_headers in _common.py is updated to handle both NameStringValuePair objects (typed config) and raw dicts (YAML path), making the full dict path testable end-to-end. Assisted-by: Claude Opus 4.6 --- CHANGELOG.md | 2 + .../sdk/_configuration/_common.py | 5 ++- .../sdk/_configuration/_logger_provider.py | 44 ++++++++++--------- .../sdk/_configuration/_meter_provider.py | 44 ++++++++++--------- .../sdk/_configuration/_tracer_provider.py | 42 +++++++++++------- .../sdk/_configuration/models.py | 30 +++++-------- .../_configuration/test_logger_provider.py | 23 +++++++--- .../_configuration/test_meter_provider.py | 20 +++++++++ .../_configuration/test_tracer_provider.py | 22 ++++++++++ 9 files changed, 152 insertions(+), 80 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 60244311072..a84571cd4f0 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 + ([#5069](https://github.com/open-telemetry/opentelemetry-python/pull/5069)) - `opentelemetry-sdk`: add `load_entry_point` shared utility to declarative file configuration for loading plugins via entry points; refactor propagator loading to use it ([#5093](https://github.com/open-telemetry/opentelemetry-python/pull/5093)) - `opentelemetry-sdk`: fix YAML structure injection via environment variable substitution in declarative file configuration; values containing newlines are now emitted as quoted YAML scalars per spec requirement diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_common.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_common.py index 0498a19e13c..92c16654199 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_common.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_common.py @@ -73,5 +73,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 61673238b72..7156507e3ea 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, @@ -26,9 +29,6 @@ from opentelemetry.sdk._configuration.models import ( LoggerProvider as LoggerProviderConfig, ) -from opentelemetry.sdk._configuration.models import ( - LogRecordExporter as LogRecordExporterConfig, -) from opentelemetry.sdk._configuration.models import ( LogRecordProcessor as LogRecordProcessorConfig, ) @@ -136,24 +136,28 @@ def _create_otlp_grpc_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: +_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: dict) -> LogRecordExporter: + """Create a log record exporter from a config dict with a single key naming the exporter type. + + Known names (otlp_http, otlp_grpc, console) are bootstrapped directly. + Unknown names are loaded via the ``opentelemetry_logs_exporter`` entry + point group, matching the spec's PluginComponentProvider mechanism. + """ + if len(config) != 1: raise ConfigurationError( - "otlp_file_development log exporter is experimental and not yet supported." + f"Log exporter config must have exactly one key, got: {list(config.keys())}" ) - raise ConfigurationError( - "No exporter type specified in log record exporter config. " - "Supported types: console, otlp_http, otlp_grpc." - ) + name, exporter_config = next(iter(config.items())) + if name in _LOG_EXPORTER_REGISTRY: + return _LOG_EXPORTER_REGISTRY[name](exporter_config) + return load_entry_point("opentelemetry_logs_exporter", name)() def _create_batch_log_record_processor( diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_meter_provider.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_meter_provider.py index 257351135f3..fa35fd9977a 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, @@ -49,9 +52,6 @@ from opentelemetry.sdk._configuration.models import ( PeriodicMetricReader as PeriodicMetricReaderConfig, ) -from opentelemetry.sdk._configuration.models import ( - PushMetricExporter as PushMetricExporterConfig, -) from opentelemetry.sdk._configuration.models import ( View as ViewConfig, ) @@ -349,24 +349,28 @@ def _create_otlp_grpc_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: +_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: dict) -> MetricExporter: + """Create a push metric exporter from a config dict with a single key naming the exporter type. + + Known names (otlp_http, otlp_grpc, console) are bootstrapped directly. + Unknown names are loaded via the ``opentelemetry_metrics_exporter`` entry + point group, matching the spec's PluginComponentProvider mechanism. + """ + if len(config) != 1: raise ConfigurationError( - "otlp_file_development metric exporter is experimental and not yet supported." + f"Metric exporter config must have exactly one key, got: {list(config.keys())}" ) - raise ConfigurationError( - "No exporter type specified in push metric exporter config. " - "Supported types: console, otlp_http, otlp_grpc." - ) + name, exporter_config = next(iter(config.items())) + if name in _METRIC_EXPORTER_REGISTRY: + return _METRIC_EXPORTER_REGISTRY[name](exporter_config) + return load_entry_point("opentelemetry_metrics_exporter", name)() def _create_periodic_metric_reader( diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_tracer_provider.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_tracer_provider.py index 32dfd96567b..687a0ec10c1 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, @@ -32,9 +35,6 @@ from opentelemetry.sdk._configuration.models import ( Sampler as SamplerConfig, ) -from opentelemetry.sdk._configuration.models import ( - SpanExporter as SpanExporterConfig, -) from opentelemetry.sdk._configuration.models import ( SpanLimits as SpanLimitsConfig, ) @@ -146,18 +146,28 @@ def _create_otlp_grpc_span_exporter( ) -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() - raise ConfigurationError( - "No exporter type specified in span exporter config. " - "Supported types: otlp_http, otlp_grpc, console." - ) +_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: dict) -> SpanExporter: + """Create a span exporter from a config dict with a single key naming the exporter type. + + Known names (otlp_http, otlp_grpc, console) are bootstrapped directly. + Unknown names are loaded via the ``opentelemetry_traces_exporter`` entry + point group, matching the spec's PluginComponentProvider mechanism. + """ + if len(config) != 1: + raise ConfigurationError( + f"Span exporter config must have exactly one key, got: {list(config.keys())}" + ) + name, exporter_config = next(iter(config.items())) + if name in _SPAN_EXPORTER_REGISTRY: + return _SPAN_EXPORTER_REGISTRY[name](exporter_config) + return load_entry_point("opentelemetry_traces_exporter", name)() def _create_span_processor( diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/models.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/models.py index 51591372285..abd80a56984 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/models.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/models.py @@ -350,12 +350,10 @@ class SeverityNumber(Enum): fatal4 = "fatal4" -@dataclass -class SpanExporter: - otlp_http: OtlpHttpExporter | None = None - otlp_grpc: OtlpGrpcExporter | None = None - otlp_file_development: ExperimentalOtlpFileExporter | None = None - console: ConsoleExporter | None = None +# Diverges from codegen: SpanExporter is typed as dict[str, Any] rather than +# a dataclass so that unknown exporter names (plugin/custom exporters) are +# preserved as dict keys through the config pipeline. +SpanExporter: TypeAlias = dict[str, Any] class SpanKind(Enum): @@ -524,12 +522,10 @@ class ExperimentalTracerConfigurator: tracers: list[ExperimentalTracerMatcherAndConfig] | None = None -@dataclass -class LogRecordExporter: - otlp_http: OtlpHttpExporter | None = None - otlp_grpc: OtlpGrpcExporter | None = None - otlp_file_development: ExperimentalOtlpFileExporter | None = None - console: ConsoleExporter | None = None +# Diverges from codegen: LogRecordExporter is typed as dict[str, Any] rather +# than a dataclass so that unknown exporter names (plugin/custom exporters) +# are preserved as dict keys through the config pipeline. +LogRecordExporter: TypeAlias = dict[str, Any] @dataclass @@ -549,12 +545,10 @@ class PullMetricReader: cardinality_limits: CardinalityLimits | None = None -@dataclass -class PushMetricExporter: - otlp_http: OtlpHttpMetricExporter | None = None - otlp_grpc: OtlpGrpcMetricExporter | None = None - otlp_file_development: ExperimentalOtlpFileMetricExporter | None = None - console: ConsoleMetricExporter | None = None +# Diverges from codegen: PushMetricExporter is typed as dict[str, Any] rather +# than a dataclass so that unknown exporter names (plugin/custom exporters) +# are preserved as dict keys through the config pipeline. +PushMetricExporter: TypeAlias = dict[str, Any] @dataclass diff --git a/opentelemetry-sdk/tests/_configuration/test_logger_provider.py b/opentelemetry-sdk/tests/_configuration/test_logger_provider.py index ff820d105ad..0116a4a2e11 100644 --- a/opentelemetry-sdk/tests/_configuration/test_logger_provider.py +++ b/opentelemetry-sdk/tests/_configuration/test_logger_provider.py @@ -228,16 +228,29 @@ 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})], + ): + result = _create_log_record_exporter({"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): + _create_log_record_exporter({"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 04d60847f02..c101144cae7 100644 --- a/opentelemetry-sdk/tests/_configuration/test_meter_provider.py +++ b/opentelemetry-sdk/tests/_configuration/test_meter_provider.py @@ -306,6 +306,26 @@ 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})], + ): + config = self._make_periodic_config({"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=[], + ): + config = self._make_periodic_config({"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 5caf077cd5e..4d0f4e9c8de 100644 --- a/opentelemetry-sdk/tests/_configuration/test_tracer_provider.py +++ b/opentelemetry-sdk/tests/_configuration/test_tracer_provider.py @@ -361,6 +361,28 @@ 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({"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({"no_such_exporter": {}}) + with self.assertRaises(ConfigurationError): + create_tracer_provider(config) + class TestCreateSpanLimits(unittest.TestCase): # pylint: disable=no-self-use From 25e92570ddc505d2df73206b7da20785728be6b2 Mon Sep 17 00:00:00 2001 From: Mike Goldsmith Date: Mon, 20 Apr 2026 14:31:09 +0100 Subject: [PATCH 2/4] revert models.py changes, use raw dicts without modifying codegen The generated models are unchanged. Python dataclasses don't enforce field types at runtime, so parent fields like BatchSpanProcessor.exporter naturally accept raw dicts (from the YAML loader) alongside typed instances. Tests updated to pass raw dicts consistently. Assisted-by: Claude Opus 4.6 --- .../sdk/_configuration/models.py | 30 ++++++----- .../_configuration/test_logger_provider.py | 51 +++++++++---------- .../_configuration/test_meter_provider.py | 43 +++++++--------- .../_configuration/test_tracer_provider.py | 25 ++++----- 4 files changed, 70 insertions(+), 79 deletions(-) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/models.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/models.py index abd80a56984..51591372285 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/models.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/models.py @@ -350,10 +350,12 @@ class SeverityNumber(Enum): fatal4 = "fatal4" -# Diverges from codegen: SpanExporter is typed as dict[str, Any] rather than -# a dataclass so that unknown exporter names (plugin/custom exporters) are -# preserved as dict keys through the config pipeline. -SpanExporter: TypeAlias = dict[str, Any] +@dataclass +class SpanExporter: + otlp_http: OtlpHttpExporter | None = None + otlp_grpc: OtlpGrpcExporter | None = None + otlp_file_development: ExperimentalOtlpFileExporter | None = None + console: ConsoleExporter | None = None class SpanKind(Enum): @@ -522,10 +524,12 @@ class ExperimentalTracerConfigurator: tracers: list[ExperimentalTracerMatcherAndConfig] | None = None -# Diverges from codegen: LogRecordExporter is typed as dict[str, Any] rather -# than a dataclass so that unknown exporter names (plugin/custom exporters) -# are preserved as dict keys through the config pipeline. -LogRecordExporter: TypeAlias = dict[str, Any] +@dataclass +class LogRecordExporter: + otlp_http: OtlpHttpExporter | None = None + otlp_grpc: OtlpGrpcExporter | None = None + otlp_file_development: ExperimentalOtlpFileExporter | None = None + console: ConsoleExporter | None = None @dataclass @@ -545,10 +549,12 @@ class PullMetricReader: cardinality_limits: CardinalityLimits | None = None -# Diverges from codegen: PushMetricExporter is typed as dict[str, Any] rather -# than a dataclass so that unknown exporter names (plugin/custom exporters) -# are preserved as dict keys through the config pipeline. -PushMetricExporter: TypeAlias = dict[str, Any] +@dataclass +class PushMetricExporter: + otlp_http: OtlpHttpMetricExporter | None = None + otlp_grpc: OtlpGrpcMetricExporter | None = None + otlp_file_development: ExperimentalOtlpFileMetricExporter | None = None + console: ConsoleMetricExporter | None = None @dataclass diff --git a/opentelemetry-sdk/tests/_configuration/test_logger_provider.py b/opentelemetry-sdk/tests/_configuration/test_logger_provider.py index 0116a4a2e11..3de8b5cbf90 100644 --- a/opentelemetry-sdk/tests/_configuration/test_logger_provider.py +++ b/opentelemetry-sdk/tests/_configuration/test_logger_provider.py @@ -39,9 +39,6 @@ from opentelemetry.sdk._configuration.models import ( LoggerProvider as LoggerProviderConfig, ) -from opentelemetry.sdk._configuration.models import ( - LogRecordExporter as LogRecordExporterConfig, -) from opentelemetry.sdk._configuration.models import ( LogRecordLimits as LogRecordLimitsConfig, ) @@ -100,7 +97,7 @@ def _make_batch_config( max_export_batch_size=None, ): if exporter_config is None: - exporter_config = LogRecordExporterConfig(console={}) + exporter_config = {"console": {}} return BatchLogRecordProcessorConfig( exporter=exporter_config, schedule_delay=schedule_delay, @@ -184,9 +181,7 @@ def test_batch_processor_uses_console_exporter(self): ) def test_simple_processor_uses_console_exporter(self): - config = SimpleLogRecordProcessorConfig( - exporter=LogRecordExporterConfig(console={}) - ) + config = SimpleLogRecordProcessorConfig(exporter={"console": {}}) processor = _create_simple_log_record_processor(config) self.assertIsInstance(processor, SimpleLogRecordProcessor) self.assertIsInstance(processor._exporter, ConsoleLogRecordExporter) @@ -198,9 +193,7 @@ def test_batch_processor_dispatched_from_processor_config(self): def test_simple_processor_dispatched_from_processor_config(self): config = LogRecordProcessorConfig( - simple=SimpleLogRecordProcessorConfig( - exporter=LogRecordExporterConfig(console={}) - ) + simple=SimpleLogRecordProcessorConfig(exporter={"console": {}}) ) processor = _create_log_record_processor(config) self.assertIsInstance(processor, SimpleLogRecordProcessor) @@ -224,12 +217,12 @@ def test_batch_processor_suppresses_env_var(self): class TestCreateLogRecordExporters(unittest.TestCase): def test_console_exporter(self): - config = LogRecordExporterConfig(console={}) + config = {"console": {}} exporter = _create_log_record_exporter(config) self.assertIsInstance(exporter, ConsoleLogRecordExporter) def test_no_exporter_type_raises(self): - config = LogRecordExporterConfig() + config = {} with self.assertRaises(ConfigurationError): _create_log_record_exporter(config) @@ -252,9 +245,11 @@ def test_unknown_log_exporter_raises_configuration_error(self): _create_log_record_exporter({"no_such_exporter": {}}) def test_otlp_http_missing_package_raises(self): - config = LogRecordExporterConfig( - otlp_http=OtlpHttpExporterConfig(endpoint="http://localhost:4318") - ) + config = { + "otlp_http": OtlpHttpExporterConfig( + endpoint="http://localhost:4318" + ) + } with patch.dict( sys.modules, { @@ -266,9 +261,11 @@ def test_otlp_http_missing_package_raises(self): _create_log_record_exporter(config) def test_otlp_grpc_missing_package_raises(self): - config = LogRecordExporterConfig( - otlp_grpc=OtlpGrpcExporterConfig(endpoint="http://localhost:4317") - ) + config = { + "otlp_grpc": OtlpGrpcExporterConfig( + endpoint="http://localhost:4317" + ) + } with patch.dict( sys.modules, { @@ -296,12 +293,12 @@ def test_otlp_http_exporter_endpoint(self): "opentelemetry.exporter.otlp.proto.http._log_exporter": mock_log_module, }, ): - config = LogRecordExporterConfig( - otlp_http=OtlpHttpExporterConfig( + config = { + "otlp_http": OtlpHttpExporterConfig( endpoint="http://collector:4318", timeout=5000, ) - ) + } _create_log_record_exporter(config) mock_exporter_cls.assert_called_once() @@ -324,13 +321,13 @@ def test_otlp_http_exporter_headers(self): "opentelemetry.exporter.otlp.proto.http._log_exporter": mock_log_module, }, ): - config = LogRecordExporterConfig( - otlp_http=OtlpHttpExporterConfig( + config = { + "otlp_http": OtlpHttpExporterConfig( headers=[ NameStringValuePair(name="x-api-key", value="secret") ] ) - ) + } _create_log_record_exporter(config) call_kwargs = mock_exporter_cls.call_args.kwargs @@ -350,12 +347,12 @@ def test_otlp_grpc_exporter_endpoint(self): "opentelemetry.exporter.otlp.proto.grpc._log_exporter": mock_grpc_log_module, }, ): - config = LogRecordExporterConfig( - otlp_grpc=OtlpGrpcExporterConfig( + config = { + "otlp_grpc": OtlpGrpcExporterConfig( endpoint="http://collector:4317", timeout=10000, ) - ) + } _create_log_record_exporter(config) mock_exporter_cls.assert_called_once() diff --git a/opentelemetry-sdk/tests/_configuration/test_meter_provider.py b/opentelemetry-sdk/tests/_configuration/test_meter_provider.py index c101144cae7..94647d80750 100644 --- a/opentelemetry-sdk/tests/_configuration/test_meter_provider.py +++ b/opentelemetry-sdk/tests/_configuration/test_meter_provider.py @@ -63,9 +63,6 @@ from opentelemetry.sdk._configuration.models import ( PeriodicMetricReader as PeriodicMetricReaderConfig, ) -from opentelemetry.sdk._configuration.models import ( - PushMetricExporter as PushMetricExporterConfig, -) from opentelemetry.sdk._configuration.models import ( View as ViewConfig, ) @@ -132,9 +129,7 @@ def test_none_config_does_not_read_interval_env_var(self): readers=[ MetricReaderConfig( periodic=PeriodicMetricReaderConfig( - exporter=PushMetricExporterConfig( - console=ConsoleMetricExporterConfig() - ) + exporter={"console": ConsoleMetricExporterConfig()} ) ) ] @@ -188,7 +183,7 @@ def _make_periodic_config(exporter_config, interval=None, timeout=None): def test_console_exporter(self): config = self._make_periodic_config( - PushMetricExporterConfig(console=ConsoleMetricExporterConfig()) + {"console": ConsoleMetricExporterConfig()} ) provider = create_meter_provider(config) reader = provider._sdk_config.metric_readers[0] @@ -197,7 +192,7 @@ def test_console_exporter(self): def test_periodic_reader_default_interval(self): config = self._make_periodic_config( - PushMetricExporterConfig(console=ConsoleMetricExporterConfig()) + {"console": ConsoleMetricExporterConfig()} ) provider = create_meter_provider(config) reader = provider._sdk_config.metric_readers[0] @@ -205,7 +200,7 @@ def test_periodic_reader_default_interval(self): def test_periodic_reader_default_timeout(self): config = self._make_periodic_config( - PushMetricExporterConfig(console=ConsoleMetricExporterConfig()) + {"console": ConsoleMetricExporterConfig()} ) provider = create_meter_provider(config) reader = provider._sdk_config.metric_readers[0] @@ -213,7 +208,7 @@ def test_periodic_reader_default_timeout(self): def test_periodic_reader_explicit_interval(self): config = self._make_periodic_config( - PushMetricExporterConfig(console=ConsoleMetricExporterConfig()), + {"console": ConsoleMetricExporterConfig()}, interval=5000, ) provider = create_meter_provider(config) @@ -222,7 +217,7 @@ def test_periodic_reader_explicit_interval(self): def test_periodic_reader_explicit_timeout(self): config = self._make_periodic_config( - PushMetricExporterConfig(console=ConsoleMetricExporterConfig()), + {"console": ConsoleMetricExporterConfig()}, timeout=10000, ) provider = create_meter_provider(config) @@ -231,7 +226,7 @@ def test_periodic_reader_explicit_timeout(self): def test_otlp_http_missing_package_raises(self): config = self._make_periodic_config( - PushMetricExporterConfig(otlp_http=OtlpHttpMetricExporterConfig()) + {"otlp_http": OtlpHttpMetricExporterConfig()} ) with patch.dict( sys.modules, @@ -260,11 +255,11 @@ def test_otlp_http_created_with_endpoint(self): }, ): config = self._make_periodic_config( - PushMetricExporterConfig( - otlp_http=OtlpHttpMetricExporterConfig( + { + "otlp_http": OtlpHttpMetricExporterConfig( endpoint="http://localhost:4318" ) - ) + } ) create_meter_provider(config) @@ -276,7 +271,7 @@ def test_otlp_http_created_with_endpoint(self): def test_otlp_grpc_missing_package_raises(self): config = self._make_periodic_config( - PushMetricExporterConfig(otlp_grpc=OtlpGrpcMetricExporterConfig()) + {"otlp_grpc": OtlpGrpcMetricExporterConfig()} ) with patch.dict( sys.modules, @@ -302,7 +297,7 @@ def test_no_reader_type_raises(self): create_meter_provider(config) def test_no_exporter_type_raises(self): - config = self._make_periodic_config(PushMetricExporterConfig()) + config = self._make_periodic_config({}) with self.assertRaises(ConfigurationError): create_meter_provider(config) @@ -331,16 +326,12 @@ def test_multiple_readers(self): readers=[ MetricReaderConfig( periodic=PeriodicMetricReaderConfig( - exporter=PushMetricExporterConfig( - console=ConsoleMetricExporterConfig() - ) + exporter={"console": ConsoleMetricExporterConfig()} ) ), MetricReaderConfig( periodic=PeriodicMetricReaderConfig( - exporter=PushMetricExporterConfig( - console=ConsoleMetricExporterConfig() - ) + exporter={"console": ConsoleMetricExporterConfig()} ) ), ] @@ -356,12 +347,12 @@ def _make_console_config(temporality=None, histogram_agg=None): readers=[ MetricReaderConfig( periodic=PeriodicMetricReaderConfig( - exporter=PushMetricExporterConfig( - console=ConsoleMetricExporterConfig( + exporter={ + "console": ConsoleMetricExporterConfig( temporality_preference=temporality, default_histogram_aggregation=histogram_agg, ) - ) + } ) ) ] diff --git a/opentelemetry-sdk/tests/_configuration/test_tracer_provider.py b/opentelemetry-sdk/tests/_configuration/test_tracer_provider.py index 4d0f4e9c8de..1ed4b5d5cd1 100644 --- a/opentelemetry-sdk/tests/_configuration/test_tracer_provider.py +++ b/opentelemetry-sdk/tests/_configuration/test_tracer_provider.py @@ -43,9 +43,6 @@ from opentelemetry.sdk._configuration.models import ( SimpleSpanProcessor as SimpleSpanProcessorConfig, ) -from opentelemetry.sdk._configuration.models import ( - SpanExporter as SpanExporterConfig, -) from opentelemetry.sdk._configuration.models import ( SpanLimits as SpanLimitsConfig, ) @@ -248,7 +245,7 @@ def _make_simple_config(exporter_config): ) def test_console_exporter_batch(self): - config = self._make_batch_config(SpanExporterConfig(console={})) + config = self._make_batch_config({"console": {}}) provider = create_tracer_provider(config) procs = provider._active_span_processor._span_processors self.assertEqual(len(procs), 1) @@ -256,7 +253,7 @@ def test_console_exporter_batch(self): self.assertIsInstance(procs[0].span_exporter, ConsoleSpanExporter) def test_console_exporter_simple(self): - config = self._make_simple_config(SpanExporterConfig(console={})) + config = self._make_simple_config({"console": {}}) provider = create_tracer_provider(config) procs = provider._active_span_processor._span_processors self.assertIsInstance(procs[0], SimpleSpanProcessor) @@ -264,7 +261,7 @@ def test_console_exporter_simple(self): def test_otlp_http_missing_package_raises(self): config = self._make_batch_config( - SpanExporterConfig(otlp_http=OtlpHttpExporterConfig()) + {"otlp_http": OtlpHttpExporterConfig()} ) with patch.dict( sys.modules, @@ -294,11 +291,11 @@ def test_otlp_http_created_with_endpoint(self): }, ): config = self._make_batch_config( - SpanExporterConfig( - otlp_http=OtlpHttpExporterConfig( + { + "otlp_http": OtlpHttpExporterConfig( endpoint="http://localhost:4318" ) - ) + } ) create_tracer_provider(config) @@ -323,11 +320,11 @@ def test_otlp_http_headers_list(self): }, ): config = self._make_batch_config( - SpanExporterConfig( - otlp_http=OtlpHttpExporterConfig( + { + "otlp_http": OtlpHttpExporterConfig( headers_list="x-api-key=secret,env=prod" ) - ) + } ) create_tracer_provider(config) @@ -338,7 +335,7 @@ def test_otlp_http_headers_list(self): def test_otlp_grpc_missing_package_raises(self): config = self._make_batch_config( - SpanExporterConfig(otlp_grpc=OtlpGrpcExporterConfig()) + {"otlp_grpc": OtlpGrpcExporterConfig()} ) with patch.dict( sys.modules, @@ -357,7 +354,7 @@ def test_no_processor_type_raises(self): create_tracer_provider(config) def test_no_exporter_type_raises(self): - config = self._make_batch_config(SpanExporterConfig()) + config = self._make_batch_config({}) with self.assertRaises(ConfigurationError): create_tracer_provider(config) From 4e15e8e300c97bb039f31c2b74d2f6f1624a681c Mon Sep 17 00:00:00 2001 From: Mike Goldsmith Date: Wed, 29 Apr 2026 15:24:08 +0100 Subject: [PATCH 3/4] update exporter plugin loading to use additional_properties Use typed SpanExporterConfig, LogRecordExporterConfig, and PushMetricExporterConfig with additional_properties from the @_additional_properties decorator instead of raw dict iteration. Known exporter types are checked via typed fields and their registries. Unknown plugin names from additional_properties are loaded via the opentelemetry_{traces,logs,metrics}_exporter entry point groups. Assisted-by: Claude Opus 4.6 --- .../sdk/_configuration/_logger_provider.py | 35 +++++++---- .../sdk/_configuration/_meter_provider.py | 35 +++++++---- .../sdk/_configuration/_tracer_provider.py | 33 ++++++---- .../_configuration/test_logger_provider.py | 61 +++++++++++-------- .../_configuration/test_meter_provider.py | 53 ++++++++++------ .../_configuration/test_tracer_provider.py | 35 +++++++---- 6 files changed, 155 insertions(+), 97 deletions(-) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_logger_provider.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_logger_provider.py index 7156507e3ea..f1d70a493da 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_logger_provider.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_logger_provider.py @@ -29,6 +29,9 @@ from opentelemetry.sdk._configuration.models import ( LoggerProvider as LoggerProviderConfig, ) +from opentelemetry.sdk._configuration.models import ( + LogRecordExporter as LogRecordExporterConfig, +) from opentelemetry.sdk._configuration.models import ( LogRecordProcessor as LogRecordProcessorConfig, ) @@ -143,21 +146,27 @@ def _create_otlp_grpc_log_exporter( } -def _create_log_record_exporter(config: dict) -> LogRecordExporter: - """Create a log record exporter from a config dict with a single key naming the exporter type. +def _create_log_record_exporter( + config: LogRecordExporterConfig, +) -> LogRecordExporter: + """Create a log record exporter from config. - Known names (otlp_http, otlp_grpc, console) are bootstrapped directly. - Unknown names are loaded via the ``opentelemetry_logs_exporter`` entry - point group, matching the spec's PluginComponentProvider mechanism. + 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. """ - if len(config) != 1: - raise ConfigurationError( - f"Log exporter config must have exactly one key, got: {list(config.keys())}" - ) - name, exporter_config = next(iter(config.items())) - if name in _LOG_EXPORTER_REGISTRY: - return _LOG_EXPORTER_REGISTRY[name](exporter_config) - return load_entry_point("opentelemetry_logs_exporter", name)() + 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." + ) def _create_batch_log_record_processor( diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_meter_provider.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_meter_provider.py index fa35fd9977a..5f4d30f2e7b 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_meter_provider.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_meter_provider.py @@ -52,6 +52,9 @@ from opentelemetry.sdk._configuration.models import ( PeriodicMetricReader as PeriodicMetricReaderConfig, ) +from opentelemetry.sdk._configuration.models import ( + PushMetricExporter as PushMetricExporterConfig, +) from opentelemetry.sdk._configuration.models import ( View as ViewConfig, ) @@ -356,21 +359,27 @@ def _create_otlp_grpc_metric_exporter( } -def _create_push_metric_exporter(config: dict) -> MetricExporter: - """Create a push metric exporter from a config dict with a single key naming the exporter type. +def _create_push_metric_exporter( + config: PushMetricExporterConfig, +) -> MetricExporter: + """Create a push metric exporter from config. - Known names (otlp_http, otlp_grpc, console) are bootstrapped directly. - Unknown names are loaded via the ``opentelemetry_metrics_exporter`` entry - point group, matching the spec's PluginComponentProvider mechanism. + 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. """ - if len(config) != 1: - raise ConfigurationError( - f"Metric exporter config must have exactly one key, got: {list(config.keys())}" - ) - name, exporter_config = next(iter(config.items())) - if name in _METRIC_EXPORTER_REGISTRY: - return _METRIC_EXPORTER_REGISTRY[name](exporter_config) - return load_entry_point("opentelemetry_metrics_exporter", name)() + 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." + ) def _create_periodic_metric_reader( diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_tracer_provider.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_tracer_provider.py index 687a0ec10c1..db28910cd41 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_tracer_provider.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_tracer_provider.py @@ -35,6 +35,9 @@ from opentelemetry.sdk._configuration.models import ( Sampler as SamplerConfig, ) +from opentelemetry.sdk._configuration.models import ( + SpanExporter as SpanExporterConfig, +) from opentelemetry.sdk._configuration.models import ( SpanLimits as SpanLimitsConfig, ) @@ -153,21 +156,25 @@ def _create_otlp_grpc_span_exporter( } -def _create_span_exporter(config: dict) -> SpanExporter: - """Create a span exporter from a config dict with a single key naming the exporter type. +def _create_span_exporter(config: SpanExporterConfig) -> SpanExporter: + """Create a span exporter from config. - Known names (otlp_http, otlp_grpc, console) are bootstrapped directly. - Unknown names are loaded via the ``opentelemetry_traces_exporter`` entry - point group, matching the spec's PluginComponentProvider mechanism. + 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. """ - if len(config) != 1: - raise ConfigurationError( - f"Span exporter config must have exactly one key, got: {list(config.keys())}" - ) - name, exporter_config = next(iter(config.items())) - if name in _SPAN_EXPORTER_REGISTRY: - return _SPAN_EXPORTER_REGISTRY[name](exporter_config) - return load_entry_point("opentelemetry_traces_exporter", name)() + 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." + ) def _create_span_processor( diff --git a/opentelemetry-sdk/tests/_configuration/test_logger_provider.py b/opentelemetry-sdk/tests/_configuration/test_logger_provider.py index 3de8b5cbf90..40f012dae1b 100644 --- a/opentelemetry-sdk/tests/_configuration/test_logger_provider.py +++ b/opentelemetry-sdk/tests/_configuration/test_logger_provider.py @@ -39,6 +39,9 @@ from opentelemetry.sdk._configuration.models import ( LoggerProvider as LoggerProviderConfig, ) +from opentelemetry.sdk._configuration.models import ( + LogRecordExporter as LogRecordExporterConfig, +) from opentelemetry.sdk._configuration.models import ( LogRecordLimits as LogRecordLimitsConfig, ) @@ -97,7 +100,7 @@ def _make_batch_config( max_export_batch_size=None, ): if exporter_config is None: - exporter_config = {"console": {}} + exporter_config = LogRecordExporterConfig(console={}) return BatchLogRecordProcessorConfig( exporter=exporter_config, schedule_delay=schedule_delay, @@ -181,7 +184,9 @@ def test_batch_processor_uses_console_exporter(self): ) def test_simple_processor_uses_console_exporter(self): - config = SimpleLogRecordProcessorConfig(exporter={"console": {}}) + config = SimpleLogRecordProcessorConfig( + exporter=LogRecordExporterConfig(console={}) + ) processor = _create_simple_log_record_processor(config) self.assertIsInstance(processor, SimpleLogRecordProcessor) self.assertIsInstance(processor._exporter, ConsoleLogRecordExporter) @@ -193,7 +198,9 @@ def test_batch_processor_dispatched_from_processor_config(self): def test_simple_processor_dispatched_from_processor_config(self): config = LogRecordProcessorConfig( - simple=SimpleLogRecordProcessorConfig(exporter={"console": {}}) + simple=SimpleLogRecordProcessorConfig( + exporter=LogRecordExporterConfig(console={}) + ) ) processor = _create_log_record_processor(config) self.assertIsInstance(processor, SimpleLogRecordProcessor) @@ -217,12 +224,12 @@ def test_batch_processor_suppresses_env_var(self): class TestCreateLogRecordExporters(unittest.TestCase): def test_console_exporter(self): - config = {"console": {}} + config = LogRecordExporterConfig(console={}) exporter = _create_log_record_exporter(config) self.assertIsInstance(exporter, ConsoleLogRecordExporter) def test_no_exporter_type_raises(self): - config = {} + config = LogRecordExporterConfig() with self.assertRaises(ConfigurationError): _create_log_record_exporter(config) @@ -233,7 +240,10 @@ def test_plugin_log_exporter_loaded_via_entry_point(self): "opentelemetry.sdk._configuration._common.entry_points", return_value=[MagicMock(**{"load.return_value": mock_class})], ): - result = _create_log_record_exporter({"my_custom_exporter": {}}) + # 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): @@ -242,14 +252,15 @@ def test_unknown_log_exporter_raises_configuration_error(self): return_value=[], ): with self.assertRaises(ConfigurationError): - _create_log_record_exporter({"no_such_exporter": {}}) + # pylint: disable=unexpected-keyword-arg + _create_log_record_exporter( + LogRecordExporterConfig(no_such_exporter={}) + ) def test_otlp_http_missing_package_raises(self): - config = { - "otlp_http": OtlpHttpExporterConfig( - endpoint="http://localhost:4318" - ) - } + config = LogRecordExporterConfig( + otlp_http=OtlpHttpExporterConfig(endpoint="http://localhost:4318") + ) with patch.dict( sys.modules, { @@ -261,11 +272,9 @@ def test_otlp_http_missing_package_raises(self): _create_log_record_exporter(config) def test_otlp_grpc_missing_package_raises(self): - config = { - "otlp_grpc": OtlpGrpcExporterConfig( - endpoint="http://localhost:4317" - ) - } + config = LogRecordExporterConfig( + otlp_grpc=OtlpGrpcExporterConfig(endpoint="http://localhost:4317") + ) with patch.dict( sys.modules, { @@ -293,12 +302,12 @@ def test_otlp_http_exporter_endpoint(self): "opentelemetry.exporter.otlp.proto.http._log_exporter": mock_log_module, }, ): - config = { - "otlp_http": OtlpHttpExporterConfig( + config = LogRecordExporterConfig( + otlp_http=OtlpHttpExporterConfig( endpoint="http://collector:4318", timeout=5000, ) - } + ) _create_log_record_exporter(config) mock_exporter_cls.assert_called_once() @@ -321,13 +330,13 @@ def test_otlp_http_exporter_headers(self): "opentelemetry.exporter.otlp.proto.http._log_exporter": mock_log_module, }, ): - config = { - "otlp_http": OtlpHttpExporterConfig( + config = LogRecordExporterConfig( + otlp_http=OtlpHttpExporterConfig( headers=[ NameStringValuePair(name="x-api-key", value="secret") ] ) - } + ) _create_log_record_exporter(config) call_kwargs = mock_exporter_cls.call_args.kwargs @@ -347,12 +356,12 @@ def test_otlp_grpc_exporter_endpoint(self): "opentelemetry.exporter.otlp.proto.grpc._log_exporter": mock_grpc_log_module, }, ): - config = { - "otlp_grpc": OtlpGrpcExporterConfig( + config = LogRecordExporterConfig( + otlp_grpc=OtlpGrpcExporterConfig( endpoint="http://collector:4317", timeout=10000, ) - } + ) _create_log_record_exporter(config) mock_exporter_cls.assert_called_once() diff --git a/opentelemetry-sdk/tests/_configuration/test_meter_provider.py b/opentelemetry-sdk/tests/_configuration/test_meter_provider.py index 94647d80750..3355895ebed 100644 --- a/opentelemetry-sdk/tests/_configuration/test_meter_provider.py +++ b/opentelemetry-sdk/tests/_configuration/test_meter_provider.py @@ -63,6 +63,9 @@ from opentelemetry.sdk._configuration.models import ( PeriodicMetricReader as PeriodicMetricReaderConfig, ) +from opentelemetry.sdk._configuration.models import ( + PushMetricExporter as PushMetricExporterConfig, +) from opentelemetry.sdk._configuration.models import ( View as ViewConfig, ) @@ -129,7 +132,9 @@ def test_none_config_does_not_read_interval_env_var(self): readers=[ MetricReaderConfig( periodic=PeriodicMetricReaderConfig( - exporter={"console": ConsoleMetricExporterConfig()} + exporter=PushMetricExporterConfig( + console=ConsoleMetricExporterConfig() + ) ) ) ] @@ -183,7 +188,7 @@ def _make_periodic_config(exporter_config, interval=None, timeout=None): def test_console_exporter(self): config = self._make_periodic_config( - {"console": ConsoleMetricExporterConfig()} + PushMetricExporterConfig(console=ConsoleMetricExporterConfig()) ) provider = create_meter_provider(config) reader = provider._sdk_config.metric_readers[0] @@ -192,7 +197,7 @@ def test_console_exporter(self): def test_periodic_reader_default_interval(self): config = self._make_periodic_config( - {"console": ConsoleMetricExporterConfig()} + PushMetricExporterConfig(console=ConsoleMetricExporterConfig()) ) provider = create_meter_provider(config) reader = provider._sdk_config.metric_readers[0] @@ -200,7 +205,7 @@ def test_periodic_reader_default_interval(self): def test_periodic_reader_default_timeout(self): config = self._make_periodic_config( - {"console": ConsoleMetricExporterConfig()} + PushMetricExporterConfig(console=ConsoleMetricExporterConfig()) ) provider = create_meter_provider(config) reader = provider._sdk_config.metric_readers[0] @@ -208,7 +213,7 @@ def test_periodic_reader_default_timeout(self): def test_periodic_reader_explicit_interval(self): config = self._make_periodic_config( - {"console": ConsoleMetricExporterConfig()}, + PushMetricExporterConfig(console=ConsoleMetricExporterConfig()), interval=5000, ) provider = create_meter_provider(config) @@ -217,7 +222,7 @@ def test_periodic_reader_explicit_interval(self): def test_periodic_reader_explicit_timeout(self): config = self._make_periodic_config( - {"console": ConsoleMetricExporterConfig()}, + PushMetricExporterConfig(console=ConsoleMetricExporterConfig()), timeout=10000, ) provider = create_meter_provider(config) @@ -226,7 +231,7 @@ def test_periodic_reader_explicit_timeout(self): def test_otlp_http_missing_package_raises(self): config = self._make_periodic_config( - {"otlp_http": OtlpHttpMetricExporterConfig()} + PushMetricExporterConfig(otlp_http=OtlpHttpMetricExporterConfig()) ) with patch.dict( sys.modules, @@ -255,11 +260,11 @@ def test_otlp_http_created_with_endpoint(self): }, ): config = self._make_periodic_config( - { - "otlp_http": OtlpHttpMetricExporterConfig( + PushMetricExporterConfig( + otlp_http=OtlpHttpMetricExporterConfig( endpoint="http://localhost:4318" ) - } + ) ) create_meter_provider(config) @@ -271,7 +276,7 @@ def test_otlp_http_created_with_endpoint(self): def test_otlp_grpc_missing_package_raises(self): config = self._make_periodic_config( - {"otlp_grpc": OtlpGrpcMetricExporterConfig()} + PushMetricExporterConfig(otlp_grpc=OtlpGrpcMetricExporterConfig()) ) with patch.dict( sys.modules, @@ -297,7 +302,7 @@ def test_no_reader_type_raises(self): create_meter_provider(config) def test_no_exporter_type_raises(self): - config = self._make_periodic_config({}) + config = self._make_periodic_config(PushMetricExporterConfig()) with self.assertRaises(ConfigurationError): create_meter_provider(config) @@ -308,7 +313,10 @@ def test_plugin_metric_exporter_loaded_via_entry_point(self): "opentelemetry.sdk._configuration._common.entry_points", return_value=[MagicMock(**{"load.return_value": mock_class})], ): - config = self._make_periodic_config({"my_custom_exporter": {}}) + # 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) @@ -317,7 +325,10 @@ def test_unknown_metric_exporter_raises_configuration_error(self): "opentelemetry.sdk._configuration._common.entry_points", return_value=[], ): - config = self._make_periodic_config({"no_such_exporter": {}}) + # pylint: disable=unexpected-keyword-arg + config = self._make_periodic_config( + PushMetricExporterConfig(no_such_exporter={}) + ) with self.assertRaises(ConfigurationError): create_meter_provider(config) @@ -326,12 +337,16 @@ def test_multiple_readers(self): readers=[ MetricReaderConfig( periodic=PeriodicMetricReaderConfig( - exporter={"console": ConsoleMetricExporterConfig()} + exporter=PushMetricExporterConfig( + console=ConsoleMetricExporterConfig() + ) ) ), MetricReaderConfig( periodic=PeriodicMetricReaderConfig( - exporter={"console": ConsoleMetricExporterConfig()} + exporter=PushMetricExporterConfig( + console=ConsoleMetricExporterConfig() + ) ) ), ] @@ -347,12 +362,12 @@ def _make_console_config(temporality=None, histogram_agg=None): readers=[ MetricReaderConfig( periodic=PeriodicMetricReaderConfig( - exporter={ - "console": ConsoleMetricExporterConfig( + exporter=PushMetricExporterConfig( + console=ConsoleMetricExporterConfig( temporality_preference=temporality, default_histogram_aggregation=histogram_agg, ) - } + ) ) ) ] diff --git a/opentelemetry-sdk/tests/_configuration/test_tracer_provider.py b/opentelemetry-sdk/tests/_configuration/test_tracer_provider.py index 1ed4b5d5cd1..c1aa7f9cbc7 100644 --- a/opentelemetry-sdk/tests/_configuration/test_tracer_provider.py +++ b/opentelemetry-sdk/tests/_configuration/test_tracer_provider.py @@ -43,6 +43,9 @@ from opentelemetry.sdk._configuration.models import ( SimpleSpanProcessor as SimpleSpanProcessorConfig, ) +from opentelemetry.sdk._configuration.models import ( + SpanExporter as SpanExporterConfig, +) from opentelemetry.sdk._configuration.models import ( SpanLimits as SpanLimitsConfig, ) @@ -245,7 +248,7 @@ def _make_simple_config(exporter_config): ) def test_console_exporter_batch(self): - config = self._make_batch_config({"console": {}}) + config = self._make_batch_config(SpanExporterConfig(console={})) provider = create_tracer_provider(config) procs = provider._active_span_processor._span_processors self.assertEqual(len(procs), 1) @@ -253,7 +256,7 @@ def test_console_exporter_batch(self): self.assertIsInstance(procs[0].span_exporter, ConsoleSpanExporter) def test_console_exporter_simple(self): - config = self._make_simple_config({"console": {}}) + config = self._make_simple_config(SpanExporterConfig(console={})) provider = create_tracer_provider(config) procs = provider._active_span_processor._span_processors self.assertIsInstance(procs[0], SimpleSpanProcessor) @@ -261,7 +264,7 @@ def test_console_exporter_simple(self): def test_otlp_http_missing_package_raises(self): config = self._make_batch_config( - {"otlp_http": OtlpHttpExporterConfig()} + SpanExporterConfig(otlp_http=OtlpHttpExporterConfig()) ) with patch.dict( sys.modules, @@ -291,11 +294,11 @@ def test_otlp_http_created_with_endpoint(self): }, ): config = self._make_batch_config( - { - "otlp_http": OtlpHttpExporterConfig( + SpanExporterConfig( + otlp_http=OtlpHttpExporterConfig( endpoint="http://localhost:4318" ) - } + ) ) create_tracer_provider(config) @@ -320,11 +323,11 @@ def test_otlp_http_headers_list(self): }, ): config = self._make_batch_config( - { - "otlp_http": OtlpHttpExporterConfig( + SpanExporterConfig( + otlp_http=OtlpHttpExporterConfig( headers_list="x-api-key=secret,env=prod" ) - } + ) ) create_tracer_provider(config) @@ -335,7 +338,7 @@ def test_otlp_http_headers_list(self): def test_otlp_grpc_missing_package_raises(self): config = self._make_batch_config( - {"otlp_grpc": OtlpGrpcExporterConfig()} + SpanExporterConfig(otlp_grpc=OtlpGrpcExporterConfig()) ) with patch.dict( sys.modules, @@ -354,7 +357,7 @@ def test_no_processor_type_raises(self): create_tracer_provider(config) def test_no_exporter_type_raises(self): - config = self._make_batch_config({}) + config = self._make_batch_config(SpanExporterConfig()) with self.assertRaises(ConfigurationError): create_tracer_provider(config) @@ -365,7 +368,10 @@ def test_plugin_span_exporter_loaded_via_entry_point(self): "opentelemetry.sdk._configuration._common.entry_points", return_value=[MagicMock(**{"load.return_value": mock_class})], ): - config = self._make_batch_config({"my_custom_exporter": {}}) + 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 @@ -376,7 +382,10 @@ def test_unknown_span_exporter_raises_configuration_error(self): "opentelemetry.sdk._configuration._common.entry_points", return_value=[], ): - config = self._make_batch_config({"no_such_exporter": {}}) + config = self._make_batch_config( + # pylint: disable=unexpected-keyword-arg + SpanExporterConfig(no_such_exporter={}) + ) with self.assertRaises(ConfigurationError): create_tracer_provider(config) From cb1992cccfb8403f1a58e7192147fc9f718ff698 Mon Sep 17 00:00:00 2001 From: Mike Goldsmith Date: Mon, 11 May 2026 14:31:27 +0100 Subject: [PATCH 4/4] address review: consistent console exporter in registry, merge main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make console exporter registration consistent across signals: - traces: lambda _: ConsoleSpanExporter() (simple constructor) - logs: lambda _: ConsoleLogRecordExporter() (was wrapping a helper) - metrics: _create_console_metric_exporter (named function, takes temporality/aggregation config) Metrics uses a named function because it needs to process config args. Traces and logs are simple constructors — no wrapper needed. Assisted-by: Claude Opus 4.6 --- .../src/opentelemetry/sdk/_configuration/_logger_provider.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_logger_provider.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_logger_provider.py index 17297e12288..6718d5635d0 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_logger_provider.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_logger_provider.py @@ -130,7 +130,7 @@ 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(), + "console": lambda _: ConsoleLogRecordExporter(), }