Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 @@ -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,
Expand All @@ -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,
)
Expand Down Expand Up @@ -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(
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
216 changes: 214 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 @@ -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)
Comment thread
MikeGoldsmith marked this conversation as resolved.
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):
Expand Down
Loading