From 384d1d610cdfa404b1fdd6e32019d6bf1a652011 Mon Sep 17 00:00:00 2001 From: Mike Goldsmith Date: Fri, 15 May 2026 13:54:29 +0100 Subject: [PATCH 1/7] add pull metric reader support to declarative config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements PullMetricReader and PullMetricExporter support in declarative file configuration: - _create_prometheus_metric_reader() — dynamically imports PrometheusMetricReader, maps config fields (host, port, without_target_info_development), and starts HTTP server - _create_pull_metric_exporter() — dispatches to prometheus or loads plugin readers via opentelemetry_pull_metric_exporter entry point group - _create_pull_metric_reader() — handles producers (warns, #5074) and cardinality_limits (warns), delegates to exporter factory producers and cardinality_limits log warnings as not yet supported. Assisted-by: Claude Opus 4.6 --- .changelog/XXXX.added | 1 + .../sdk/_configuration/_meter_provider.py | 108 +++++++++- .../_configuration/test_meter_provider.py | 191 +++++++++++++++++- 3 files changed, 292 insertions(+), 8 deletions(-) create mode 100644 .changelog/XXXX.added diff --git a/.changelog/XXXX.added b/.changelog/XXXX.added new file mode 100644 index 00000000000..eb2ae696017 --- /dev/null +++ b/.changelog/XXXX.added @@ -0,0 +1 @@ +`opentelemetry-sdk`: add pull metric reader support to declarative file configuration, including Prometheus metric reader via the `prometheus_development` config field diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_meter_provider.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_meter_provider.py index 2bfd11b4223..f4e3c6cce51 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, @@ -37,6 +40,12 @@ from opentelemetry.sdk._configuration.models import ( PeriodicMetricReader as PeriodicMetricReaderConfig, ) +from opentelemetry.sdk._configuration.models import ( + PullMetricExporter as PullMetricExporterConfig, +) +from opentelemetry.sdk._configuration.models import ( + PullMetricReader as PullMetricReaderConfig, +) from opentelemetry.sdk._configuration.models import ( PushMetricExporter as PushMetricExporterConfig, ) @@ -382,18 +391,105 @@ def _create_periodic_metric_reader( ) +def _create_prometheus_metric_reader(config) -> MetricReader: + """Create a PrometheusMetricReader from config. + + Dynamically imports the prometheus exporter package to avoid a hard + dependency. Maps config fields to constructor parameters and starts + the HTTP server. + """ + try: + # pylint: disable=import-outside-toplevel,no-name-in-module + from opentelemetry.exporter.prometheus import ( # type: ignore[import-untyped] # noqa: PLC0415 + PrometheusMetricReader, + start_http_server, + ) + except ImportError as exc: + raise ConfigurationError( + "prometheus pull metric exporter requires " + "'opentelemetry-exporter-prometheus'. " + "Install it with: pip install opentelemetry-exporter-prometheus" + ) from exc + + disable_target_info = bool(config.without_target_info_development or False) + + if config.without_scope_info is not None: + _logger.warning( + "without_scope_info is not yet supported for " + "Prometheus metric exporter and will be ignored." + ) + if config.with_resource_constant_labels is not None: + _logger.warning( + "with_resource_constant_labels is not yet supported for " + "Prometheus metric exporter and will be ignored." + ) + + reader = PrometheusMetricReader( + disable_target_info=disable_target_info, + ) + + port = config.port if config.port is not None else 9464 + host = config.host if config.host is not None else "localhost" + start_http_server(port=port, addr=host) + + return reader + + +def _create_pull_metric_exporter( + config: PullMetricExporterConfig, +) -> MetricReader: + """Create a pull metric exporter (which is itself a MetricReader) from config. + + Pull metric exporters like Prometheus are combined reader+exporter objects: + the "exporter" IS the reader. The config schema models them as separate + exporter configs, but the factory returns a MetricReader. + + Plugin pull exporters are loaded via the ``opentelemetry_pull_metric_exporter`` + entry point group. + """ + if config.prometheus_development is not None: + return _create_prometheus_metric_reader(config.prometheus_development) + if config.additional_properties: + name, plugin_config = next(iter(config.additional_properties.items())) + return load_entry_point("opentelemetry_pull_metric_exporter", name)( + **(plugin_config or {}) + ) + raise ConfigurationError( + "No exporter type specified in pull metric exporter config. " + "Supported types: prometheus_development." + ) + + +def _create_pull_metric_reader( + config: PullMetricReaderConfig, +) -> MetricReader: + """Create a pull MetricReader from config. + + The pull reader's exporter is itself a MetricReader (combined reader+exporter). + producers and cardinality_limits are not yet supported. + """ + if config.producers: + _logger.warning( + "MetricProducer configuration is not yet supported for " + "pull metric readers and will be ignored." + ) + if config.cardinality_limits is not None: + _logger.warning( + "cardinality_limits is not yet supported for " + "pull metric readers and will be ignored." + ) + return _create_pull_metric_exporter(config.exporter) + + def _create_metric_reader(config: MetricReaderConfig) -> MetricReader: """Create a MetricReader from config.""" if config.periodic is not None: return _create_periodic_metric_reader(config.periodic) if config.pull is not None: - raise ConfigurationError( - "Pull metric readers (e.g. Prometheus) are experimental and not yet supported " - "by declarative config. Use the SDK API directly to configure pull readers." - ) + return _create_pull_metric_reader(config.pull) raise ConfigurationError( "No reader type specified in metric reader config. " - "Supported types: periodic." + "Supported types: periodic, pull." ) diff --git a/opentelemetry-sdk/tests/_configuration/test_meter_provider.py b/opentelemetry-sdk/tests/_configuration/test_meter_provider.py index de153dd78bf..d3fd24d753f 100644 --- a/opentelemetry-sdk/tests/_configuration/test_meter_provider.py +++ b/opentelemetry-sdk/tests/_configuration/test_meter_provider.py @@ -26,6 +26,9 @@ from opentelemetry.sdk._configuration.models import ( ExemplarFilter as ExemplarFilterConfig, ) +from opentelemetry.sdk._configuration.models import ( + ExperimentalPrometheusMetricExporter as PrometheusMetricExporterConfig, +) from opentelemetry.sdk._configuration.models import ( ExplicitBucketHistogramAggregation as ExplicitBucketConfig, ) @@ -52,6 +55,12 @@ from opentelemetry.sdk._configuration.models import ( PeriodicMetricReader as PeriodicMetricReaderConfig, ) +from opentelemetry.sdk._configuration.models import ( + PullMetricExporter as PullMetricExporterConfig, +) +from opentelemetry.sdk._configuration.models import ( + PullMetricReader as PullMetricReaderConfig, +) from opentelemetry.sdk._configuration.models import ( PushMetricExporter as PushMetricExporterConfig, ) @@ -278,13 +287,191 @@ def test_otlp_grpc_missing_package_raises(self): create_meter_provider(config) self.assertIn("otlp-proto-grpc", str(ctx.exception)) - def test_pull_reader_raises(self): + def test_pull_prometheus_creates_reader(self): + mock_reader_cls = MagicMock() + mock_start_server = MagicMock() + mock_module = MagicMock() + mock_module.PrometheusMetricReader = mock_reader_cls + mock_module.start_http_server = mock_start_server + + with patch.dict( + sys.modules, + {"opentelemetry.exporter.prometheus": mock_module}, + ): + config = MeterProviderConfig( + readers=[ + MetricReaderConfig( + pull=PullMetricReaderConfig( + exporter=PullMetricExporterConfig( + prometheus_development=PrometheusMetricExporterConfig( + host="0.0.0.0", + port=9090, + without_target_info_development=True, + ) + ) + ) + ) + ] + ) + create_meter_provider(config) + + mock_reader_cls.assert_called_once_with(disable_target_info=True) + mock_start_server.assert_called_once_with(port=9090, addr="0.0.0.0") + + def test_pull_prometheus_defaults(self): + mock_reader_cls = MagicMock() + mock_start_server = MagicMock() + mock_module = MagicMock() + mock_module.PrometheusMetricReader = mock_reader_cls + mock_module.start_http_server = mock_start_server + + with patch.dict( + sys.modules, + {"opentelemetry.exporter.prometheus": mock_module}, + ): + config = MeterProviderConfig( + readers=[ + MetricReaderConfig( + pull=PullMetricReaderConfig( + exporter=PullMetricExporterConfig( + prometheus_development=PrometheusMetricExporterConfig() + ) + ) + ) + ] + ) + create_meter_provider(config) + + mock_reader_cls.assert_called_once_with(disable_target_info=False) + mock_start_server.assert_called_once_with(port=9464, addr="localhost") + + def test_pull_prometheus_missing_package_raises(self): + with patch.dict( + sys.modules, + {"opentelemetry.exporter.prometheus": None}, + ): + config = MeterProviderConfig( + readers=[ + MetricReaderConfig( + pull=PullMetricReaderConfig( + exporter=PullMetricExporterConfig( + prometheus_development=PrometheusMetricExporterConfig() + ) + ) + ) + ] + ) + with self.assertRaises(ConfigurationError): + create_meter_provider(config) + + def test_pull_no_exporter_raises(self): config = MeterProviderConfig( - readers=[MetricReaderConfig(pull=MagicMock())] + readers=[ + MetricReaderConfig( + pull=PullMetricReaderConfig( + exporter=PullMetricExporterConfig() + ) + ) + ] ) with self.assertRaises(ConfigurationError): create_meter_provider(config) + def test_pull_plugin_loads_via_entry_point(self): + mock_reader = MagicMock() + mock_class = MagicMock(return_value=mock_reader) + with patch( + "opentelemetry.sdk._configuration._common.entry_points", + return_value=[MagicMock(**{"load.return_value": mock_class})], + ): + config = MeterProviderConfig( + readers=[ + MetricReaderConfig( + pull=PullMetricReaderConfig( + # pylint: disable=unexpected-keyword-arg + exporter=PullMetricExporterConfig( + my_custom_reader={"port": 8080} + ) + ) + ) + ] + ) + provider = create_meter_provider(config) + self.assertEqual(len(provider._sdk_config.metric_readers), 1) + mock_class.assert_called_once_with(port=8080) + + def test_pull_plugin_not_found_raises(self): + with patch( + "opentelemetry.sdk._configuration._common.entry_points", + return_value=[], + ): + config = MeterProviderConfig( + readers=[ + MetricReaderConfig( + pull=PullMetricReaderConfig( + # pylint: disable=unexpected-keyword-arg + exporter=PullMetricExporterConfig( + no_such_reader={} + ) + ) + ) + ] + ) + with self.assertRaises(ConfigurationError): + create_meter_provider(config) + + def test_pull_producers_warns(self): + mock_module = MagicMock() + + with patch.dict( + sys.modules, + {"opentelemetry.exporter.prometheus": mock_module}, + ): + config = MeterProviderConfig( + readers=[ + MetricReaderConfig( + pull=PullMetricReaderConfig( + exporter=PullMetricExporterConfig( + prometheus_development=PrometheusMetricExporterConfig() + ), + producers=[MagicMock()], + ) + ) + ] + ) + with self.assertLogs( + "opentelemetry.sdk._configuration._meter_provider", + level="WARNING", + ) as cm: + create_meter_provider(config) + self.assertTrue(any("MetricProducer" in msg for msg in cm.output)) + + def test_pull_cardinality_limits_warns(self): + mock_module = MagicMock() + + with patch.dict( + sys.modules, + {"opentelemetry.exporter.prometheus": mock_module}, + ): + config = MeterProviderConfig( + readers=[ + MetricReaderConfig( + pull=PullMetricReaderConfig( + exporter=PullMetricExporterConfig( + prometheus_development=PrometheusMetricExporterConfig() + ), + cardinality_limits=MagicMock(), + ) + ) + ] + ) + with self.assertLogs( + "opentelemetry.sdk._configuration._meter_provider", + level="WARNING", + ) as cm: + create_meter_provider(config) + self.assertTrue(any("cardinality_limits" in msg for msg in cm.output)) + def test_no_reader_type_raises(self): config = MeterProviderConfig(readers=[MetricReaderConfig()]) with self.assertRaises(ConfigurationError): From a346b17b7deefd09abb8ea4c5386e476cd6defd5 Mon Sep 17 00:00:00 2001 From: Mike Goldsmith Date: Fri, 15 May 2026 13:55:01 +0100 Subject: [PATCH 2/7] rename changelog fragment to PR #5216 --- .changelog/{XXXX.added => 5216.added} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .changelog/{XXXX.added => 5216.added} (100%) diff --git a/.changelog/XXXX.added b/.changelog/5216.added similarity index 100% rename from .changelog/XXXX.added rename to .changelog/5216.added From 687761892cd47958e42385dca990df8e878bc68c Mon Sep 17 00:00:00 2001 From: Mike Goldsmith Date: Fri, 15 May 2026 14:16:48 +0100 Subject: [PATCH 3/7] fix pylint R1726: simplify boolean condition Remove redundant 'or False' from bool() conversion. Assisted-by: Claude Opus 4.6 --- .../src/opentelemetry/sdk/_configuration/_meter_provider.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_meter_provider.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_meter_provider.py index f4e3c6cce51..25fa407feeb 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_meter_provider.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_meter_provider.py @@ -411,7 +411,7 @@ def _create_prometheus_metric_reader(config) -> MetricReader: "Install it with: pip install opentelemetry-exporter-prometheus" ) from exc - disable_target_info = bool(config.without_target_info_development or False) + disable_target_info = bool(config.without_target_info_development) if config.without_scope_info is not None: _logger.warning( From 99dd5f570d7319e8683849acc3cb56e4c6eb3341 Mon Sep 17 00:00:00 2001 From: Mike Goldsmith Date: Fri, 15 May 2026 14:43:07 +0100 Subject: [PATCH 4/7] fix pylint R6301: use self in prometheus test methods Add provider assertions so test methods use self, satisfying pylint's no-self-use check. Assisted-by: Claude Opus 4.6 --- .../tests/_configuration/test_meter_provider.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/opentelemetry-sdk/tests/_configuration/test_meter_provider.py b/opentelemetry-sdk/tests/_configuration/test_meter_provider.py index d3fd24d753f..6bc50701ec7 100644 --- a/opentelemetry-sdk/tests/_configuration/test_meter_provider.py +++ b/opentelemetry-sdk/tests/_configuration/test_meter_provider.py @@ -313,10 +313,11 @@ def test_pull_prometheus_creates_reader(self): ) ] ) - create_meter_provider(config) + provider = create_meter_provider(config) mock_reader_cls.assert_called_once_with(disable_target_info=True) mock_start_server.assert_called_once_with(port=9090, addr="0.0.0.0") + self.assertEqual(len(provider._sdk_config.metric_readers), 1) def test_pull_prometheus_defaults(self): mock_reader_cls = MagicMock() @@ -340,10 +341,11 @@ def test_pull_prometheus_defaults(self): ) ] ) - create_meter_provider(config) + provider = create_meter_provider(config) mock_reader_cls.assert_called_once_with(disable_target_info=False) mock_start_server.assert_called_once_with(port=9464, addr="localhost") + self.assertEqual(len(provider._sdk_config.metric_readers), 1) def test_pull_prometheus_missing_package_raises(self): with patch.dict( From ebc4498d54d615908c4b7d587162cf24348ee9ab Mon Sep 17 00:00:00 2001 From: Mike Goldsmith Date: Mon, 18 May 2026 15:30:09 +0100 Subject: [PATCH 5/7] address review: add type annotation, validate entry point group in test - Add PrometheusMetricExporterConfig type annotation to _create_prometheus_metric_reader config parameter - Assert entry_points is called with the correct group name (opentelemetry_pull_metric_exporter) in plugin loading test Assisted-by: Claude Opus 4.6 --- .../opentelemetry/sdk/_configuration/_meter_provider.py | 7 ++++++- .../tests/_configuration/test_meter_provider.py | 9 ++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_meter_provider.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_meter_provider.py index 25fa407feeb..7d8687e1739 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_meter_provider.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_meter_provider.py @@ -20,6 +20,9 @@ from opentelemetry.sdk._configuration.models import ( ExemplarFilter as ExemplarFilterConfig, ) +from opentelemetry.sdk._configuration.models import ( + ExperimentalPrometheusMetricExporter as PrometheusMetricExporterConfig, +) from opentelemetry.sdk._configuration.models import ( ExporterDefaultHistogramAggregation, ExporterTemporalityPreference, @@ -391,7 +394,9 @@ def _create_periodic_metric_reader( ) -def _create_prometheus_metric_reader(config) -> MetricReader: +def _create_prometheus_metric_reader( + config: PrometheusMetricExporterConfig, +) -> MetricReader: """Create a PrometheusMetricReader from config. Dynamically imports the prometheus exporter package to avoid a hard diff --git a/opentelemetry-sdk/tests/_configuration/test_meter_provider.py b/opentelemetry-sdk/tests/_configuration/test_meter_provider.py index 6bc50701ec7..f2e1d5365e1 100644 --- a/opentelemetry-sdk/tests/_configuration/test_meter_provider.py +++ b/opentelemetry-sdk/tests/_configuration/test_meter_provider.py @@ -382,9 +382,12 @@ def test_pull_no_exporter_raises(self): def test_pull_plugin_loads_via_entry_point(self): mock_reader = MagicMock() mock_class = MagicMock(return_value=mock_reader) + mock_entry_points = MagicMock( + return_value=[MagicMock(**{"load.return_value": mock_class})] + ) with patch( "opentelemetry.sdk._configuration._common.entry_points", - return_value=[MagicMock(**{"load.return_value": mock_class})], + mock_entry_points, ): config = MeterProviderConfig( readers=[ @@ -401,6 +404,10 @@ def test_pull_plugin_loads_via_entry_point(self): provider = create_meter_provider(config) self.assertEqual(len(provider._sdk_config.metric_readers), 1) mock_class.assert_called_once_with(port=8080) + mock_entry_points.assert_called_once_with( + group="opentelemetry_pull_metric_exporter", + name="my_custom_reader", + ) def test_pull_plugin_not_found_raises(self): with patch( From b03875fd6649d7ca328be3f7d3fa689f6c4ab044 Mon Sep 17 00:00:00 2001 From: Mike Goldsmith Date: Tue, 19 May 2026 12:45:47 +0100 Subject: [PATCH 6/7] split pull metric reader tests into separate class to fix pylint R0904 --- opentelemetry-sdk/tests/_configuration/test_meter_provider.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/opentelemetry-sdk/tests/_configuration/test_meter_provider.py b/opentelemetry-sdk/tests/_configuration/test_meter_provider.py index f2e1d5365e1..6ae61ff21f9 100644 --- a/opentelemetry-sdk/tests/_configuration/test_meter_provider.py +++ b/opentelemetry-sdk/tests/_configuration/test_meter_provider.py @@ -287,6 +287,8 @@ def test_otlp_grpc_missing_package_raises(self): create_meter_provider(config) self.assertIn("otlp-proto-grpc", str(ctx.exception)) + +class TestCreatePullMetricReaders(unittest.TestCase): def test_pull_prometheus_creates_reader(self): mock_reader_cls = MagicMock() mock_start_server = MagicMock() From 9620bb6fdb11df7e232f97d4e51e6228b115bb46 Mon Sep 17 00:00:00 2001 From: Mike Goldsmith Date: Tue, 19 May 2026 12:49:04 +0100 Subject: [PATCH 7/7] fix test class split: move push exporter tests to separate class The merge brought in push exporter plugin tests from #5128 which ended up in TestCreatePullMetricReaders. Moved them to a new TestCreateMetricReadersGeneral class with the _make_periodic_config helper they need. Assisted-by: Claude Opus 4.6 --- .../tests/_configuration/test_meter_provider.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/opentelemetry-sdk/tests/_configuration/test_meter_provider.py b/opentelemetry-sdk/tests/_configuration/test_meter_provider.py index dbd1cbef937..0cbb53cbc74 100644 --- a/opentelemetry-sdk/tests/_configuration/test_meter_provider.py +++ b/opentelemetry-sdk/tests/_configuration/test_meter_provider.py @@ -511,6 +511,20 @@ def test_pull_cardinality_limits_warns(self): create_meter_provider(config) self.assertTrue(any("cardinality_limits" in msg for msg in cm.output)) + +class TestCreateMetricReadersGeneral(unittest.TestCase): + @staticmethod + def _make_periodic_config(exporter_config): + return MeterProviderConfig( + readers=[ + MetricReaderConfig( + periodic=PeriodicMetricReaderConfig( + exporter=exporter_config + ) + ) + ] + ) + def test_no_reader_type_raises(self): config = MeterProviderConfig(readers=[MetricReaderConfig()]) with self.assertRaises(ConfigurationError):