Skip to content

Commit 5e87e76

Browse files
committed
feat: add metric name translation strategy support for the Prometheus exporter
1 parent 15e9664 commit 5e87e76

3 files changed

Lines changed: 169 additions & 12 deletions

File tree

exporter/opentelemetry-exporter-prometheus/src/opentelemetry/exporter/prometheus/__init__.py

Lines changed: 57 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363
"""
6464

6565
from collections import deque
66+
from enum import Enum
6667
from itertools import chain
6768
from json import dumps
6869
from logging import getLogger
@@ -76,6 +77,7 @@
7677
GaugeMetricFamily,
7778
HistogramMetricFamily,
7879
InfoMetricFamily,
80+
UnknownMetricFamily,
7981
)
8082
from prometheus_client.core import Metric as PrometheusMetric
8183

@@ -116,6 +118,17 @@
116118
_TARGET_INFO_DESCRIPTION = "Target metadata"
117119

118120

121+
class TranslationStrategy(Enum):
122+
"""Controls how OpenTelemetry metric names are translated to Prometheus conventions."""
123+
124+
UNDERSCORE_ESCAPING_WITH_SUFFIXES = "underscore_escaping_with_suffixes"
125+
UNDERSCORE_ESCAPING_WITHOUT_SUFFIXES = (
126+
"underscore_escaping_without_suffixes"
127+
)
128+
NO_UTF8_ESCAPING_WITH_SUFFIXES = "no_utf8_escaping_with_suffixes"
129+
NO_TRANSLATION = "no_translation"
130+
131+
119132
def _convert_buckets(
120133
bucket_counts: Sequence[int], explicit_bounds: Sequence[float]
121134
) -> Sequence[Tuple[str, int]]:
@@ -135,7 +148,10 @@ class PrometheusMetricReader(MetricReader):
135148
"""Prometheus metric exporter for OpenTelemetry."""
136149

137150
def __init__(
138-
self, disable_target_info: bool = False, prefix: str = ""
151+
self,
152+
disable_target_info: bool = False,
153+
prefix: str = "",
154+
translation_strategy: TranslationStrategy = TranslationStrategy.UNDERSCORE_ESCAPING_WITH_SUFFIXES,
139155
) -> None:
140156
super().__init__(
141157
preferred_temporality={
@@ -149,7 +165,9 @@ def __init__(
149165
otel_component_type=OtelComponentTypeValues.PROMETHEUS_HTTP_TEXT_METRIC_EXPORTER,
150166
)
151167
self._collector = _CustomCollector(
152-
disable_target_info=disable_target_info, prefix=prefix
168+
disable_target_info=disable_target_info,
169+
prefix=prefix,
170+
translation_strategy=translation_strategy,
153171
)
154172
REGISTRY.register(self._collector)
155173
self._collector._callback = self.collect
@@ -176,12 +194,18 @@ class _CustomCollector:
176194
https://github.com/prometheus/client_python#custom-collectors
177195
"""
178196

179-
def __init__(self, disable_target_info: bool = False, prefix: str = ""):
197+
def __init__(
198+
self,
199+
disable_target_info: bool = False,
200+
prefix: str = "",
201+
translation_strategy: TranslationStrategy = TranslationStrategy.UNDERSCORE_ESCAPING_WITH_SUFFIXES,
202+
):
180203
self._callback = None
181204
self._metrics_datas: Deque[MetricsData] = deque()
182205
self._disable_target_info = disable_target_info
183206
self._target_info = None
184207
self._prefix = prefix
208+
self._translation_strategy = translation_strategy
185209

186210
def add_metrics_data(self, metrics_data: MetricsData) -> None:
187211
"""Add metrics to Prometheus data"""
@@ -220,7 +244,7 @@ def collect(self) -> Iterable[PrometheusMetric]:
220244
if metric_family_id_metric_family:
221245
yield from metric_family_id_metric_family.values()
222246

223-
# pylint: disable=too-many-locals,too-many-branches
247+
# pylint: disable=too-many-locals,too-many-branches,too-many-statements
224248
def _translate_to_prometheus(
225249
self,
226250
metrics_data: MetricsData,
@@ -233,16 +257,30 @@ def _translate_to_prometheus(
233257
for metric in scope_metrics.metrics:
234258
metrics.append(metric)
235259

260+
_add_suffixes = self._translation_strategy in (
261+
TranslationStrategy.UNDERSCORE_ESCAPING_WITH_SUFFIXES,
262+
TranslationStrategy.NO_UTF8_ESCAPING_WITH_SUFFIXES,
263+
)
264+
_escape_names = self._translation_strategy in (
265+
TranslationStrategy.UNDERSCORE_ESCAPING_WITH_SUFFIXES,
266+
TranslationStrategy.UNDERSCORE_ESCAPING_WITHOUT_SUFFIXES,
267+
)
268+
236269
for metric in metrics:
237270
label_values_data_points = []
238271
values = []
239272

240273
metric_name = metric.name
241-
if self._prefix:
274+
if (
275+
self._translation_strategy
276+
!= TranslationStrategy.NO_TRANSLATION
277+
and self._prefix
278+
):
242279
metric_name = self._prefix + "_" + metric_name
243-
metric_name = sanitize_full_name(metric_name)
280+
if _escape_names:
281+
metric_name = sanitize_full_name(metric_name)
244282
metric_description = metric.description or ""
245-
metric_unit = map_unit(metric.unit)
283+
metric_unit = map_unit(metric.unit) if _add_suffixes else ""
246284

247285
# First pass: collect all unique label keys across all data points
248286
all_label_keys_set = set()
@@ -306,17 +344,25 @@ def _translate_to_prometheus(
306344
isinstance(metric.data, Sum)
307345
and not should_convert_sum_to_gauge
308346
):
347+
family_kwargs = {}
348+
if _add_suffixes:
349+
family_class = CounterMetricFamily
350+
family_kwargs["unit"] = metric_unit
351+
else:
352+
# The CounterMetricFamily always adds the "_total" suffix to
353+
# metric names. To avoid adding this suffix for Sums, we must
354+
# use the untyped (unknown) metric family.
355+
family_class = UnknownMetricFamily
309356
metric_family_id = "|".join(
310-
[per_metric_family_id, CounterMetricFamily.__name__]
357+
[per_metric_family_id, family_class.__name__]
311358
)
312-
313359
if metric_family_id not in metric_family_id_metric_family:
314360
metric_family_id_metric_family[metric_family_id] = (
315-
CounterMetricFamily(
361+
family_class(
316362
name=metric_name,
317363
documentation=metric_description,
318364
labels=all_label_keys,
319-
unit=metric_unit,
365+
**family_kwargs,
320366
)
321367
)
322368
for label_values, value in zip(

exporter/opentelemetry-exporter-prometheus/test-requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ importlib-metadata==6.11.0
33
iniconfig==2.0.0
44
packaging==24.0
55
pluggy==1.6.0
6-
prometheus_client==0.20.0
6+
prometheus_client==0.25.0
77
py-cpuinfo==9.0.0
88
pytest==7.4.4
99
tomli==2.0.1

exporter/opentelemetry-exporter-prometheus/tests/test_prometheus_exporter.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,12 @@
2121
CounterMetricFamily,
2222
GaugeMetricFamily,
2323
InfoMetricFamily,
24+
UnknownMetricFamily,
2425
)
2526

2627
from opentelemetry.exporter.prometheus import (
2728
PrometheusMetricReader,
29+
TranslationStrategy,
2830
_CustomCollector,
2931
)
3032
from opentelemetry.metrics import NoOpMeterProvider
@@ -47,6 +49,36 @@
4749
)
4850

4951

52+
def _collect_metric(
53+
metric: Metric,
54+
translation_strategy: TranslationStrategy = TranslationStrategy.UNDERSCORE_ESCAPING_WITH_SUFFIXES,
55+
prefix: str = "",
56+
) -> list:
57+
metrics_data = MetricsData(
58+
resource_metrics=[
59+
ResourceMetrics(
60+
resource=Mock(),
61+
scope_metrics=[
62+
ScopeMetrics(
63+
scope=Mock(),
64+
metrics=[metric],
65+
schema_url="schema_url",
66+
)
67+
],
68+
schema_url="schema_url",
69+
)
70+
]
71+
)
72+
collector = _CustomCollector(
73+
disable_target_info=True,
74+
prefix=prefix,
75+
translation_strategy=translation_strategy,
76+
)
77+
collector.add_metrics_data(metrics_data)
78+
return list(collector.collect())
79+
80+
81+
# pylint: disable=too-many-public-methods
5082
class TestPrometheusMetricReader(TestCase):
5183
def setUp(self):
5284
self._mock_registry_register = Mock()
@@ -719,3 +751,82 @@ def test_multiple_data_points_with_different_label_sets(self):
719751
"""
720752
),
721753
)
754+
755+
def test_translation_strategy(self):
756+
cases = [
757+
(
758+
TranslationStrategy.UNDERSCORE_ESCAPING_WITH_SUFFIXES,
759+
CounterMetricFamily,
760+
"test_counter_seconds",
761+
GaugeMetricFamily,
762+
"test_gauge_seconds",
763+
),
764+
(
765+
TranslationStrategy.UNDERSCORE_ESCAPING_WITHOUT_SUFFIXES,
766+
UnknownMetricFamily,
767+
"test_counter",
768+
GaugeMetricFamily,
769+
"test_gauge",
770+
),
771+
(
772+
TranslationStrategy.NO_UTF8_ESCAPING_WITH_SUFFIXES,
773+
CounterMetricFamily,
774+
"test.counter_seconds",
775+
GaugeMetricFamily,
776+
"test.gauge_seconds",
777+
),
778+
(
779+
TranslationStrategy.NO_TRANSLATION,
780+
UnknownMetricFamily,
781+
"test.counter",
782+
GaugeMetricFamily,
783+
"test.gauge",
784+
),
785+
]
786+
for (
787+
strategy,
788+
counter_cls,
789+
counter_name,
790+
gauge_cls,
791+
gauge_name,
792+
) in cases:
793+
with self.subTest(strategy=strategy):
794+
counter_result = _collect_metric(
795+
_generate_sum("test.counter", 1, unit="s"), strategy
796+
)
797+
self.assertEqual(type(counter_result[0]), counter_cls)
798+
self.assertEqual(counter_result[0].name, counter_name)
799+
800+
gauge_result = _collect_metric(
801+
_generate_gauge("test.gauge", 1, unit="s"), strategy
802+
)
803+
self.assertEqual(type(gauge_result[0]), gauge_cls)
804+
self.assertEqual(gauge_result[0].name, gauge_name)
805+
806+
def test_translation_strategy_prefix(self):
807+
cases = [
808+
(
809+
TranslationStrategy.UNDERSCORE_ESCAPING_WITH_SUFFIXES,
810+
"myprefix_test_counter",
811+
),
812+
(
813+
TranslationStrategy.UNDERSCORE_ESCAPING_WITHOUT_SUFFIXES,
814+
"myprefix_test_counter",
815+
),
816+
(
817+
TranslationStrategy.NO_UTF8_ESCAPING_WITH_SUFFIXES,
818+
"myprefix_test.counter",
819+
),
820+
(
821+
TranslationStrategy.NO_TRANSLATION,
822+
"test.counter", # prefix is not applied
823+
),
824+
]
825+
for strategy, expected_name in cases:
826+
with self.subTest(strategy=strategy):
827+
result = _collect_metric(
828+
_generate_sum("test.counter", 1, unit=""),
829+
strategy,
830+
prefix="myprefix",
831+
)
832+
self.assertEqual(result[0].name, expected_name)

0 commit comments

Comments
 (0)