Skip to content

Commit d73593d

Browse files
aabmassocelotl
andauthored
Fix prometheus metric name and unit conversion (#3924)
* Fix prometheus metric name and unit conversion * Apply suggestions from code review Co-authored-by: Diego Hurtado <[email protected]> * Make annotation parsing more permissive, add test case for consecutive underscores * Add test case for metric name already containing the unit * simplify and speed up regex and update TODO * Add OTEL_PYTHON_EXPERIMENTAL_DISABLE_PROMETHEUS_UNIT_NORMALIZATION opt-out mechanism * Fix RST typo --------- Co-authored-by: Diego Hurtado <[email protected]>
1 parent 187048a commit d73593d

File tree

7 files changed

+598
-61
lines changed

7 files changed

+598
-61
lines changed

CHANGELOG.md

+12
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4242
([#3917](https://github.com/open-telemetry/opentelemetry-python/pull/3917/))
4343
- Add OpenTelemetry trove classifiers to PyPI packages
4444
([#3913] (https://github.com/open-telemetry/opentelemetry-python/pull/3913))
45+
- Fix prometheus metric name and unit conversion
46+
([#3924](https://github.com/open-telemetry/opentelemetry-python/pull/3924))
47+
- this is a breaking change to prometheus metric names so they comply with the
48+
[specification](https://github.com/open-telemetry/opentelemetry-specification/blob/v1.33.0/specification/compatibility/prometheus_and_openmetrics.md#otlp-metric-points-to-prometheus).
49+
- you can temporarily opt-out of the unit normalization by setting the environment variable
50+
`OTEL_PYTHON_EXPERIMENTAL_DISABLE_PROMETHEUS_UNIT_NORMALIZATION=true`
51+
- common unit abbreviations are converted to Prometheus conventions (`s` -> `seconds`),
52+
following the [collector's implementation](https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/c0b51136575aa7ba89326d18edb4549e7e1bbdb9/pkg/translator/prometheus/normalize_name.go#L108)
53+
- repeated `_` are replaced with a single `_`
54+
- unit annotations (enclosed in curly braces like `{requests}`) are stripped away
55+
- units with slash are converted e.g. `m/s` -> `meters_per_second`.
56+
- The exporter's API is not changed
4557

4658
## Version 1.24.0/0.45b0 (2024-03-28)
4759

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

+31-22
Original file line numberDiff line numberDiff line change
@@ -67,8 +67,7 @@
6767
from json import dumps
6868
from logging import getLogger
6969
from os import environ
70-
from re import IGNORECASE, UNICODE, compile
71-
from typing import Dict, Sequence, Tuple, Union
70+
from typing import Deque, Dict, Iterable, Sequence, Tuple, Union
7271

7372
from prometheus_client import start_http_server
7473
from prometheus_client.core import (
@@ -80,9 +79,15 @@
8079
)
8180
from prometheus_client.core import Metric as PrometheusMetric
8281

82+
from opentelemetry.exporter.prometheus._mapping import (
83+
map_unit,
84+
sanitize_attribute,
85+
sanitize_full_name,
86+
)
8387
from opentelemetry.sdk.environment_variables import (
8488
OTEL_EXPORTER_PROMETHEUS_HOST,
8589
OTEL_EXPORTER_PROMETHEUS_PORT,
90+
OTEL_PYTHON_EXPERIMENTAL_DISABLE_PROMETHEUS_UNIT_NORMALIZATION,
8691
)
8792
from opentelemetry.sdk.metrics import Counter
8893
from opentelemetry.sdk.metrics import Histogram as HistogramInstrument
@@ -101,6 +106,7 @@
101106
MetricsData,
102107
Sum,
103108
)
109+
from opentelemetry.util.types import Attributes
104110

105111
_logger = getLogger(__name__)
106112

@@ -164,18 +170,15 @@ class _CustomCollector:
164170

165171
def __init__(self, disable_target_info: bool = False):
166172
self._callback = None
167-
self._metrics_datas = deque()
168-
self._non_letters_digits_underscore_re = compile(
169-
r"[^\w]", UNICODE | IGNORECASE
170-
)
173+
self._metrics_datas: Deque[MetricsData] = deque()
171174
self._disable_target_info = disable_target_info
172175
self._target_info = None
173176

174177
def add_metrics_data(self, metrics_data: MetricsData) -> None:
175178
"""Add metrics to Prometheus data"""
176179
self._metrics_datas.append(metrics_data)
177180

178-
def collect(self) -> None:
181+
def collect(self) -> Iterable[PrometheusMetric]:
179182
"""Collect fetches the metrics from OpenTelemetry
180183
and delivers them as Prometheus Metrics.
181184
Collect is invoked every time a ``prometheus.Gatherer`` is run
@@ -189,7 +192,7 @@ def collect(self) -> None:
189192
if len(self._metrics_datas):
190193
if not self._disable_target_info:
191194
if self._target_info is None:
192-
attributes = {}
195+
attributes: Attributes = {}
193196
for res in self._metrics_datas[0].resource_metrics:
194197
attributes = {**attributes, **res.resource.attributes}
195198

@@ -228,17 +231,29 @@ def _translate_to_prometheus(
228231

229232
pre_metric_family_ids = []
230233

231-
metric_name = ""
232-
metric_name += self._sanitize(metric.name)
234+
metric_name = sanitize_full_name(metric.name)
233235

234236
metric_description = metric.description or ""
235237

238+
# TODO(#3929): remove this opt-out option
239+
disable_unit_normalization = (
240+
environ.get(
241+
OTEL_PYTHON_EXPERIMENTAL_DISABLE_PROMETHEUS_UNIT_NORMALIZATION,
242+
"false",
243+
).lower()
244+
== "true"
245+
)
246+
if disable_unit_normalization:
247+
metric_unit = metric.unit
248+
else:
249+
metric_unit = map_unit(metric.unit)
250+
236251
for number_data_point in metric.data.data_points:
237252
label_keys = []
238253
label_values = []
239254

240255
for key, value in sorted(number_data_point.attributes.items()):
241-
label_keys.append(self._sanitize(key))
256+
label_keys.append(sanitize_attribute(key))
242257
label_values.append(self._check_value(value))
243258

244259
pre_metric_family_ids.append(
@@ -247,7 +262,7 @@ def _translate_to_prometheus(
247262
metric_name,
248263
metric_description,
249264
"%".join(label_keys),
250-
metric.unit,
265+
metric_unit,
251266
]
252267
)
253268
)
@@ -299,7 +314,7 @@ def _translate_to_prometheus(
299314
name=metric_name,
300315
documentation=metric_description,
301316
labels=label_keys,
302-
unit=metric.unit,
317+
unit=metric_unit,
303318
)
304319
)
305320
metric_family_id_metric_family[
@@ -323,7 +338,7 @@ def _translate_to_prometheus(
323338
name=metric_name,
324339
documentation=metric_description,
325340
labels=label_keys,
326-
unit=metric.unit,
341+
unit=metric_unit,
327342
)
328343
)
329344
metric_family_id_metric_family[
@@ -344,7 +359,7 @@ def _translate_to_prometheus(
344359
name=metric_name,
345360
documentation=metric_description,
346361
labels=label_keys,
347-
unit=metric.unit,
362+
unit=metric_unit,
348363
)
349364
)
350365
metric_family_id_metric_family[
@@ -361,12 +376,6 @@ def _translate_to_prometheus(
361376
"Unsupported metric data. %s", type(metric.data)
362377
)
363378

364-
def _sanitize(self, key: str) -> str:
365-
"""sanitize the given metric name or label according to Prometheus rule.
366-
Replace all characters other than [A-Za-z0-9_] with '_'.
367-
"""
368-
return self._non_letters_digits_underscore_re.sub("_", key)
369-
370379
# pylint: disable=no-self-use
371380
def _check_value(self, value: Union[int, float, str, Sequence]) -> str:
372381
"""Check the label value and return is appropriate representation"""
@@ -380,7 +389,7 @@ def _create_info_metric(
380389
"""Create an Info Metric Family with list of attributes"""
381390
# sanitize the attribute names according to Prometheus rule
382391
attributes = {
383-
self._sanitize(key): self._check_value(value)
392+
sanitize_attribute(key): self._check_value(value)
384393
for key, value in attributes.items()
385394
}
386395
info = InfoMetricFamily(name, description, labels=attributes)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
# Copyright The OpenTelemetry Authors
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from re import UNICODE, compile
16+
17+
_SANITIZE_NAME_RE = compile(r"[^a-zA-Z0-9:]+", UNICODE)
18+
# Same as name, but doesn't allow ":"
19+
_SANITIZE_ATTRIBUTE_KEY_RE = compile(r"[^a-zA-Z0-9]+", UNICODE)
20+
21+
# UCUM style annotations which are text enclosed in curly braces https://ucum.org/ucum#para-6.
22+
# This regex is more permissive than UCUM allows and matches any character within curly braces.
23+
_UNIT_ANNOTATION = compile(r"{.*}")
24+
25+
# Remaps common UCUM and SI units to prometheus conventions. Copied from
26+
# https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/v0.101.0/pkg/translator/prometheus/normalize_name.go#L19
27+
# See specification:
28+
# https://github.com/open-telemetry/opentelemetry-specification/blob/v1.33.0/specification/compatibility/prometheus_and_openmetrics.md#metric-metadata-1
29+
_UNIT_MAPPINGS = {
30+
# Time
31+
"d": "days",
32+
"h": "hours",
33+
"min": "minutes",
34+
"s": "seconds",
35+
"ms": "milliseconds",
36+
"us": "microseconds",
37+
"ns": "nanoseconds",
38+
# Bytes
39+
"By": "bytes",
40+
"KiBy": "kibibytes",
41+
"MiBy": "mebibytes",
42+
"GiBy": "gibibytes",
43+
"TiBy": "tibibytes",
44+
"KBy": "kilobytes",
45+
"MBy": "megabytes",
46+
"GBy": "gigabytes",
47+
"TBy": "terabytes",
48+
# SI
49+
"m": "meters",
50+
"V": "volts",
51+
"A": "amperes",
52+
"J": "joules",
53+
"W": "watts",
54+
"g": "grams",
55+
# Misc
56+
"Cel": "celsius",
57+
"Hz": "hertz",
58+
# TODO(https://github.com/open-telemetry/opentelemetry-specification/issues/4058): the
59+
# specification says to normalize "1" to ratio but that may change. Update this mapping or
60+
# remove TODO once a decision is made.
61+
"1": "",
62+
"%": "percent",
63+
}
64+
# Similar to _UNIT_MAPPINGS, but for "per" unit denominator.
65+
# Example: s => per second (singular)
66+
# Copied from https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/80317ce83ed87a2dff0c316bb939afbfaa823d5e/pkg/translator/prometheus/normalize_name.go#L58
67+
_PER_UNIT_MAPPINGS = {
68+
"s": "second",
69+
"m": "minute",
70+
"h": "hour",
71+
"d": "day",
72+
"w": "week",
73+
"mo": "month",
74+
"y": "year",
75+
}
76+
77+
78+
def sanitize_full_name(name: str) -> str:
79+
"""sanitize the given metric name according to Prometheus rule, including sanitizing
80+
leading digits
81+
82+
https://github.com/open-telemetry/opentelemetry-specification/blob/v1.33.0/specification/compatibility/prometheus_and_openmetrics.md#metric-metadata-1
83+
"""
84+
# Leading number special case
85+
if name and name[0].isdigit():
86+
name = "_" + name[1:]
87+
return _sanitize_name(name)
88+
89+
90+
def _sanitize_name(name: str) -> str:
91+
"""sanitize the given metric name according to Prometheus rule, but does not handle
92+
sanitizing a leading digit."""
93+
return _SANITIZE_NAME_RE.sub("_", name)
94+
95+
96+
def sanitize_attribute(key: str) -> str:
97+
"""sanitize the given metric attribute key according to Prometheus rule.
98+
99+
https://github.com/open-telemetry/opentelemetry-specification/blob/v1.33.0/specification/compatibility/prometheus_and_openmetrics.md#metric-attributes
100+
"""
101+
# Leading number special case
102+
if key and key[0].isdigit():
103+
key = "_" + key[1:]
104+
return _SANITIZE_ATTRIBUTE_KEY_RE.sub("_", key)
105+
106+
107+
def map_unit(unit: str) -> str:
108+
"""Maps unit to common prometheus metric names if available and sanitizes any invalid
109+
characters
110+
111+
See:
112+
- https://github.com/open-telemetry/opentelemetry-specification/blob/v1.33.0/specification/compatibility/prometheus_and_openmetrics.md#metric-metadata-1
113+
- https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/v0.101.0/pkg/translator/prometheus/normalize_name.go#L108
114+
"""
115+
# remove curly brace unit annotations
116+
unit = _UNIT_ANNOTATION.sub("", unit)
117+
118+
if unit in _UNIT_MAPPINGS:
119+
return _UNIT_MAPPINGS[unit]
120+
121+
# replace "/" with "per" units like m/s -> meters_per_second
122+
ratio_unit_subparts = unit.split("/", maxsplit=1)
123+
if len(ratio_unit_subparts) == 2:
124+
bottom = _sanitize_name(ratio_unit_subparts[1])
125+
if bottom:
126+
top = _sanitize_name(ratio_unit_subparts[0])
127+
top = _UNIT_MAPPINGS.get(top, top)
128+
bottom = _PER_UNIT_MAPPINGS.get(bottom, bottom)
129+
return f"{top}_per_{bottom}" if top else f"per_{bottom}"
130+
131+
return (
132+
# since units end up as a metric name suffix, they must be sanitized
133+
_sanitize_name(unit)
134+
# strip surrounding "_" chars since it will lead to consecutive underscores in the
135+
# metric name
136+
.strip("_")
137+
)

0 commit comments

Comments
 (0)