Skip to content

Commit 384d1d6

Browse files
committed
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
1 parent 369644c commit 384d1d6

3 files changed

Lines changed: 292 additions & 8 deletions

File tree

.changelog/XXXX.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: 102 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@
66
import logging
77

88
from opentelemetry import metrics
9-
from opentelemetry.sdk._configuration._common import _parse_headers
9+
from opentelemetry.sdk._configuration._common import (
10+
_parse_headers,
11+
load_entry_point,
12+
)
1013
from opentelemetry.sdk._configuration._exceptions import ConfigurationError
1114
from opentelemetry.sdk._configuration.models import (
1215
Aggregation as AggregationConfig,
@@ -37,6 +40,12 @@
3740
from opentelemetry.sdk._configuration.models import (
3841
PeriodicMetricReader as PeriodicMetricReaderConfig,
3942
)
43+
from opentelemetry.sdk._configuration.models import (
44+
PullMetricExporter as PullMetricExporterConfig,
45+
)
46+
from opentelemetry.sdk._configuration.models import (
47+
PullMetricReader as PullMetricReaderConfig,
48+
)
4049
from opentelemetry.sdk._configuration.models import (
4150
PushMetricExporter as PushMetricExporterConfig,
4251
)
@@ -382,18 +391,105 @@ def _create_periodic_metric_reader(
382391
)
383392

384393

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

399495

opentelemetry-sdk/tests/_configuration/test_meter_provider.py

Lines changed: 189 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
)
@@ -278,13 +287,191 @@ def test_otlp_grpc_missing_package_raises(self):
278287
create_meter_provider(config)
279288
self.assertIn("otlp-proto-grpc", str(ctx.exception))
280289

281-
def test_pull_reader_raises(self):
290+
def test_pull_prometheus_creates_reader(self):
291+
mock_reader_cls = MagicMock()
292+
mock_start_server = MagicMock()
293+
mock_module = MagicMock()
294+
mock_module.PrometheusMetricReader = mock_reader_cls
295+
mock_module.start_http_server = mock_start_server
296+
297+
with patch.dict(
298+
sys.modules,
299+
{"opentelemetry.exporter.prometheus": mock_module},
300+
):
301+
config = MeterProviderConfig(
302+
readers=[
303+
MetricReaderConfig(
304+
pull=PullMetricReaderConfig(
305+
exporter=PullMetricExporterConfig(
306+
prometheus_development=PrometheusMetricExporterConfig(
307+
host="0.0.0.0",
308+
port=9090,
309+
without_target_info_development=True,
310+
)
311+
)
312+
)
313+
)
314+
]
315+
)
316+
create_meter_provider(config)
317+
318+
mock_reader_cls.assert_called_once_with(disable_target_info=True)
319+
mock_start_server.assert_called_once_with(port=9090, addr="0.0.0.0")
320+
321+
def test_pull_prometheus_defaults(self):
322+
mock_reader_cls = MagicMock()
323+
mock_start_server = MagicMock()
324+
mock_module = MagicMock()
325+
mock_module.PrometheusMetricReader = mock_reader_cls
326+
mock_module.start_http_server = mock_start_server
327+
328+
with patch.dict(
329+
sys.modules,
330+
{"opentelemetry.exporter.prometheus": mock_module},
331+
):
332+
config = MeterProviderConfig(
333+
readers=[
334+
MetricReaderConfig(
335+
pull=PullMetricReaderConfig(
336+
exporter=PullMetricExporterConfig(
337+
prometheus_development=PrometheusMetricExporterConfig()
338+
)
339+
)
340+
)
341+
]
342+
)
343+
create_meter_provider(config)
344+
345+
mock_reader_cls.assert_called_once_with(disable_target_info=False)
346+
mock_start_server.assert_called_once_with(port=9464, addr="localhost")
347+
348+
def test_pull_prometheus_missing_package_raises(self):
349+
with patch.dict(
350+
sys.modules,
351+
{"opentelemetry.exporter.prometheus": None},
352+
):
353+
config = MeterProviderConfig(
354+
readers=[
355+
MetricReaderConfig(
356+
pull=PullMetricReaderConfig(
357+
exporter=PullMetricExporterConfig(
358+
prometheus_development=PrometheusMetricExporterConfig()
359+
)
360+
)
361+
)
362+
]
363+
)
364+
with self.assertRaises(ConfigurationError):
365+
create_meter_provider(config)
366+
367+
def test_pull_no_exporter_raises(self):
282368
config = MeterProviderConfig(
283-
readers=[MetricReaderConfig(pull=MagicMock())]
369+
readers=[
370+
MetricReaderConfig(
371+
pull=PullMetricReaderConfig(
372+
exporter=PullMetricExporterConfig()
373+
)
374+
)
375+
]
284376
)
285377
with self.assertRaises(ConfigurationError):
286378
create_meter_provider(config)
287379

380+
def test_pull_plugin_loads_via_entry_point(self):
381+
mock_reader = MagicMock()
382+
mock_class = MagicMock(return_value=mock_reader)
383+
with patch(
384+
"opentelemetry.sdk._configuration._common.entry_points",
385+
return_value=[MagicMock(**{"load.return_value": mock_class})],
386+
):
387+
config = MeterProviderConfig(
388+
readers=[
389+
MetricReaderConfig(
390+
pull=PullMetricReaderConfig(
391+
# pylint: disable=unexpected-keyword-arg
392+
exporter=PullMetricExporterConfig(
393+
my_custom_reader={"port": 8080}
394+
)
395+
)
396+
)
397+
]
398+
)
399+
provider = create_meter_provider(config)
400+
self.assertEqual(len(provider._sdk_config.metric_readers), 1)
401+
mock_class.assert_called_once_with(port=8080)
402+
403+
def test_pull_plugin_not_found_raises(self):
404+
with patch(
405+
"opentelemetry.sdk._configuration._common.entry_points",
406+
return_value=[],
407+
):
408+
config = MeterProviderConfig(
409+
readers=[
410+
MetricReaderConfig(
411+
pull=PullMetricReaderConfig(
412+
# pylint: disable=unexpected-keyword-arg
413+
exporter=PullMetricExporterConfig(
414+
no_such_reader={}
415+
)
416+
)
417+
)
418+
]
419+
)
420+
with self.assertRaises(ConfigurationError):
421+
create_meter_provider(config)
422+
423+
def test_pull_producers_warns(self):
424+
mock_module = MagicMock()
425+
426+
with patch.dict(
427+
sys.modules,
428+
{"opentelemetry.exporter.prometheus": mock_module},
429+
):
430+
config = MeterProviderConfig(
431+
readers=[
432+
MetricReaderConfig(
433+
pull=PullMetricReaderConfig(
434+
exporter=PullMetricExporterConfig(
435+
prometheus_development=PrometheusMetricExporterConfig()
436+
),
437+
producers=[MagicMock()],
438+
)
439+
)
440+
]
441+
)
442+
with self.assertLogs(
443+
"opentelemetry.sdk._configuration._meter_provider",
444+
level="WARNING",
445+
) as cm:
446+
create_meter_provider(config)
447+
self.assertTrue(any("MetricProducer" in msg for msg in cm.output))
448+
449+
def test_pull_cardinality_limits_warns(self):
450+
mock_module = MagicMock()
451+
452+
with patch.dict(
453+
sys.modules,
454+
{"opentelemetry.exporter.prometheus": mock_module},
455+
):
456+
config = MeterProviderConfig(
457+
readers=[
458+
MetricReaderConfig(
459+
pull=PullMetricReaderConfig(
460+
exporter=PullMetricExporterConfig(
461+
prometheus_development=PrometheusMetricExporterConfig()
462+
),
463+
cardinality_limits=MagicMock(),
464+
)
465+
)
466+
]
467+
)
468+
with self.assertLogs(
469+
"opentelemetry.sdk._configuration._meter_provider",
470+
level="WARNING",
471+
) as cm:
472+
create_meter_provider(config)
473+
self.assertTrue(any("cardinality_limits" in msg for msg in cm.output))
474+
288475
def test_no_reader_type_raises(self):
289476
config = MeterProviderConfig(readers=[MetricReaderConfig()])
290477
with self.assertRaises(ConfigurationError):

0 commit comments

Comments
 (0)