1use ahash::HashMap;
16use lazy_static::lazy_static;
17use otel_arrow_rust::proto::opentelemetry::metrics::v1::Metric;
18use regex::Regex;
19use session::protocol_ctx::{MetricType, OtlpMetricTranslationStrategy};
20
21const UNDERSCORE: &str = "_";
22const DOUBLE_UNDERSCORE: &str = "__";
23const TOTAL: &str = "total";
24const RATIO: &str = "ratio";
25const PER_PREFIX: &str = "per_";
26
27lazy_static! {
28 static ref NON_ALPHA_NUM_CHAR: Regex = Regex::new(r"[^a-zA-Z0-9]").unwrap();
29 static ref UNIT_MAP: HashMap<String, String> = [
30 ("d", "days"),
32 ("h", "hours"),
33 ("min", "minutes"),
34 ("s", "seconds"),
35 ("ms", "milliseconds"),
36 ("us", "microseconds"),
37 ("ns", "nanoseconds"),
38 ("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 ("m", "meters"),
50 ("V", "volts"),
51 ("A", "amperes"),
52 ("J", "joules"),
53 ("W", "watts"),
54 ("g", "grams"),
55 ("Cel", "celsius"),
57 ("Hz", "hertz"),
58 ("1", ""),
59 ("%", "percent"),
60 ]
61 .iter()
62 .map(|(k, v)| (k.to_string(), v.to_string()))
63 .collect();
64 static ref PER_UNIT_MAP: HashMap<String, String> = [
65 ("s", "second"),
66 ("m", "minute"),
67 ("h", "hour"),
68 ("d", "day"),
69 ("w", "week"),
70 ("mo", "month"),
71 ("y", "year"),
72 ]
73 .iter()
74 .map(|(k, v)| (k.to_string(), v.to_string()))
75 .collect();
76}
77
78pub fn translate_metric_name(
79 metric: &Metric,
80 metric_type: &MetricType,
81 strategy: OtlpMetricTranslationStrategy,
82) -> String {
83 match (strategy.should_escape(), strategy.should_add_suffixes()) {
84 (true, true) => normalize_metric_name(metric, metric_type),
85 (true, false) => normalize_metric_name_without_suffixes(&metric.name),
86 (false, true) => build_utf8_metric_name(&metric.name, &metric.unit, metric_type),
87 (false, false) => metric.name.clone(),
88 }
89}
90
91pub fn translate_label_name(name: &str, strategy: OtlpMetricTranslationStrategy) -> String {
92 if strategy.should_escape() {
93 normalize_label_name(name)
94 } else {
95 name.to_string()
96 }
97}
98
99pub fn normalize_metric_name(metric: &Metric, metric_type: &MetricType) -> String {
101 normalize_metric_name_with_suffixes(&metric.name, &metric.unit, metric_type)
102}
103
104fn normalize_metric_name_with_suffixes(name: &str, unit: &str, metric_type: &MetricType) -> String {
105 let mut name_tokens = metric_name_tokens(name);
106
107 if !unit.is_empty() {
108 let (main, per) = build_clean_unit_suffix(unit);
109 if let Some(main) = main
110 && !name_tokens.contains(&main)
111 {
112 name_tokens.push(main);
113 }
114 if let Some(per) = per
115 && !name_tokens.contains(&per)
116 {
117 name_tokens.push("per".to_string());
118 name_tokens.push(per);
119 }
120 }
121
122 if matches!(metric_type, MetricType::MonotonicSum) {
123 name_tokens.retain(|t| t != TOTAL);
124 name_tokens.push(TOTAL.to_string());
125 }
126
127 if unit == "1" && matches!(metric_type, MetricType::Gauge) {
128 name_tokens.retain(|t| t != RATIO);
129 name_tokens.push(RATIO.to_string());
130 }
131
132 prefix_digit_metric_name(name_tokens.join(UNDERSCORE))
133}
134
135fn normalize_metric_name_without_suffixes(name: &str) -> String {
136 prefix_digit_metric_name(metric_name_tokens(name).join(UNDERSCORE))
137}
138
139fn metric_name_tokens(name: &str) -> Vec<String> {
140 NON_ALPHA_NUM_CHAR
141 .split(name)
142 .filter_map(|s| {
143 let trimmed = s.trim();
144 if trimmed.is_empty() {
145 None
146 } else {
147 Some(trimmed.to_string())
148 }
149 })
150 .collect()
151}
152
153fn prefix_digit_metric_name(name: String) -> String {
154 if let Some((_, first)) = name.char_indices().next()
155 && first.is_ascii_digit()
156 {
157 format!("_{}", name)
158 } else {
159 name
160 }
161}
162
163fn build_utf8_metric_name(input_name: &str, unit: &str, metric_type: &MetricType) -> String {
164 let mut name = input_name.to_string();
165
166 let append_ratio = unit == "1" && matches!(metric_type, MetricType::Gauge);
167 if append_ratio {
168 name = trim_suffix_and_delimiter(&name, RATIO);
169 }
170
171 let append_total = matches!(metric_type, MetricType::MonotonicSum);
172 if append_total {
173 name = trim_suffix_and_delimiter(&name, TOTAL);
174 }
175
176 let (main_unit_suffix, per_unit_suffix) = build_unit_suffixes(unit);
177 let append_per = !per_unit_suffix.is_empty();
178 if append_per {
179 name = trim_suffix_and_delimiter(&name, &per_unit_suffix);
180 }
181
182 if !main_unit_suffix.is_empty() && !name.ends_with(&main_unit_suffix) {
183 name.push('_');
184 name.push_str(&main_unit_suffix);
185 }
186 if append_per {
187 name.push('_');
188 name.push_str(&per_unit_suffix);
189 }
190 if append_total {
191 name.push_str("_total");
192 }
193 if append_ratio {
194 name.push_str("_ratio");
195 }
196
197 name
198}
199
200fn trim_suffix_and_delimiter(name: &str, suffix: &str) -> String {
201 name.strip_suffix(suffix)
202 .and_then(|prefix| prefix.strip_suffix('_'))
203 .filter(|prefix| !prefix.is_empty())
204 .unwrap_or(name)
205 .to_string()
206}
207
208fn build_clean_unit_suffix(unit: &str) -> (Option<String>, Option<String>) {
209 let (main, per) = build_unit_suffixes(unit);
210 let main = clean_unit_name(&main);
211 let per = per
212 .strip_prefix(PER_PREFIX)
213 .map(clean_unit_name)
214 .unwrap_or_default();
215
216 (
217 (!main.is_empty()).then_some(main),
218 (!per.is_empty()).then_some(per),
219 )
220}
221
222pub(crate) fn ucum_to_openmetrics_unit(unit: &str) -> String {
224 if unit == "1" {
225 return "ratios".to_string();
226 }
227
228 match build_clean_unit_suffix(unit) {
229 (Some(main), Some(per)) => format!("{main}_per_{per}"),
230 (Some(main), None) => main,
231 (None, Some(per)) => format!("per_{per}"),
232 (None, None) => String::new(),
233 }
234}
235
236fn build_unit_suffixes(unit: &str) -> (String, String) {
237 let (main, per) = unit.split_once('/').unwrap_or((unit, ""));
238 let main_unit_suffix = unit_suffix(main, &UNIT_MAP);
239 let per_unit_suffix = unit_suffix(per, &PER_UNIT_MAP);
240
241 if per_unit_suffix.is_empty() {
242 (main_unit_suffix, per_unit_suffix)
243 } else {
244 (main_unit_suffix, format!("{PER_PREFIX}{per_unit_suffix}"))
245 }
246}
247
248fn unit_suffix(unit_str: &str, unit_map: &HashMap<String, String>) -> String {
249 let unit = unit_str.trim();
250 if unit.is_empty() || unit.contains('{') || unit.contains('}') {
251 return String::new();
252 }
253
254 unit_map
255 .get(unit)
256 .map(|s| s.as_ref())
257 .unwrap_or(unit)
258 .to_string()
259}
260
261pub(crate) fn clean_unit_name(name: &str) -> String {
262 NON_ALPHA_NUM_CHAR
263 .split(name)
264 .filter(|s| !s.is_empty())
265 .collect::<Vec<&str>>()
266 .join(UNDERSCORE)
267 .trim_matches('_')
268 .to_string()
269}
270
271pub fn normalize_label_name(name: &str) -> String {
273 if name.is_empty() {
274 return name.to_string();
275 }
276
277 let n = NON_ALPHA_NUM_CHAR.replace_all(name, UNDERSCORE);
278 if let Some((_, first)) = n.char_indices().next()
279 && first.is_ascii_digit()
280 {
281 return format!("key_{}", n);
282 }
283 if n.starts_with(UNDERSCORE) && !n.starts_with(DOUBLE_UNDERSCORE) {
284 return format!("key{}", n);
285 }
286 n.to_string()
287}
288
289#[cfg(test)]
290mod tests {
291 use otel_arrow_rust::proto::opentelemetry::metrics::v1::Metric;
292 use session::protocol_ctx::OtlpMetricTranslationStrategy::{
293 NoTranslation, NoUtf8EscapingWithSuffixes, UnderscoreEscapingWithSuffixes,
294 UnderscoreEscapingWithoutSuffixes,
295 };
296
297 use super::*;
298
299 #[test]
300 fn test_translate_metric_name_strategies() {
301 let metric = Metric {
302 name: "http.server.duration_total".to_string(),
303 unit: "s".to_string(),
304 ..Default::default()
305 };
306
307 assert_eq!(
308 translate_metric_name(
309 &metric,
310 &MetricType::MonotonicSum,
311 UnderscoreEscapingWithSuffixes
312 ),
313 "http_server_duration_seconds_total"
314 );
315 assert_eq!(
316 translate_metric_name(
317 &metric,
318 &MetricType::MonotonicSum,
319 UnderscoreEscapingWithoutSuffixes,
320 ),
321 "http_server_duration_total"
322 );
323 assert_eq!(
324 translate_metric_name(
325 &metric,
326 &MetricType::MonotonicSum,
327 NoUtf8EscapingWithSuffixes
328 ),
329 "http.server.duration_seconds_total"
330 );
331 assert_eq!(
332 translate_metric_name(&metric, &MetricType::MonotonicSum, NoTranslation),
333 "http.server.duration_total"
334 );
335 }
336
337 #[test]
338 fn test_translate_metric_name_no_utf8_suffix_ordering() {
339 let metric = Metric {
340 name: "request.rate_per_second_total".to_string(),
341 unit: "1/s".to_string(),
342 ..Default::default()
343 };
344 assert_eq!(
345 translate_metric_name(
346 &metric,
347 &MetricType::MonotonicSum,
348 NoUtf8EscapingWithSuffixes
349 ),
350 "request.rate_per_second_total"
351 );
352
353 let metric = Metric {
354 name: "cpu.utilization_ratio".to_string(),
355 unit: "1".to_string(),
356 ..Default::default()
357 };
358 assert_eq!(
359 translate_metric_name(&metric, &MetricType::Gauge, NoUtf8EscapingWithSuffixes),
360 "cpu.utilization_ratio"
361 );
362
363 let metric = Metric {
364 name: "subtotal".to_string(),
365 ..Default::default()
366 };
367 assert_eq!(
368 translate_metric_name(
369 &metric,
370 &MetricType::MonotonicSum,
371 NoUtf8EscapingWithSuffixes
372 ),
373 "subtotal_total"
374 );
375
376 let metric = Metric {
377 name: "utilizationratio".to_string(),
378 unit: "1".to_string(),
379 ..Default::default()
380 };
381 assert_eq!(
382 translate_metric_name(&metric, &MetricType::Gauge, NoUtf8EscapingWithSuffixes),
383 "utilizationratio_ratio"
384 );
385 }
386
387 #[test]
388 fn test_translate_metric_name_prometheus_style_units_for_all_strategies() {
389 let cases = [
390 (
391 Metric {
392 name: "duration.latency".to_string(),
393 unit: "ms".to_string(),
394 ..Default::default()
395 },
396 MetricType::Gauge,
397 [
398 (
399 UnderscoreEscapingWithSuffixes,
400 "duration_latency_milliseconds",
401 ),
402 (UnderscoreEscapingWithoutSuffixes, "duration_latency"),
403 (NoUtf8EscapingWithSuffixes, "duration.latency_milliseconds"),
404 (NoTranslation, "duration.latency"),
405 ],
406 ),
407 (
408 Metric {
409 name: "disk.io".to_string(),
410 unit: "By".to_string(),
411 ..Default::default()
412 },
413 MetricType::MonotonicSum,
414 [
415 (UnderscoreEscapingWithSuffixes, "disk_io_bytes_total"),
416 (UnderscoreEscapingWithoutSuffixes, "disk_io"),
417 (NoUtf8EscapingWithSuffixes, "disk.io_bytes_total"),
418 (NoTranslation, "disk.io"),
419 ],
420 ),
421 (
422 Metric {
423 name: "cpu.utilization".to_string(),
424 unit: "%".to_string(),
425 ..Default::default()
426 },
427 MetricType::Gauge,
428 [
429 (UnderscoreEscapingWithSuffixes, "cpu_utilization_percent"),
430 (UnderscoreEscapingWithoutSuffixes, "cpu_utilization"),
431 (NoUtf8EscapingWithSuffixes, "cpu.utilization_percent"),
432 (NoTranslation, "cpu.utilization"),
433 ],
434 ),
435 (
436 Metric {
437 name: "request.rate".to_string(),
438 unit: "1/s".to_string(),
439 ..Default::default()
440 },
441 MetricType::MonotonicSum,
442 [
443 (
444 UnderscoreEscapingWithSuffixes,
445 "request_rate_per_second_total",
446 ),
447 (UnderscoreEscapingWithoutSuffixes, "request_rate"),
448 (NoUtf8EscapingWithSuffixes, "request.rate_per_second_total"),
449 (NoTranslation, "request.rate"),
450 ],
451 ),
452 (
453 Metric {
454 name: "queue.depth".to_string(),
455 unit: "{items}".to_string(),
456 ..Default::default()
457 },
458 MetricType::Gauge,
459 [
460 (UnderscoreEscapingWithSuffixes, "queue_depth"),
461 (UnderscoreEscapingWithoutSuffixes, "queue_depth"),
462 (NoUtf8EscapingWithSuffixes, "queue.depth"),
463 (NoTranslation, "queue.depth"),
464 ],
465 ),
466 ];
467
468 for (metric, metric_type, expectations) in cases {
469 for (strategy, expected) in expectations {
470 assert_eq!(
471 translate_metric_name(&metric, &metric_type, strategy),
472 expected,
473 "metric: {}, unit: {}, type: {:?}, strategy: {:?}",
474 metric.name,
475 metric.unit,
476 metric_type,
477 strategy
478 );
479 }
480 }
481 }
482
483 #[test]
484 fn test_translate_label_name_strategies() {
485 assert_eq!(
486 translate_label_name("service.name", UnderscoreEscapingWithSuffixes),
487 "service_name"
488 );
489 assert_eq!(
490 translate_label_name("_foo", UnderscoreEscapingWithoutSuffixes),
491 "key_foo"
492 );
493 assert_eq!(
494 translate_label_name("service.name", NoUtf8EscapingWithSuffixes),
495 "service.name"
496 );
497 assert_eq!(translate_label_name("_foo", NoTranslation), "_foo");
498 }
499
500 #[test]
501 fn test_clean_unit_name() {
502 assert_eq!(clean_unit_name("faults"), "faults");
503 assert_eq!(clean_unit_name("{faults}"), "faults");
504 assert_eq!(clean_unit_name("req/sec"), "req_sec");
505 assert_eq!(clean_unit_name("m/s"), "m_s");
506 assert_eq!(clean_unit_name("___test___"), "test");
507 assert_eq!(
508 clean_unit_name("multiple__underscores"),
509 "multiple_underscores"
510 );
511 assert_eq!(clean_unit_name(""), "");
512 assert_eq!(clean_unit_name("___"), "");
513 assert_eq!(clean_unit_name("bytes.per.second"), "bytes_per_second");
514 }
515}