Skip to content

Commit 6cab5dc

Browse files
feat(config): add pull metric reader support to declarative config (#5216)
* add pull metric reader support to declarative config 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 * rename changelog fragment to PR #5216 * fix pylint R1726: simplify boolean condition Remove redundant 'or False' from bool() conversion. Assisted-by: Claude Opus 4.6 * 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 * 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 * split pull metric reader tests into separate class to fix pylint R0904 * 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 --------- Co-authored-by: Lukas Hering <40302054+herin049@users.noreply.github.com>
1 parent 1731583 commit 6cab5dc

3 files changed

Lines changed: 318 additions & 7 deletions

File tree

.changelog/5216.added

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
`opentelemetry-sdk`: add pull metric reader support to declarative file configuration, including Prometheus metric reader via the `prometheus_development` config field

opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_meter_provider.py

Lines changed: 103 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@
2121
from opentelemetry.sdk._configuration.models import (
2222
ExemplarFilter as ExemplarFilterConfig,
2323
)
24+
from opentelemetry.sdk._configuration.models import (
25+
ExperimentalPrometheusMetricExporter as PrometheusMetricExporterConfig,
26+
)
2427
from opentelemetry.sdk._configuration.models import (
2528
ExporterDefaultHistogramAggregation,
2629
ExporterTemporalityPreference,
@@ -41,6 +44,12 @@
4144
from opentelemetry.sdk._configuration.models import (
4245
PeriodicMetricReader as PeriodicMetricReaderConfig,
4346
)
47+
from opentelemetry.sdk._configuration.models import (
48+
PullMetricExporter as PullMetricExporterConfig,
49+
)
50+
from opentelemetry.sdk._configuration.models import (
51+
PullMetricReader as PullMetricReaderConfig,
52+
)
4453
from opentelemetry.sdk._configuration.models import (
4554
PushMetricExporter as PushMetricExporterConfig,
4655
)
@@ -392,18 +401,107 @@ def _create_periodic_metric_reader(
392401
)
393402

394403

404+
def _create_prometheus_metric_reader(
405+
config: PrometheusMetricExporterConfig,
406+
) -> MetricReader:
407+
"""Create a PrometheusMetricReader from config.
408+
409+
Dynamically imports the prometheus exporter package to avoid a hard
410+
dependency. Maps config fields to constructor parameters and starts
411+
the HTTP server.
412+
"""
413+
try:
414+
# pylint: disable=import-outside-toplevel,no-name-in-module
415+
from opentelemetry.exporter.prometheus import ( # type: ignore[import-untyped] # noqa: PLC0415
416+
PrometheusMetricReader,
417+
start_http_server,
418+
)
419+
except ImportError as exc:
420+
raise ConfigurationError(
421+
"prometheus pull metric exporter requires "
422+
"'opentelemetry-exporter-prometheus'. "
423+
"Install it with: pip install opentelemetry-exporter-prometheus"
424+
) from exc
425+
426+
disable_target_info = bool(config.without_target_info_development)
427+
428+
if config.without_scope_info is not None:
429+
_logger.warning(
430+
"without_scope_info is not yet supported for "
431+
"Prometheus metric exporter and will be ignored."
432+
)
433+
if config.with_resource_constant_labels is not None:
434+
_logger.warning(
435+
"with_resource_constant_labels is not yet supported for "
436+
"Prometheus metric exporter and will be ignored."
437+
)
438+
439+
reader = PrometheusMetricReader(
440+
disable_target_info=disable_target_info,
441+
)
442+
443+
port = config.port if config.port is not None else 9464
444+
host = config.host if config.host is not None else "localhost"
445+
start_http_server(port=port, addr=host)
446+
447+
return reader
448+
449+
450+
def _create_pull_metric_exporter(
451+
config: PullMetricExporterConfig,
452+
) -> MetricReader:
453+
"""Create a pull metric exporter (which is itself a MetricReader) from config.
454+
455+
Pull metric exporters like Prometheus are combined reader+exporter objects:
456+
the "exporter" IS the reader. The config schema models them as separate
457+
exporter configs, but the factory returns a MetricReader.
458+
459+
Plugin pull exporters are loaded via the ``opentelemetry_pull_metric_exporter``
460+
entry point group.
461+
"""
462+
if config.prometheus_development is not None:
463+
return _create_prometheus_metric_reader(config.prometheus_development)
464+
if config.additional_properties:
465+
name, plugin_config = next(iter(config.additional_properties.items()))
466+
return load_entry_point("opentelemetry_pull_metric_exporter", name)(
467+
**(plugin_config or {})
468+
)
469+
raise ConfigurationError(
470+
"No exporter type specified in pull metric exporter config. "
471+
"Supported types: prometheus_development."
472+
)
473+
474+
475+
def _create_pull_metric_reader(
476+
config: PullMetricReaderConfig,
477+
) -> MetricReader:
478+
"""Create a pull MetricReader from config.
479+
480+
The pull reader's exporter is itself a MetricReader (combined reader+exporter).
481+
producers and cardinality_limits are not yet supported.
482+
"""
483+
if config.producers:
484+
_logger.warning(
485+
"MetricProducer configuration is not yet supported for "
486+
"pull metric readers and will be ignored."
487+
)
488+
if config.cardinality_limits is not None:
489+
_logger.warning(
490+
"cardinality_limits is not yet supported for "
491+
"pull metric readers and will be ignored."
492+
)
493+
return _create_pull_metric_exporter(config.exporter)
494+
495+
395496
def _create_metric_reader(config: MetricReaderConfig) -> MetricReader:
396497
"""Create a MetricReader from config."""
397498
if config.periodic is not None:
398499
return _create_periodic_metric_reader(config.periodic)
399500
if config.pull is not None:
400-
raise ConfigurationError(
401-
"Pull metric readers (e.g. Prometheus) are experimental and not yet supported "
402-
"by declarative config. Use the SDK API directly to configure pull readers."
403-
)
501+
return _create_pull_metric_reader(config.pull)
404502
raise ConfigurationError(
405503
"No reader type specified in metric reader config. "
406-
"Supported types: periodic."
504+
"Supported types: periodic, pull."
407505
)
408506

409507

opentelemetry-sdk/tests/_configuration/test_meter_provider.py

Lines changed: 214 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@
2626
from opentelemetry.sdk._configuration.models import (
2727
ExemplarFilter as ExemplarFilterConfig,
2828
)
29+
from opentelemetry.sdk._configuration.models import (
30+
ExperimentalPrometheusMetricExporter as PrometheusMetricExporterConfig,
31+
)
2932
from opentelemetry.sdk._configuration.models import (
3033
ExplicitBucketHistogramAggregation as ExplicitBucketConfig,
3134
)
@@ -52,6 +55,12 @@
5255
from opentelemetry.sdk._configuration.models import (
5356
PeriodicMetricReader as PeriodicMetricReaderConfig,
5457
)
58+
from opentelemetry.sdk._configuration.models import (
59+
PullMetricExporter as PullMetricExporterConfig,
60+
)
61+
from opentelemetry.sdk._configuration.models import (
62+
PullMetricReader as PullMetricReaderConfig,
63+
)
5564
from opentelemetry.sdk._configuration.models import (
5665
PushMetricExporter as PushMetricExporterConfig,
5766
)
@@ -306,13 +315,216 @@ def test_otlp_grpc_missing_package_raises(self):
306315
create_meter_provider(config)
307316
self.assertIn("otlp-proto-grpc", str(ctx.exception))
308317

309-
def test_pull_reader_raises(self):
318+
319+
class TestCreatePullMetricReaders(unittest.TestCase):
320+
def test_pull_prometheus_creates_reader(self):
321+
mock_reader_cls = MagicMock()
322+
mock_start_server = MagicMock()
323+
mock_module = MagicMock()
324+
mock_module.PrometheusMetricReader = mock_reader_cls
325+
mock_module.start_http_server = mock_start_server
326+
327+
with patch.dict(
328+
sys.modules,
329+
{"opentelemetry.exporter.prometheus": mock_module},
330+
):
331+
config = MeterProviderConfig(
332+
readers=[
333+
MetricReaderConfig(
334+
pull=PullMetricReaderConfig(
335+
exporter=PullMetricExporterConfig(
336+
prometheus_development=PrometheusMetricExporterConfig(
337+
host="0.0.0.0",
338+
port=9090,
339+
without_target_info_development=True,
340+
)
341+
)
342+
)
343+
)
344+
]
345+
)
346+
provider = create_meter_provider(config)
347+
348+
mock_reader_cls.assert_called_once_with(disable_target_info=True)
349+
mock_start_server.assert_called_once_with(port=9090, addr="0.0.0.0")
350+
self.assertEqual(len(provider._sdk_config.metric_readers), 1)
351+
352+
def test_pull_prometheus_defaults(self):
353+
mock_reader_cls = MagicMock()
354+
mock_start_server = MagicMock()
355+
mock_module = MagicMock()
356+
mock_module.PrometheusMetricReader = mock_reader_cls
357+
mock_module.start_http_server = mock_start_server
358+
359+
with patch.dict(
360+
sys.modules,
361+
{"opentelemetry.exporter.prometheus": mock_module},
362+
):
363+
config = MeterProviderConfig(
364+
readers=[
365+
MetricReaderConfig(
366+
pull=PullMetricReaderConfig(
367+
exporter=PullMetricExporterConfig(
368+
prometheus_development=PrometheusMetricExporterConfig()
369+
)
370+
)
371+
)
372+
]
373+
)
374+
provider = create_meter_provider(config)
375+
376+
mock_reader_cls.assert_called_once_with(disable_target_info=False)
377+
mock_start_server.assert_called_once_with(port=9464, addr="localhost")
378+
self.assertEqual(len(provider._sdk_config.metric_readers), 1)
379+
380+
def test_pull_prometheus_missing_package_raises(self):
381+
with patch.dict(
382+
sys.modules,
383+
{"opentelemetry.exporter.prometheus": None},
384+
):
385+
config = MeterProviderConfig(
386+
readers=[
387+
MetricReaderConfig(
388+
pull=PullMetricReaderConfig(
389+
exporter=PullMetricExporterConfig(
390+
prometheus_development=PrometheusMetricExporterConfig()
391+
)
392+
)
393+
)
394+
]
395+
)
396+
with self.assertRaises(ConfigurationError):
397+
create_meter_provider(config)
398+
399+
def test_pull_no_exporter_raises(self):
310400
config = MeterProviderConfig(
311-
readers=[MetricReaderConfig(pull=MagicMock())]
401+
readers=[
402+
MetricReaderConfig(
403+
pull=PullMetricReaderConfig(
404+
exporter=PullMetricExporterConfig()
405+
)
406+
)
407+
]
312408
)
313409
with self.assertRaises(ConfigurationError):
314410
create_meter_provider(config)
315411

412+
def test_pull_plugin_loads_via_entry_point(self):
413+
mock_reader = MagicMock()
414+
mock_class = MagicMock(return_value=mock_reader)
415+
mock_entry_points = MagicMock(
416+
return_value=[MagicMock(**{"load.return_value": mock_class})]
417+
)
418+
with patch(
419+
"opentelemetry.sdk._configuration._common.entry_points",
420+
mock_entry_points,
421+
):
422+
config = MeterProviderConfig(
423+
readers=[
424+
MetricReaderConfig(
425+
pull=PullMetricReaderConfig(
426+
# pylint: disable=unexpected-keyword-arg
427+
exporter=PullMetricExporterConfig(
428+
my_custom_reader={"port": 8080}
429+
)
430+
)
431+
)
432+
]
433+
)
434+
provider = create_meter_provider(config)
435+
self.assertEqual(len(provider._sdk_config.metric_readers), 1)
436+
mock_class.assert_called_once_with(port=8080)
437+
mock_entry_points.assert_called_once_with(
438+
group="opentelemetry_pull_metric_exporter",
439+
name="my_custom_reader",
440+
)
441+
442+
def test_pull_plugin_not_found_raises(self):
443+
with patch(
444+
"opentelemetry.sdk._configuration._common.entry_points",
445+
return_value=[],
446+
):
447+
config = MeterProviderConfig(
448+
readers=[
449+
MetricReaderConfig(
450+
pull=PullMetricReaderConfig(
451+
# pylint: disable=unexpected-keyword-arg
452+
exporter=PullMetricExporterConfig(
453+
no_such_reader={}
454+
)
455+
)
456+
)
457+
]
458+
)
459+
with self.assertRaises(ConfigurationError):
460+
create_meter_provider(config)
461+
462+
def test_pull_producers_warns(self):
463+
mock_module = MagicMock()
464+
465+
with patch.dict(
466+
sys.modules,
467+
{"opentelemetry.exporter.prometheus": mock_module},
468+
):
469+
config = MeterProviderConfig(
470+
readers=[
471+
MetricReaderConfig(
472+
pull=PullMetricReaderConfig(
473+
exporter=PullMetricExporterConfig(
474+
prometheus_development=PrometheusMetricExporterConfig()
475+
),
476+
producers=[MagicMock()],
477+
)
478+
)
479+
]
480+
)
481+
with self.assertLogs(
482+
"opentelemetry.sdk._configuration._meter_provider",
483+
level="WARNING",
484+
) as cm:
485+
create_meter_provider(config)
486+
self.assertTrue(any("MetricProducer" in msg for msg in cm.output))
487+
488+
def test_pull_cardinality_limits_warns(self):
489+
mock_module = MagicMock()
490+
491+
with patch.dict(
492+
sys.modules,
493+
{"opentelemetry.exporter.prometheus": mock_module},
494+
):
495+
config = MeterProviderConfig(
496+
readers=[
497+
MetricReaderConfig(
498+
pull=PullMetricReaderConfig(
499+
exporter=PullMetricExporterConfig(
500+
prometheus_development=PrometheusMetricExporterConfig()
501+
),
502+
cardinality_limits=MagicMock(),
503+
)
504+
)
505+
]
506+
)
507+
with self.assertLogs(
508+
"opentelemetry.sdk._configuration._meter_provider",
509+
level="WARNING",
510+
) as cm:
511+
create_meter_provider(config)
512+
self.assertTrue(any("cardinality_limits" in msg for msg in cm.output))
513+
514+
515+
class TestCreateMetricReadersGeneral(unittest.TestCase):
516+
@staticmethod
517+
def _make_periodic_config(exporter_config):
518+
return MeterProviderConfig(
519+
readers=[
520+
MetricReaderConfig(
521+
periodic=PeriodicMetricReaderConfig(
522+
exporter=exporter_config
523+
)
524+
)
525+
]
526+
)
527+
316528
def test_no_reader_type_raises(self):
317529
config = MeterProviderConfig(readers=[MetricReaderConfig()])
318530
with self.assertRaises(ConfigurationError):

0 commit comments

Comments
 (0)