Skip to content

Commit b87a827

Browse files
authored
fix: unit conversion in prom exporter (#1157)
* fix: unit conversion in prom exporter * re-generate && make fmt * add change log * fix: proto changes * use split_once
1 parent e150092 commit b87a827

File tree

4 files changed

+210
-98
lines changed

4 files changed

+210
-98
lines changed

.gitmodules

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
[submodule "opentelemetry-proto/src/proto/opentelemetry-proto"]
22
path = opentelemetry-proto/src/proto/opentelemetry-proto
33
url = https://github.com/open-telemetry/opentelemetry-proto
4-
branch = tags/v0.19.0
4+
branch = tags/v1.0.0

opentelemetry-prometheus/CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
### Added
66

77
- Add `with_namespace` option to exporter config.
8+
- Add more units conversions between OTEL metrics and prometheus metrics [#1157](https://github.com/open-telemetry/opentelemetry-rust/pull/1157).
89
- Add `without_counter_suffixes` option to exporter config.
910

1011
## v0.12.0

opentelemetry-prometheus/src/lib.rs

+12-97
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@
9696
use once_cell::sync::{Lazy, OnceCell};
9797
use opentelemetry_api::{
9898
global,
99-
metrics::{MetricsError, Result, Unit},
99+
metrics::{MetricsError, Result},
100100
Context, Key, Value,
101101
};
102102
use opentelemetry_sdk::{
@@ -132,6 +132,7 @@ const SCOPE_INFO_KEYS: [&str; 2] = ["otel_scope_name", "otel_scope_version"];
132132
const COUNTER_SUFFIX: &str = "_total";
133133

134134
mod config;
135+
mod utils;
135136

136137
pub use config::ExporterBuilder;
137138

@@ -259,14 +260,16 @@ impl Collector {
259260
}
260261

261262
fn get_name(&self, m: &data::Metric) -> Cow<'static, str> {
262-
let name = sanitize_name(&m.name);
263-
match (
264-
&self.namespace,
265-
get_unit_suffixes(&m.unit).filter(|_| !self.without_units),
266-
) {
267-
(Some(namespace), Some(suffix)) => Cow::Owned(format!("{namespace}{name}{suffix}")),
263+
let name = utils::sanitize_name(&m.name);
264+
let unit_suffixes = if self.without_units {
265+
None
266+
} else {
267+
utils::get_unit_suffixes(&m.unit)
268+
};
269+
match (&self.namespace, unit_suffixes) {
270+
(Some(namespace), Some(suffix)) => Cow::Owned(format!("{namespace}{name}_{suffix}")),
268271
(Some(namespace), None) => Cow::Owned(format!("{namespace}{name}")),
269-
(None, Some(suffix)) => Cow::Owned(format!("{name}{suffix}")),
272+
(None, Some(suffix)) => Cow::Owned(format!("{name}_{suffix}")),
270273
(None, None) => name,
271274
}
272275
}
@@ -378,7 +381,7 @@ impl prometheus::core::Collector for Collector {
378381
fn get_attrs(kvs: &mut dyn Iterator<Item = (&Key, &Value)>, extra: &[LabelPair]) -> Vec<LabelPair> {
379382
let mut keys_map = BTreeMap::<String, Vec<String>>::new();
380383
for (key, value) in kvs {
381-
let key = sanitize_prom_kv(key.as_str());
384+
let key = utils::sanitize_prom_kv(key.as_str());
382385
keys_map
383386
.entry(key)
384387
.and_modify(|v| v.push(value.to_string()))
@@ -587,63 +590,6 @@ fn create_scope_info_metric(scope: &Scope) -> MetricFamily {
587590
mf
588591
}
589592

590-
fn get_unit_suffixes(unit: &Unit) -> Option<&'static str> {
591-
match unit.as_str() {
592-
"1" => Some("_ratio"),
593-
"By" => Some("_bytes"),
594-
"ms" => Some("_milliseconds"),
595-
_ => None,
596-
}
597-
}
598-
599-
#[allow(clippy::ptr_arg)]
600-
fn sanitize_name(s: &Cow<'static, str>) -> Cow<'static, str> {
601-
// prefix chars to add in case name starts with number
602-
let mut prefix = "";
603-
604-
// Find first invalid char
605-
if let Some((replace_idx, _)) = s.char_indices().find(|(i, c)| {
606-
if *i == 0 && c.is_ascii_digit() {
607-
// first char is number, add prefix and replace reset of chars
608-
prefix = "_";
609-
true
610-
} else {
611-
// keep checking
612-
!c.is_alphanumeric() && *c != '_' && *c != ':'
613-
}
614-
}) {
615-
// up to `replace_idx` have been validated, convert the rest
616-
let (valid, rest) = s.split_at(replace_idx);
617-
Cow::Owned(
618-
prefix
619-
.chars()
620-
.chain(valid.chars())
621-
.chain(rest.chars().map(|c| {
622-
if c.is_ascii_alphanumeric() || c == '_' || c == ':' {
623-
c
624-
} else {
625-
'_'
626-
}
627-
}))
628-
.collect(),
629-
)
630-
} else {
631-
s.clone() // no invalid chars found, return existing
632-
}
633-
}
634-
635-
fn sanitize_prom_kv(s: &str) -> String {
636-
s.chars()
637-
.map(|c| {
638-
if c.is_ascii_alphanumeric() || c == ':' {
639-
c
640-
} else {
641-
'_'
642-
}
643-
})
644-
.collect()
645-
}
646-
647593
trait Numeric: fmt::Debug {
648594
// lossy at large values for u64 and i64 but prometheus only handles floats
649595
fn as_f64(&self) -> f64;
@@ -666,34 +612,3 @@ impl Numeric for f64 {
666612
*self
667613
}
668614
}
669-
670-
#[cfg(test)]
671-
mod tests {
672-
use super::*;
673-
674-
#[test]
675-
fn name_sanitization() {
676-
let tests = vec![
677-
("nam€_with_3_width_rune", "nam__with_3_width_rune"),
678-
("`", "_"),
679-
(
680-
r##"! "#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWKYZ[]\^_abcdefghijklmnopqrstuvwkyz{|}~"##,
681-
"________________0123456789:______ABCDEFGHIJKLMNOPQRSTUVWKYZ_____abcdefghijklmnopqrstuvwkyz____",
682-
),
683-
684-
("Avalid_23name", "Avalid_23name"),
685-
("_Avalid_23name", "_Avalid_23name"),
686-
("1valid_23name", "_1valid_23name"),
687-
("avalid_23name", "avalid_23name"),
688-
("Ava:lid_23name", "Ava:lid_23name"),
689-
("a lid_23name", "a_lid_23name"),
690-
(":leading_colon", ":leading_colon"),
691-
("colon:in:the:middle", "colon:in:the:middle"),
692-
("", ""),
693-
];
694-
695-
for (input, want) in tests {
696-
assert_eq!(want, sanitize_name(&input.into()), "input: {input}")
697-
}
698-
}
699-
}

opentelemetry-prometheus/src/utils.rs

+196
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
use opentelemetry_api::metrics::Unit;
2+
use std::borrow::Cow;
3+
4+
const NON_APPLICABLE_ON_PER_UNIT: [&str; 8] = ["1", "d", "h", "min", "s", "ms", "us", "ns"];
5+
6+
pub(crate) fn get_unit_suffixes(unit: &Unit) -> Option<Cow<'static, str>> {
7+
// no unit return early
8+
if unit.as_str().is_empty() {
9+
return None;
10+
}
11+
12+
// direct match with known units
13+
if let Some(matched) = get_prom_units(unit.as_str()) {
14+
return Some(Cow::Borrowed(matched));
15+
}
16+
17+
// converting foo/bar to foo_per_bar
18+
// split the string by the first '/'
19+
// if the first part is empty, we just return the second part if it's a match with known per unit
20+
// e.g
21+
// "test/y" => "per_year"
22+
// "km/s" => "kilometers_per_second"
23+
if let Some((first, second)) = unit.as_str().split_once('/') {
24+
return match (
25+
NON_APPLICABLE_ON_PER_UNIT.contains(&first),
26+
get_prom_units(first),
27+
get_prom_per_unit(second),
28+
) {
29+
(true, _, Some(second_part)) | (false, None, Some(second_part)) => {
30+
Some(Cow::Owned(format!("per_{}", second_part)))
31+
}
32+
(false, Some(first_part), Some(second_part)) => {
33+
Some(Cow::Owned(format!("{}_per_{}", first_part, second_part)))
34+
}
35+
_ => None,
36+
};
37+
}
38+
39+
None
40+
}
41+
42+
fn get_prom_units(unit: &str) -> Option<&'static str> {
43+
match unit {
44+
// Time
45+
"d" => Some("days"),
46+
"h" => Some("hours"),
47+
"min" => Some("minutes"),
48+
"s" => Some("seconds"),
49+
"ms" => Some("milliseconds"),
50+
"us" => Some("microseconds"),
51+
"ns" => Some("nanoseconds"),
52+
"By" => Some("bytes"),
53+
54+
// Bytes
55+
"KiBy" => Some("kibibytes"),
56+
"MiBy" => Some("mebibytes"),
57+
"GiBy" => Some("gibibytes"),
58+
"TiBy" => Some("tibibytes"),
59+
"KBy" => Some("kilobytes"),
60+
"MBy" => Some("megabytes"),
61+
"GBy" => Some("gigabytes"),
62+
"TBy" => Some("terabytes"),
63+
"B" => Some("bytes"),
64+
"KB" => Some("kilobytes"),
65+
"MB" => Some("megabytes"),
66+
"GB" => Some("gigabytes"),
67+
"TB" => Some("terabytes"),
68+
69+
// SI
70+
"m" => Some("meters"),
71+
"V" => Some("volts"),
72+
"A" => Some("amperes"),
73+
"J" => Some("joules"),
74+
"W" => Some("watts"),
75+
"g" => Some("grams"),
76+
77+
// Misc
78+
"Cel" => Some("celsius"),
79+
"Hz" => Some("hertz"),
80+
"1" => Some("ratio"),
81+
"%" => Some("percent"),
82+
"$" => Some("dollars"),
83+
_ => None,
84+
}
85+
}
86+
87+
fn get_prom_per_unit(unit: &str) -> Option<&'static str> {
88+
match unit {
89+
"s" => Some("second"),
90+
"m" => Some("minute"),
91+
"h" => Some("hour"),
92+
"d" => Some("day"),
93+
"w" => Some("week"),
94+
"mo" => Some("month"),
95+
"y" => Some("year"),
96+
_ => None,
97+
}
98+
}
99+
100+
#[allow(clippy::ptr_arg)]
101+
pub(crate) fn sanitize_name(s: &Cow<'static, str>) -> Cow<'static, str> {
102+
// prefix chars to add in case name starts with number
103+
let mut prefix = "";
104+
105+
// Find first invalid char
106+
if let Some((replace_idx, _)) = s.char_indices().find(|(i, c)| {
107+
if *i == 0 && c.is_ascii_digit() {
108+
// first char is number, add prefix and replace reset of chars
109+
prefix = "_";
110+
true
111+
} else {
112+
// keep checking
113+
!c.is_alphanumeric() && *c != '_' && *c != ':'
114+
}
115+
}) {
116+
// up to `replace_idx` have been validated, convert the rest
117+
let (valid, rest) = s.split_at(replace_idx);
118+
Cow::Owned(
119+
prefix
120+
.chars()
121+
.chain(valid.chars())
122+
.chain(rest.chars().map(|c| {
123+
if c.is_ascii_alphanumeric() || c == '_' || c == ':' {
124+
c
125+
} else {
126+
'_'
127+
}
128+
}))
129+
.collect(),
130+
)
131+
} else {
132+
s.clone() // no invalid chars found, return existing
133+
}
134+
}
135+
136+
pub(crate) fn sanitize_prom_kv(s: &str) -> String {
137+
s.chars()
138+
.map(|c| {
139+
if c.is_ascii_alphanumeric() || c == ':' {
140+
c
141+
} else {
142+
'_'
143+
}
144+
})
145+
.collect()
146+
}
147+
148+
#[cfg(test)]
149+
mod tests {
150+
use super::*;
151+
152+
#[test]
153+
fn name_sanitization() {
154+
let tests = vec![
155+
("nam€_with_3_width_rune", "nam__with_3_width_rune"),
156+
("`", "_"),
157+
(
158+
r##"! "#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWKYZ[]\^_abcdefghijklmnopqrstuvwkyz{|}~"##,
159+
"________________0123456789:______ABCDEFGHIJKLMNOPQRSTUVWKYZ_____abcdefghijklmnopqrstuvwkyz____",
160+
),
161+
162+
("Avalid_23name", "Avalid_23name"),
163+
("_Avalid_23name", "_Avalid_23name"),
164+
("1valid_23name", "_1valid_23name"),
165+
("avalid_23name", "avalid_23name"),
166+
("Ava:lid_23name", "Ava:lid_23name"),
167+
("a lid_23name", "a_lid_23name"),
168+
(":leading_colon", ":leading_colon"),
169+
("colon:in:the:middle", "colon:in:the:middle"),
170+
("", ""),
171+
];
172+
173+
for (input, want) in tests {
174+
assert_eq!(want, sanitize_name(&input.into()), "input: {input}")
175+
}
176+
}
177+
178+
#[test]
179+
fn test_get_unit_suffixes() {
180+
let test_cases = vec![
181+
// Direct match
182+
("g", Some(Cow::Borrowed("grams"))),
183+
// Per unit
184+
("test/y", Some(Cow::Owned("per_year".to_owned()))),
185+
("1/y", Some(Cow::Owned("per_year".to_owned()))),
186+
("m/s", Some(Cow::Owned("meters_per_second".to_owned()))),
187+
// No match
188+
("invalid", None),
189+
("", None),
190+
];
191+
for (unit_str, expected_suffix) in test_cases {
192+
let unit = Unit::new(unit_str);
193+
assert_eq!(get_unit_suffixes(&unit), expected_suffix);
194+
}
195+
}
196+
}

0 commit comments

Comments
 (0)