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
289pub fn legacy_normalize_otlp_name(name: &str) -> String {
296 name.to_lowercase().replace(['.', '-'], "_")
297}
298
299#[cfg(test)]
300mod tests {
301 use otel_arrow_rust::proto::opentelemetry::metrics::v1::Metric;
302 use session::protocol_ctx::OtlpMetricTranslationStrategy::{
303 NoTranslation, NoUtf8EscapingWithSuffixes, UnderscoreEscapingWithSuffixes,
304 UnderscoreEscapingWithoutSuffixes,
305 };
306
307 use super::*;
308
309 #[test]
310 fn test_legacy_normalize_otlp_name() {
311 assert_eq!(
312 legacy_normalize_otlp_name("jvm.memory.free"),
313 "jvm_memory_free"
314 );
315 assert_eq!(
316 legacy_normalize_otlp_name("jvm-memory-free"),
317 "jvm_memory_free"
318 );
319 assert_eq!(
320 legacy_normalize_otlp_name("jvm_memory_free"),
321 "jvm_memory_free"
322 );
323 assert_eq!(
324 legacy_normalize_otlp_name("JVM_MEMORY_FREE"),
325 "jvm_memory_free"
326 );
327 assert_eq!(
328 legacy_normalize_otlp_name("JVM_memory_FREE"),
329 "jvm_memory_free"
330 );
331 }
332
333 #[test]
334 fn test_translate_metric_name_strategies() {
335 let metric = Metric {
336 name: "http.server.duration_total".to_string(),
337 unit: "s".to_string(),
338 ..Default::default()
339 };
340
341 assert_eq!(
342 translate_metric_name(
343 &metric,
344 &MetricType::MonotonicSum,
345 UnderscoreEscapingWithSuffixes
346 ),
347 "http_server_duration_seconds_total"
348 );
349 assert_eq!(
350 translate_metric_name(
351 &metric,
352 &MetricType::MonotonicSum,
353 UnderscoreEscapingWithoutSuffixes,
354 ),
355 "http_server_duration_total"
356 );
357 assert_eq!(
358 translate_metric_name(
359 &metric,
360 &MetricType::MonotonicSum,
361 NoUtf8EscapingWithSuffixes
362 ),
363 "http.server.duration_seconds_total"
364 );
365 assert_eq!(
366 translate_metric_name(&metric, &MetricType::MonotonicSum, NoTranslation),
367 "http.server.duration_total"
368 );
369 }
370
371 #[test]
372 fn test_translate_metric_name_no_utf8_suffix_ordering() {
373 let metric = Metric {
374 name: "request.rate_per_second_total".to_string(),
375 unit: "1/s".to_string(),
376 ..Default::default()
377 };
378 assert_eq!(
379 translate_metric_name(
380 &metric,
381 &MetricType::MonotonicSum,
382 NoUtf8EscapingWithSuffixes
383 ),
384 "request.rate_per_second_total"
385 );
386
387 let metric = Metric {
388 name: "cpu.utilization_ratio".to_string(),
389 unit: "1".to_string(),
390 ..Default::default()
391 };
392 assert_eq!(
393 translate_metric_name(&metric, &MetricType::Gauge, NoUtf8EscapingWithSuffixes),
394 "cpu.utilization_ratio"
395 );
396
397 let metric = Metric {
398 name: "subtotal".to_string(),
399 ..Default::default()
400 };
401 assert_eq!(
402 translate_metric_name(
403 &metric,
404 &MetricType::MonotonicSum,
405 NoUtf8EscapingWithSuffixes
406 ),
407 "subtotal_total"
408 );
409
410 let metric = Metric {
411 name: "utilizationratio".to_string(),
412 unit: "1".to_string(),
413 ..Default::default()
414 };
415 assert_eq!(
416 translate_metric_name(&metric, &MetricType::Gauge, NoUtf8EscapingWithSuffixes),
417 "utilizationratio_ratio"
418 );
419 }
420
421 #[test]
422 fn test_translate_metric_name_prometheus_style_units_for_all_strategies() {
423 let cases = [
424 (
425 Metric {
426 name: "duration.latency".to_string(),
427 unit: "ms".to_string(),
428 ..Default::default()
429 },
430 MetricType::Gauge,
431 [
432 (
433 UnderscoreEscapingWithSuffixes,
434 "duration_latency_milliseconds",
435 ),
436 (UnderscoreEscapingWithoutSuffixes, "duration_latency"),
437 (NoUtf8EscapingWithSuffixes, "duration.latency_milliseconds"),
438 (NoTranslation, "duration.latency"),
439 ],
440 ),
441 (
442 Metric {
443 name: "disk.io".to_string(),
444 unit: "By".to_string(),
445 ..Default::default()
446 },
447 MetricType::MonotonicSum,
448 [
449 (UnderscoreEscapingWithSuffixes, "disk_io_bytes_total"),
450 (UnderscoreEscapingWithoutSuffixes, "disk_io"),
451 (NoUtf8EscapingWithSuffixes, "disk.io_bytes_total"),
452 (NoTranslation, "disk.io"),
453 ],
454 ),
455 (
456 Metric {
457 name: "cpu.utilization".to_string(),
458 unit: "%".to_string(),
459 ..Default::default()
460 },
461 MetricType::Gauge,
462 [
463 (UnderscoreEscapingWithSuffixes, "cpu_utilization_percent"),
464 (UnderscoreEscapingWithoutSuffixes, "cpu_utilization"),
465 (NoUtf8EscapingWithSuffixes, "cpu.utilization_percent"),
466 (NoTranslation, "cpu.utilization"),
467 ],
468 ),
469 (
470 Metric {
471 name: "request.rate".to_string(),
472 unit: "1/s".to_string(),
473 ..Default::default()
474 },
475 MetricType::MonotonicSum,
476 [
477 (
478 UnderscoreEscapingWithSuffixes,
479 "request_rate_per_second_total",
480 ),
481 (UnderscoreEscapingWithoutSuffixes, "request_rate"),
482 (NoUtf8EscapingWithSuffixes, "request.rate_per_second_total"),
483 (NoTranslation, "request.rate"),
484 ],
485 ),
486 (
487 Metric {
488 name: "queue.depth".to_string(),
489 unit: "{items}".to_string(),
490 ..Default::default()
491 },
492 MetricType::Gauge,
493 [
494 (UnderscoreEscapingWithSuffixes, "queue_depth"),
495 (UnderscoreEscapingWithoutSuffixes, "queue_depth"),
496 (NoUtf8EscapingWithSuffixes, "queue.depth"),
497 (NoTranslation, "queue.depth"),
498 ],
499 ),
500 ];
501
502 for (metric, metric_type, expectations) in cases {
503 for (strategy, expected) in expectations {
504 assert_eq!(
505 translate_metric_name(&metric, &metric_type, strategy),
506 expected,
507 "metric: {}, unit: {}, type: {:?}, strategy: {:?}",
508 metric.name,
509 metric.unit,
510 metric_type,
511 strategy
512 );
513 }
514 }
515 }
516
517 #[test]
518 fn test_translate_label_name_strategies() {
519 assert_eq!(
520 translate_label_name("service.name", UnderscoreEscapingWithSuffixes),
521 "service_name"
522 );
523 assert_eq!(
524 translate_label_name("_foo", UnderscoreEscapingWithoutSuffixes),
525 "key_foo"
526 );
527 assert_eq!(
528 translate_label_name("service.name", NoUtf8EscapingWithSuffixes),
529 "service.name"
530 );
531 assert_eq!(translate_label_name("_foo", NoTranslation), "_foo");
532 }
533
534 #[test]
535 fn test_clean_unit_name() {
536 assert_eq!(clean_unit_name("faults"), "faults");
537 assert_eq!(clean_unit_name("{faults}"), "faults");
538 assert_eq!(clean_unit_name("req/sec"), "req_sec");
539 assert_eq!(clean_unit_name("m/s"), "m_s");
540 assert_eq!(clean_unit_name("___test___"), "test");
541 assert_eq!(
542 clean_unit_name("multiple__underscores"),
543 "multiple_underscores"
544 );
545 assert_eq!(clean_unit_name(""), "");
546 assert_eq!(clean_unit_name("___"), "");
547 assert_eq!(clean_unit_name("bytes.per.second"), "bytes_per_second");
548 }
549}