Skip to main content

torrust_metrics/metric_collection/
prometheus.rs

1use std::borrow::Cow;
2use std::sync::Arc;
3
4use torrust_clock::DurationSinceUnixEpoch;
5
6use crate::counter::Counter;
7use crate::gauge::Gauge;
8use crate::label::LabelSet;
9use crate::metric::description::MetricDescription;
10use crate::metric::{Metric, MetricName};
11use crate::metric_collection::{MetricCollection, MetricKindCollection};
12use crate::prometheus::{PrometheusDeserializable, PrometheusDeserializationError, PrometheusSerializable};
13use crate::sample::Sample;
14use crate::sample_collection::SampleCollection;
15
16const FIRST_UNREPRESENTABLE_U64_AS_F64: f64 = 18_446_744_073_709_551_616.0;
17
18struct ParsedExposition {
19    exposition: openmetrics_parser::MetricsExposition<openmetrics_parser::PrometheusType, openmetrics_parser::PrometheusValue>,
20    now: DurationSinceUnixEpoch,
21}
22
23impl PrometheusSerializable for MetricCollection {
24    fn to_prometheus(&self) -> String {
25        self.counters
26            .metrics
27            .values()
28            .filter(|metric| !metric.is_empty())
29            .map(Metric::<Counter>::to_prometheus)
30            .chain(
31                self.gauges
32                    .metrics
33                    .values()
34                    .filter(|metric| !metric.is_empty())
35                    .map(Metric::<Gauge>::to_prometheus),
36            )
37            .collect::<Vec<String>>()
38            .join("\n\n")
39    }
40}
41
42/// Converts a Prometheus timestamp (seconds since Unix epoch as `f64`) to a
43/// `DurationSinceUnixEpoch`.
44///
45/// Returns `None` when `t` is non-finite, negative, or out of range.
46pub(super) fn parse_prometheus_timestamp(t: f64) -> Option<DurationSinceUnixEpoch> {
47    if t.is_finite() && t >= 0.0 {
48        if t.trunc() >= FIRST_UNREPRESENTABLE_U64_AS_F64 {
49            return None;
50        }
51
52        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
53        let secs = t.trunc() as u64;
54        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
55        let nanos = ((t - t.trunc()) * 1_000_000_000.0).round() as u32;
56        let (secs, nanos) = if nanos >= 1_000_000_000 {
57            let next_secs = secs.checked_add(1)?;
58            (next_secs, nanos - 1_000_000_000)
59        } else {
60            (secs, nanos)
61        };
62        Some(DurationSinceUnixEpoch::new(secs, nanos))
63    } else {
64        None
65    }
66}
67
68pub(super) fn build_sample_collection<T>(samples: Vec<Sample<T>>) -> Result<SampleCollection<T>, PrometheusDeserializationError> {
69    Ok(SampleCollection::new(samples)?)
70}
71
72pub(super) fn build_metric_collection(
73    counter_metrics: Vec<Metric<Counter>>,
74    gauge_metrics: Vec<Metric<Gauge>>,
75) -> Result<MetricCollection, PrometheusDeserializationError> {
76    let counters = MetricKindCollection::new(counter_metrics)?;
77    let gauges = MetricKindCollection::new(gauge_metrics)?;
78
79    Ok(MetricCollection::new(counters, gauges)?)
80}
81
82/// Converts an `openmetrics_parser::LabelSet` to our `LabelSet`, remapping
83/// any `LabelConversion` error to include the owning `family_name`.
84fn convert_openmetrics_label_set(
85    family_name: &str,
86    parser_label_set: openmetrics_parser::LabelSet<'_>,
87) -> Result<LabelSet, PrometheusDeserializationError> {
88    LabelSet::try_from(parser_label_set).map_err(|e| match e {
89        PrometheusDeserializationError::LabelConversion { message, .. } => PrometheusDeserializationError::LabelConversion {
90            metric_name: family_name.to_owned(),
91            message,
92        },
93        other => other,
94    })
95}
96
97/// Returns `true` if `v` is a non-negative, whole number that fits in a `u64`.
98fn is_whole_u64_representable(v: f64) -> bool {
99    v.is_finite() && v >= 0.0 && v.fract() == 0.0 && v < FIRST_UNREPRESENTABLE_U64_AS_F64
100}
101
102fn counter_integer_mismatch(family_name: &str, actual: String) -> PrometheusDeserializationError {
103    PrometheusDeserializationError::ValueMismatch {
104        metric_name: family_name.to_owned(),
105        expected_type: "counter (non-negative integer)".to_owned(),
106        actual,
107    }
108}
109
110fn description_from_help(help: &str) -> Option<MetricDescription> {
111    if help.is_empty() { None } else { Some(help.into()) }
112}
113
114fn ensure_trailing_newline(input: &str) -> Cow<'_, str> {
115    if input.ends_with('\n') {
116        Cow::Borrowed(input)
117    } else {
118        Cow::Owned(format!("{input}\n"))
119    }
120}
121
122trait FromPrometheusValue: Sized {
123    fn from_prometheus_value(
124        family_name: &str,
125        value: &openmetrics_parser::PrometheusValue,
126    ) -> Result<Self, PrometheusDeserializationError>;
127}
128
129impl FromPrometheusValue for Counter {
130    fn from_prometheus_value(
131        family_name: &str,
132        prom_value: &openmetrics_parser::PrometheusValue,
133    ) -> Result<Self, PrometheusDeserializationError> {
134        match prom_value {
135            openmetrics_parser::PrometheusValue::Counter(c) => {
136                let counter = match c.value {
137                    openmetrics_parser::MetricNumber::Int(value) => match u64::try_from(value) {
138                        Ok(value) => Counter::new(value),
139                        Err(_) => {
140                            return Err(counter_integer_mismatch(family_name, c.value.to_string()));
141                        }
142                    },
143                    openmetrics_parser::MetricNumber::Float(value) if is_whole_u64_representable(value) =>
144                    {
145                        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
146                        Counter::new(value as u64)
147                    }
148                    openmetrics_parser::MetricNumber::Float(_) => {
149                        return Err(counter_integer_mismatch(family_name, c.value.to_string()));
150                    }
151                };
152
153                Ok(counter)
154            }
155            openmetrics_parser::PrometheusValue::Unknown(_) => Err(PrometheusDeserializationError::UnknownValue {
156                metric_name: family_name.to_owned(),
157            }),
158            other => Err(PrometheusDeserializationError::ValueMismatch {
159                metric_name: family_name.to_owned(),
160                expected_type: "counter".to_owned(),
161                actual: format!("{other:?}"),
162            }),
163        }
164    }
165}
166
167impl FromPrometheusValue for Gauge {
168    fn from_prometheus_value(
169        family_name: &str,
170        prom_value: &openmetrics_parser::PrometheusValue,
171    ) -> Result<Self, PrometheusDeserializationError> {
172        match prom_value {
173            openmetrics_parser::PrometheusValue::Gauge(n) => Ok(Gauge::new(n.as_f64())),
174            openmetrics_parser::PrometheusValue::Unknown(_) => Err(PrometheusDeserializationError::UnknownValue {
175                metric_name: family_name.to_owned(),
176            }),
177            other => Err(PrometheusDeserializationError::ValueMismatch {
178                metric_name: family_name.to_owned(),
179                expected_type: "gauge".to_owned(),
180                actual: format!("{other:?}"),
181            }),
182        }
183    }
184}
185
186fn parse_family_samples<T: FromPrometheusValue>(
187    family_name: &str,
188    family: &openmetrics_parser::PrometheusMetricFamily,
189    now: DurationSinceUnixEpoch,
190) -> Result<Metric<T>, PrometheusDeserializationError> {
191    let label_names = Arc::new(family.get_label_names().to_vec());
192    let mut samples = Vec::new();
193
194    for parser_sample in family.iter_samples() {
195        let parser_label_set = openmetrics_parser::LabelSet::new(Arc::clone(&label_names), parser_sample).map_err(|e| {
196            PrometheusDeserializationError::LabelConversion {
197                metric_name: family_name.to_owned(),
198                message: e.to_string(),
199            }
200        })?;
201        let label_set = convert_openmetrics_label_set(family_name, parser_label_set)?;
202        let value = T::from_prometheus_value(family_name, &parser_sample.value)?;
203        let time = parser_sample.timestamp.and_then(parse_prometheus_timestamp).unwrap_or(now);
204        samples.push(Sample::new(value, time, label_set));
205    }
206
207    let metric_name = MetricName::new(family_name);
208    let description = description_from_help(&family.help);
209    Ok(Metric::new(metric_name, None, description, build_sample_collection(samples)?))
210}
211
212impl TryFrom<ParsedExposition> for MetricCollection {
213    type Error = PrometheusDeserializationError;
214
215    fn try_from(parsed: ParsedExposition) -> Result<Self, Self::Error> {
216        let ParsedExposition { exposition, now } = parsed;
217
218        let mut counter_metrics: Vec<Metric<Counter>> = Vec::new();
219        let mut gauge_metrics: Vec<Metric<Gauge>> = Vec::new();
220
221        for (family_name, family) in &exposition.families {
222            match family.family_type {
223                openmetrics_parser::PrometheusType::Counter => {
224                    counter_metrics.push(parse_family_samples::<Counter>(family_name, family, now)?);
225                }
226                openmetrics_parser::PrometheusType::Gauge => {
227                    gauge_metrics.push(parse_family_samples::<Gauge>(family_name, family, now)?);
228                }
229                openmetrics_parser::PrometheusType::Histogram | openmetrics_parser::PrometheusType::Summary => {
230                    return Err(PrometheusDeserializationError::UnsupportedType {
231                        metric_name: family_name.clone(),
232                        metric_type: family.family_type.to_string(),
233                    });
234                }
235                openmetrics_parser::PrometheusType::Unknown => {
236                    return Err(PrometheusDeserializationError::UnknownType {
237                        metric_name: family_name.clone(),
238                    });
239                }
240            }
241        }
242
243        build_metric_collection(counter_metrics, gauge_metrics)
244    }
245}
246
247impl PrometheusDeserializable for MetricCollection {
248    fn from_prometheus(input: &str, now: DurationSinceUnixEpoch) -> Result<Self, PrometheusDeserializationError> {
249        // Stage 1 (Normalize): Ensure trailing newline
250        let input = ensure_trailing_newline(input);
251
252        // Stage 2 (Parse): Text → PrometheusExposition
253        let exposition = openmetrics_parser::prometheus::parse_prometheus(input.as_ref())
254            .map_err(|e| PrometheusDeserializationError::ParseError { message: e.to_string() })?;
255
256        // Stage 3 (Convert): PrometheusExposition → MetricCollection
257        MetricCollection::try_from(ParsedExposition { exposition, now })
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    mod helper_functions {
264        use std::borrow::Cow;
265
266        use super::super::{description_from_help, ensure_trailing_newline};
267        use crate::metric::description::MetricDescription;
268
269        #[test]
270        fn ensure_trailing_newline_returns_borrowed_when_input_has_newline() {
271            let input = "# TYPE hits_total counter\n";
272            let result = ensure_trailing_newline(input);
273
274            assert!(matches!(result, Cow::Borrowed(_)));
275            assert_eq!(result.as_ref(), input);
276        }
277
278        #[test]
279        fn ensure_trailing_newline_returns_owned_when_input_missing_newline() {
280            let input = "# TYPE hits_total counter";
281            let result = ensure_trailing_newline(input);
282
283            assert!(matches!(result, Cow::Owned(_)));
284            assert_eq!(result.as_ref(), "# TYPE hits_total counter\n");
285        }
286
287        #[test]
288        fn description_from_help_returns_none_for_empty_help() {
289            assert_eq!(description_from_help(""), None);
290        }
291
292        #[test]
293        fn description_from_help_returns_some_for_non_empty_help() {
294            assert_eq!(
295                description_from_help("The total number of requests."),
296                Some(MetricDescription::new("The total number of requests."))
297            );
298        }
299    }
300
301    mod stage3_conversion {
302        use torrust_clock::DurationSinceUnixEpoch;
303
304        use super::super::ParsedExposition;
305        use crate::counter::Counter;
306        use crate::label::LabelSet;
307        use crate::metric_collection::MetricCollection;
308        use crate::metric_name;
309        use crate::prometheus::{PrometheusDeserializable, PrometheusDeserializationError};
310
311        #[test]
312        fn try_from_parsed_exposition_should_convert_counter_family() {
313            let now = DurationSinceUnixEpoch::from_secs(1_000);
314            let input = "# TYPE requests_total counter\nrequests_total 42\n";
315            let exposition =
316                openmetrics_parser::prometheus::parse_prometheus(input).expect("exposition should parse for stage-3 test");
317
318            let result =
319                MetricCollection::try_from(ParsedExposition { exposition, now }).expect("stage-3 conversion should work");
320
321            let value = result
322                .get_counter_value(&metric_name!("requests_total"), &LabelSet::empty())
323                .expect("counter should be present");
324
325            assert_eq!(value, Counter::new(42));
326        }
327
328        #[test]
329        fn try_from_parsed_exposition_should_reject_unsupported_histogram() {
330            let now = DurationSinceUnixEpoch::from_secs(0);
331            let input = "# TYPE latency histogram\nlatency_bucket{le=\"0.1\"} 5\nlatency_bucket{le=\"+Inf\"} 10\nlatency_sum 1.5\nlatency_count 10\n";
332            let exposition =
333                openmetrics_parser::prometheus::parse_prometheus(input).expect("exposition should parse for stage-3 test");
334
335            let result = MetricCollection::try_from(ParsedExposition { exposition, now });
336
337            assert!(matches!(result, Err(PrometheusDeserializationError::UnsupportedType { .. })));
338        }
339
340        #[test]
341        fn from_prometheus_and_stage3_try_from_should_produce_same_output() {
342            let now = DurationSinceUnixEpoch::from_secs(1_000);
343            let input = "# TYPE requests_total counter\nrequests_total{method=\"get\"} 42\n";
344
345            let from_text = MetricCollection::from_prometheus(input, now).expect("from_prometheus should parse");
346
347            let exposition =
348                openmetrics_parser::prometheus::parse_prometheus(input).expect("exposition should parse for stage-3 test");
349            let from_stage3 =
350                MetricCollection::try_from(ParsedExposition { exposition, now }).expect("stage-3 conversion should work");
351
352            assert_eq!(from_text, from_stage3);
353        }
354    }
355
356    mod prometheus_timestamp {
357        use torrust_clock::DurationSinceUnixEpoch;
358
359        use super::super::parse_prometheus_timestamp;
360
361        #[test]
362        fn it_should_convert_a_whole_second_timestamp() {
363            let result = parse_prometheus_timestamp(1_000.0);
364            assert_eq!(result, Some(DurationSinceUnixEpoch::from_secs(1_000)));
365        }
366
367        #[test]
368        fn it_should_convert_a_fractional_timestamp() {
369            let result = parse_prometheus_timestamp(1.5);
370            approx::assert_abs_diff_eq!(result.expect("should convert timestamp").as_secs_f64(), 1.5, epsilon = 1e-9);
371        }
372
373        #[test]
374        fn it_should_use_fallback_for_negative_timestamp() {
375            let result = parse_prometheus_timestamp(-1.0);
376            assert_eq!(result, None);
377        }
378
379        #[test]
380        fn it_should_use_fallback_for_nan() {
381            let result = parse_prometheus_timestamp(f64::NAN);
382            assert_eq!(result, None);
383        }
384
385        #[test]
386        fn it_should_use_fallback_for_positive_infinity() {
387            let result = parse_prometheus_timestamp(f64::INFINITY);
388            assert_eq!(result, None);
389        }
390
391        #[test]
392        fn it_should_use_fallback_for_negative_infinity() {
393            let result = parse_prometheus_timestamp(f64::NEG_INFINITY);
394            assert_eq!(result, None);
395        }
396
397        #[test]
398        fn it_should_use_fallback_when_timestamp_would_overflow_u64_seconds() {
399            const FIRST_UNREPRESENTABLE_U64_AS_F64: f64 = 18_446_744_073_709_551_616.0;
400            let result = parse_prometheus_timestamp(FIRST_UNREPRESENTABLE_U64_AS_F64);
401            assert_eq!(result, None);
402        }
403
404        #[test]
405        fn it_should_handle_nanosecond_boundary_overflow() {
406            // 0.9999999995 * 1e9 rounds to exactly 1_000_000_000 nanos, triggering
407            // a carry: secs becomes 2, nanos becomes 0. Use exact equality so that
408            // the mutant `nanos / 1_000_000_000` (= 1 ns) is caught.
409            let result = parse_prometheus_timestamp(1.999_999_999_5);
410            assert_eq!(result, Some(DurationSinceUnixEpoch::from_secs(2)));
411        }
412
413        #[test]
414        fn it_should_convert_zero_timestamp() {
415            let result = parse_prometheus_timestamp(0.0);
416            assert_eq!(result, Some(DurationSinceUnixEpoch::from_secs(0)));
417        }
418    }
419
420    mod prometheus_deserialization {
421        use torrust_clock::DurationSinceUnixEpoch;
422
423        use super::super::build_metric_collection;
424        use crate::counter::Counter;
425        use crate::gauge::Gauge;
426        use crate::label::{LabelSet, LabelValue};
427        use crate::metric::Metric;
428        use crate::metric::description::MetricDescription;
429        use crate::metric_collection::{MetricCollection, MetricKindCollection};
430        use crate::prometheus::{PrometheusDeserializable, PrometheusDeserializationError, PrometheusSerializable};
431        use crate::sample::Sample;
432        use crate::sample_collection::SampleCollection;
433        use crate::{label_name, metric_name};
434
435        #[test]
436        fn it_should_deserialize_a_counter_metric_from_prometheus_text() {
437            let now = DurationSinceUnixEpoch::from_secs(1_000);
438            let input = "# HELP requests_total The total number of requests.\n# TYPE requests_total counter\nrequests_total{method=\"get\"} 42\n";
439
440            let result = MetricCollection::from_prometheus(input, now).expect("should parse successfully");
441
442            let label_set = [(label_name!("method"), LabelValue::new("get"))].into();
443
444            let expected_value = result
445                .get_counter_value(&metric_name!("requests_total"), &label_set)
446                .expect("counter should be present");
447
448            assert_eq!(expected_value, Counter::new(42));
449        }
450
451        #[test]
452        fn it_should_deserialize_a_gauge_metric_from_prometheus_text() {
453            let now = DurationSinceUnixEpoch::from_secs(1_000);
454            let input = "# HELP temperature Current temperature.\n# TYPE temperature gauge\ntemperature{room=\"kitchen\"} 21.5\n";
455
456            let result = MetricCollection::from_prometheus(input, now).expect("should parse successfully");
457
458            let label_set = [(label_name!("room"), LabelValue::new("kitchen"))].into();
459
460            let expected_value = result
461                .get_gauge_value(&metric_name!("temperature"), &label_set)
462                .expect("gauge should be present");
463
464            assert_eq!(expected_value, Gauge::new(21.5));
465        }
466
467        #[test]
468        fn it_should_round_trip_serialize_then_deserialize_prometheus_text() {
469            let time = DurationSinceUnixEpoch::from_secs(1_743_552_000);
470
471            let label_set_1 = [
472                (label_name!("server_binding_protocol"), LabelValue::new("http")),
473                (label_name!("server_binding_ip"), LabelValue::new("0.0.0.0")),
474                (label_name!("server_binding_port"), LabelValue::new("7070")),
475            ]
476            .into();
477
478            let original = MetricCollection::new(
479                MetricKindCollection::new(vec![Metric::new(
480                    metric_name!("http_tracker_core_announce_requests_received_total"),
481                    None,
482                    Some(MetricDescription::new("The number of announce requests received.")),
483                    SampleCollection::new(vec![Sample::new(Counter::new(1), time, label_set_1)]).unwrap(),
484                )])
485                .unwrap(),
486                MetricKindCollection::default(),
487            )
488            .unwrap();
489
490            let prometheus_text = original.to_prometheus();
491            let deserialized =
492                MetricCollection::from_prometheus(&prometheus_text, time).expect("round-trip deserialization should succeed");
493
494            assert_eq!(original, deserialized);
495        }
496
497        #[test]
498        fn it_should_return_unsupported_type_for_histogram() {
499            let now = DurationSinceUnixEpoch::from_secs(0);
500            let input = "# TYPE latency histogram\nlatency_bucket{le=\"0.1\"} 5\nlatency_bucket{le=\"+Inf\"} 10\nlatency_sum 1.5\nlatency_count 10\n";
501
502            let result = MetricCollection::from_prometheus(input, now);
503
504            assert!(matches!(result, Err(PrometheusDeserializationError::UnsupportedType { .. })));
505        }
506
507        #[test]
508        fn it_should_return_parse_error_for_malformed_input() {
509            let now = DurationSinceUnixEpoch::from_secs(0);
510            // An invalid TYPE declaration (missing type name) causes a parse error
511            let input = "# TYPE\n";
512
513            let result = MetricCollection::from_prometheus(input, now);
514
515            assert!(matches!(result, Err(PrometheusDeserializationError::ParseError { .. })));
516        }
517
518        #[test]
519        fn it_should_use_fallback_timestamp_when_sample_has_no_timestamp() {
520            let now = DurationSinceUnixEpoch::from_secs(9_999);
521            let input = "# TYPE hits_total counter\nhits_total 7\n";
522
523            let result = MetricCollection::from_prometheus(input, now).expect("should parse");
524
525            let label_set = LabelSet::empty();
526            let value = result
527                .get_counter_value(&metric_name!("hits_total"), &label_set)
528                .expect("counter should be present");
529
530            assert_eq!(value, Counter::new(7));
531        }
532
533        #[test]
534        fn it_should_reject_fractional_counter_values() {
535            let now = DurationSinceUnixEpoch::from_secs(1_000);
536            let input = "# TYPE requests_total counter\nrequests_total 42.5\n";
537
538            let result = MetricCollection::from_prometheus(input, now);
539
540            assert!(matches!(result, Err(PrometheusDeserializationError::ValueMismatch { .. })));
541        }
542
543        #[test]
544        fn it_should_classify_duplicate_metric_names_as_collection_errors() {
545            let label_set = LabelSet::empty();
546            let time = DurationSinceUnixEpoch::from_secs(1_000);
547            let counter_metrics = vec![
548                Metric::new(
549                    metric_name!("requests_total"),
550                    None,
551                    None,
552                    SampleCollection::new(vec![Sample::new(Counter::new(1), time, label_set.clone())]).unwrap(),
553                ),
554                Metric::new(
555                    metric_name!("requests_total"),
556                    None,
557                    None,
558                    SampleCollection::new(vec![Sample::new(Counter::new(2), time, label_set)]).unwrap(),
559                ),
560            ];
561
562            let result = build_metric_collection(counter_metrics, Vec::new());
563
564            assert!(matches!(result, Err(PrometheusDeserializationError::CollectionError { .. })));
565        }
566
567        #[test]
568        fn it_should_accept_a_counter_value_that_is_a_whole_number_float() {
569            // A counter value written as a float with no fractional part (e.g. "42.0")
570            // must be accepted and treated as the integer 42. This test catches
571            // mutations that corrupt the float-counter match guard by replacing it
572            // with `false` or inverting the `>= 0.0` / `< MAX` checks.
573            let now = DurationSinceUnixEpoch::from_secs(1_000);
574            let input = "# TYPE requests_total counter\nrequests_total 42.0\n";
575
576            let result = MetricCollection::from_prometheus(input, now).expect("should parse successfully");
577
578            let label_set = LabelSet::empty();
579            let value = result
580                .get_counter_value(&metric_name!("requests_total"), &label_set)
581                .expect("counter should be present");
582
583            assert_eq!(value, Counter::new(42));
584        }
585
586        #[test]
587        fn it_should_reject_a_float_counter_value_equal_to_first_unrepresentable_u64() {
588            // 18_446_744_073_709_551_616.0 == 2^64, the first f64 that cannot be
589            // safely cast to u64. The guard `value < FIRST_UNREPRESENTABLE_U64_AS_F64`
590            // must be strict (<), not <=. This test catches the `<` → `<=` mutation.
591            let now = DurationSinceUnixEpoch::from_secs(1_000);
592            let input = "# TYPE requests_total counter\nrequests_total 18446744073709551616.0\n";
593
594            let result = MetricCollection::from_prometheus(input, now);
595
596            assert!(
597                matches!(result, Err(PrometheusDeserializationError::ValueMismatch { .. })),
598                "expected ValueMismatch, got {result:?}"
599            );
600        }
601
602        #[test]
603        fn it_should_return_unknown_type_error_when_no_type_declaration_is_present() {
604            let now = DurationSinceUnixEpoch::from_secs(0);
605            // No # TYPE line → the parser assigns type Unknown, which triggers
606            // the PrometheusType::Unknown arm and returns UnknownType error.
607            let input = "hits_total 7\n";
608
609            let result = MetricCollection::from_prometheus(input, now);
610
611            assert!(matches!(result, Err(PrometheusDeserializationError::UnknownType { .. })));
612        }
613    }
614}