Skip to main content

servers/otlp/metrics/
translator.rs

1// Copyright 2023 Greptime Team
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
15use 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        // 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        ("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
99// See https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/145942706622aba5c276ca47f48df438228bfea4/pkg/translator/prometheus/normalize_name.go#L55
100pub 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
222/// Converts a UCUM unit to its Prometheus/OpenMetrics unit word.
223pub(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
271// See https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/145942706622aba5c276ca47f48df438228bfea4/pkg/translator/prometheus/normalize_label.go#L27
272pub 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/// Normalize otlp instrumentation, metric and attribute names
290///
291/// <https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/metrics/api.md#instrument-name-syntax>
292/// - since the name are case-insensitive, we transform them to lowercase for
293///   better sql usability
294/// - replace `.` and `-` with `_`
295pub 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}