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
42pub(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
82fn 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
97fn 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 let input = ensure_trailing_newline(input);
251
252 let exposition = openmetrics_parser::prometheus::parse_prometheus(input.as_ref())
254 .map_err(|e| PrometheusDeserializationError::ParseError { message: e.to_string() })?;
255
256 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 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 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 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 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 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}