diff --git a/.changelog/5118.added b/.changelog/5118.added new file mode 100644 index 00000000000..24d908d3291 --- /dev/null +++ b/.changelog/5118.added @@ -0,0 +1 @@ +`opentelemetry-exporter-prometheus`: add support for specifying metric name translation strategy diff --git a/exporter/opentelemetry-exporter-prometheus/src/opentelemetry/exporter/prometheus/__init__.py b/exporter/opentelemetry-exporter-prometheus/src/opentelemetry/exporter/prometheus/__init__.py index 9b3d2efd34a..dc9b6b5c690 100644 --- a/exporter/opentelemetry-exporter-prometheus/src/opentelemetry/exporter/prometheus/__init__.py +++ b/exporter/opentelemetry-exporter-prometheus/src/opentelemetry/exporter/prometheus/__init__.py @@ -53,6 +53,7 @@ from collections import deque from collections.abc import Iterable, Sequence +from enum import Enum from itertools import chain from json import dumps from logging import getLogger @@ -65,6 +66,7 @@ GaugeMetricFamily, HistogramMetricFamily, InfoMetricFamily, + UnknownMetricFamily, ) from prometheus_client.core import Metric as PrometheusMetric @@ -105,6 +107,17 @@ _TARGET_INFO_DESCRIPTION = "Target metadata" +class TranslationStrategy(Enum): + """Controls how OpenTelemetry metric names are translated to Prometheus conventions.""" + + UNDERSCORE_ESCAPING_WITH_SUFFIXES = "underscore_escaping_with_suffixes" + UNDERSCORE_ESCAPING_WITHOUT_SUFFIXES = ( + "underscore_escaping_without_suffixes" + ) + NO_UTF8_ESCAPING_WITH_SUFFIXES = "no_utf8_escaping_with_suffixes" + NO_TRANSLATION = "no_translation" + + def _convert_buckets( bucket_counts: Sequence[int], explicit_bounds: Sequence[float] ) -> Sequence[tuple[str, int]]: @@ -127,6 +140,7 @@ def __init__( self, disable_target_info: bool = False, prefix: str = "", + translation_strategy: TranslationStrategy = TranslationStrategy.UNDERSCORE_ESCAPING_WITH_SUFFIXES, *, registry: CollectorRegistry = REGISTRY, ) -> None: @@ -142,7 +156,9 @@ def __init__( otel_component_type=OtelComponentTypeValues.PROMETHEUS_HTTP_TEXT_METRIC_EXPORTER, ) self._collector = _CustomCollector( - disable_target_info=disable_target_info, prefix=prefix + disable_target_info=disable_target_info, + prefix=prefix, + translation_strategy=translation_strategy, ) self._registry = registry self._registry.register(self._collector) @@ -170,12 +186,18 @@ class _CustomCollector: https://github.com/prometheus/client_python#custom-collectors """ - def __init__(self, disable_target_info: bool = False, prefix: str = ""): + def __init__( + self, + disable_target_info: bool = False, + prefix: str = "", + translation_strategy: TranslationStrategy = TranslationStrategy.UNDERSCORE_ESCAPING_WITH_SUFFIXES, + ): self._callback = None self._metrics_datas: deque[MetricsData] = deque() self._disable_target_info = disable_target_info self._target_info = None self._prefix = prefix + self._translation_strategy = translation_strategy def add_metrics_data(self, metrics_data: MetricsData) -> None: """Add metrics to Prometheus data""" @@ -214,7 +236,7 @@ def collect(self) -> Iterable[PrometheusMetric]: if metric_family_id_metric_family: yield from metric_family_id_metric_family.values() - # pylint: disable=too-many-locals,too-many-branches + # pylint: disable=too-many-locals,too-many-branches,too-many-statements def _translate_to_prometheus( self, metrics_data: MetricsData, @@ -227,16 +249,30 @@ def _translate_to_prometheus( for metric in scope_metrics.metrics: metrics.append(metric) + _add_suffixes = self._translation_strategy in ( + TranslationStrategy.UNDERSCORE_ESCAPING_WITH_SUFFIXES, + TranslationStrategy.NO_UTF8_ESCAPING_WITH_SUFFIXES, + ) + _escape_names = self._translation_strategy in ( + TranslationStrategy.UNDERSCORE_ESCAPING_WITH_SUFFIXES, + TranslationStrategy.UNDERSCORE_ESCAPING_WITHOUT_SUFFIXES, + ) + for metric in metrics: label_values_data_points = [] values = [] metric_name = metric.name - if self._prefix: + if ( + self._translation_strategy + != TranslationStrategy.NO_TRANSLATION + and self._prefix + ): metric_name = self._prefix + "_" + metric_name - metric_name = sanitize_full_name(metric_name) + if _escape_names: + metric_name = sanitize_full_name(metric_name) metric_description = metric.description or "" - metric_unit = map_unit(metric.unit) + metric_unit = map_unit(metric.unit) if _add_suffixes else "" # First pass: collect all unique label keys across all data points all_label_keys_set = set() @@ -300,17 +336,25 @@ def _translate_to_prometheus( isinstance(metric.data, Sum) and not should_convert_sum_to_gauge ): + family_kwargs = {} + if _add_suffixes: + family_class = CounterMetricFamily + family_kwargs["unit"] = metric_unit + else: + # The CounterMetricFamily always adds the "_total" suffix to + # metric names. To avoid adding this suffix for Sums, we must + # use the untyped (unknown) metric family. + family_class = UnknownMetricFamily metric_family_id = "|".join( - [per_metric_family_id, CounterMetricFamily.__name__] + [per_metric_family_id, family_class.__name__] ) - if metric_family_id not in metric_family_id_metric_family: metric_family_id_metric_family[metric_family_id] = ( - CounterMetricFamily( + family_class( name=metric_name, documentation=metric_description, labels=all_label_keys, - unit=metric_unit, + **family_kwargs, ) ) for label_values, value in zip( diff --git a/exporter/opentelemetry-exporter-prometheus/test-requirements.txt b/exporter/opentelemetry-exporter-prometheus/test-requirements.txt index b98ce327253..58c4545527a 100644 --- a/exporter/opentelemetry-exporter-prometheus/test-requirements.txt +++ b/exporter/opentelemetry-exporter-prometheus/test-requirements.txt @@ -2,7 +2,7 @@ asgiref==3.7.2 iniconfig==2.0.0 packaging==24.0 pluggy==1.6.0 -prometheus_client==0.20.0 +prometheus_client==0.25.0 py-cpuinfo==9.0.0 pytest==7.4.4 tomli==2.0.1 diff --git a/exporter/opentelemetry-exporter-prometheus/tests/test_prometheus_exporter.py b/exporter/opentelemetry-exporter-prometheus/tests/test_prometheus_exporter.py index d3d49ba467d..71c5ee445e0 100644 --- a/exporter/opentelemetry-exporter-prometheus/tests/test_prometheus_exporter.py +++ b/exporter/opentelemetry-exporter-prometheus/tests/test_prometheus_exporter.py @@ -10,10 +10,12 @@ CounterMetricFamily, GaugeMetricFamily, InfoMetricFamily, + UnknownMetricFamily, ) from opentelemetry.exporter.prometheus import ( PrometheusMetricReader, + TranslationStrategy, _CustomCollector, ) from opentelemetry.metrics import NoOpMeterProvider @@ -36,6 +38,35 @@ ) +def _collect_metric( + metric: Metric, + translation_strategy: TranslationStrategy = TranslationStrategy.UNDERSCORE_ESCAPING_WITH_SUFFIXES, + prefix: str = "", +) -> list: + metrics_data = MetricsData( + resource_metrics=[ + ResourceMetrics( + resource=Mock(), + scope_metrics=[ + ScopeMetrics( + scope=Mock(), + metrics=[metric], + schema_url="schema_url", + ) + ], + schema_url="schema_url", + ) + ] + ) + collector = _CustomCollector( + disable_target_info=True, + prefix=prefix, + translation_strategy=translation_strategy, + ) + collector.add_metrics_data(metrics_data) + return list(collector.collect()) + + class TestPrometheusMetricReader(TestCase): # pylint: disable=too-many-public-methods def setUp(self): self._mock_registry_register = Mock() @@ -721,3 +752,82 @@ def test_multiple_data_points_with_different_label_sets(self): """ ), ) + + def test_translation_strategy(self): + cases = [ + ( + TranslationStrategy.UNDERSCORE_ESCAPING_WITH_SUFFIXES, + CounterMetricFamily, + "test_counter_seconds", + GaugeMetricFamily, + "test_gauge_seconds", + ), + ( + TranslationStrategy.UNDERSCORE_ESCAPING_WITHOUT_SUFFIXES, + UnknownMetricFamily, + "test_counter", + GaugeMetricFamily, + "test_gauge", + ), + ( + TranslationStrategy.NO_UTF8_ESCAPING_WITH_SUFFIXES, + CounterMetricFamily, + "test.counter_seconds", + GaugeMetricFamily, + "test.gauge_seconds", + ), + ( + TranslationStrategy.NO_TRANSLATION, + UnknownMetricFamily, + "test.counter", + GaugeMetricFamily, + "test.gauge", + ), + ] + for ( + strategy, + counter_cls, + counter_name, + gauge_cls, + gauge_name, + ) in cases: + with self.subTest(strategy=strategy): + counter_result = _collect_metric( + _generate_sum("test.counter", 1, unit="s"), strategy + ) + self.assertEqual(type(counter_result[0]), counter_cls) + self.assertEqual(counter_result[0].name, counter_name) + + gauge_result = _collect_metric( + _generate_gauge("test.gauge", 1, unit="s"), strategy + ) + self.assertEqual(type(gauge_result[0]), gauge_cls) + self.assertEqual(gauge_result[0].name, gauge_name) + + def test_translation_strategy_prefix(self): + cases = [ + ( + TranslationStrategy.UNDERSCORE_ESCAPING_WITH_SUFFIXES, + "myprefix_test_counter", + ), + ( + TranslationStrategy.UNDERSCORE_ESCAPING_WITHOUT_SUFFIXES, + "myprefix_test_counter", + ), + ( + TranslationStrategy.NO_UTF8_ESCAPING_WITH_SUFFIXES, + "myprefix_test.counter", + ), + ( + TranslationStrategy.NO_TRANSLATION, + "test.counter", # prefix is not applied + ), + ] + for strategy, expected_name in cases: + with self.subTest(strategy=strategy): + result = _collect_metric( + _generate_sum("test.counter", 1, unit=""), + strategy, + prefix="myprefix", + ) + self.assertEqual(result[0].name, expected_name)