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#[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}