diff --git a/.changelog/5216.added b/.changelog/5216.added new file mode 100644 index 0000000000..eb2ae69601 --- /dev/null +++ b/.changelog/5216.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 5cb42ebebc..1b8bd911de 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_meter_provider.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_meter_provider.py @@ -21,6 +21,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, @@ -41,6 +44,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, ) @@ -392,18 +401,107 @@ def _create_periodic_metric_reader( ) +def _create_prometheus_metric_reader( + config: PrometheusMetricExporterConfig, +) -> 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) + + 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 f3a48acad4..0cbb53cbc7 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, ) @@ -306,13 +315,216 @@ 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): + +class TestCreatePullMetricReaders(unittest.TestCase): + 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, + ) + ) + ) + ) + ] + ) + 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() + 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() + ) + ) + ) + ] + ) + 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( + 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) + mock_entry_points = MagicMock( + return_value=[MagicMock(**{"load.return_value": mock_class})] + ) + with patch( + "opentelemetry.sdk._configuration._common.entry_points", + mock_entry_points, + ): + 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) + 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( + "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)) + + +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):