Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changelog/5216.added
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
`opentelemetry-sdk`: add pull metric reader support to declarative file configuration, including Prometheus metric reader via the `prometheus_development` config field
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
)
Expand Down Expand Up @@ -382,18 +391,105 @@ def _create_periodic_metric_reader(
)


def _create_prometheus_metric_reader(config) -> MetricReader:
Comment thread
MikeGoldsmith marked this conversation as resolved.
Outdated
"""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(
Comment thread
MikeGoldsmith marked this conversation as resolved.
"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."
)


Expand Down
193 changes: 191 additions & 2 deletions opentelemetry-sdk/tests/_configuration/test_meter_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand All @@ -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,
)
Expand Down Expand Up @@ -278,13 +287,193 @@ 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,
)
)
)
)
]
)
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)
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)
Comment thread
MikeGoldsmith marked this conversation as resolved.

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):
Expand Down
Loading